Механизм обработчиков — часть системы кастомизации Timetta. Подробнее о принципах работы и структуре — Обработчик событий сущности.
Ниже рассмотрены примеры кода, которые показывают, как можно настроить логику работы данных с помощью обработчиков.
Важно
Примеры предназначены для разработчиков и администраторов. Предполагают знание языка C# и понимание структуры данных системы Timetta.
Один из популярных сценариев работы — использование читаемых и структурированных номеров проектов, сделок и других сущностей. Например:
IT-2025-FIN-007
;PP-2024-TRANS-105
;НИОКР-2024-ХИМ-028
;ГОСТ-2024-ЭКО-112
.В Timetta можно автоматически генерировать такие номера с помощью хуков сущностей.
Ниже приведён пример обработчика, который сформирует для сущности Project номер в формате PR-[BiilingType]-[Index]. Для хранения порядкового номера необходимо создать дополнительное поле с типом «целое число» (например, IntegerValue1). Это поле будет использоваться в качестве индекса в итоговом коде.
public class CustomHooks : EntityTypeCustomHooks<Project>
{
public override async Task AfterUpsert(
CustomHooksContext context,
Project contextEntity,
Project detachedEntity
)
{
// Обновляем если Код пустой.
if (string.IsNullOrWhiteSpace(contextEntity.Code))
{
var billingTypeService = context.GetEntityService<ProjectBillingType>();
var billingType = await billingTypeService
.Get(bt => bt.Id == contextEntity.BillingTypeId)
.FirstOrDefaultAsync();
var projectService = context.GetEntityService<Project>();
var maxRowNumber = await projectService
.Get(project => true, checkRights: false)
.MaxAsync(p => p.IntegerValue1);
if (context.OperationType == EntityServiceOperation.Insert)
contextEntity.IntegerValue1 = (maxRowNumber ?? 0) + 1;
var billingTypeCode = billingType?.Code?.ToUpper() ?? "NONE";
var rowNumber = contextEntity.IntegerValue1?.ToString("D5") ?? "NONE";
// Шаблон: PROJECT-[BillingType]-[Index]
contextEntity.Code = $"PROJECT-{billingTypeCode}-{rowNumber}";
}
}
}
Рекомендации:
В Timetta необязательно вручную отслеживать, к каким продуктам у пользователя должен быть доступ. Можно настроить автоматическое назначение лицензии на основе групповой принадлежности пользователя.
Обработчик, приведённый ниже, изменит логику следующим образом: при добавлении пользователя в группу, если не превышен лимит, ему будет автоматически назначаться лицензия на продукт. При исключении из группы лицензия будет удаляться.
Соотношение Код группы -> Имя продукта задаётся в обработчике списком в виде константы.
public class CustomHooks : EntityTypeCustomHooks<UserGroup>
{
// Настройка Группа (наименование) - какие лицензии назначать.
// LicenseProduct.Projects, LicenseProduct.TimeTracking, LicenseProduct.Expenses,
// LicenseProduct.Resources, LicenseProduct.Finance, LicenseProduct.Billing,
// Clients = 1.
private static readonly Dictionary<string, LicenseProduct> GroupToLicense = new()
{
{ "Группа 1", LicenseProduct.Projects }
};
public override async Task AfterUpsert(
CustomHooksContext context,
UserGroup contextEntity,
UserGroup detachedEntity
)
{
if (context.OperationType != EntityServiceOperation.Insert)
return;
var groupLicense = await GetGroupLicense(context, contextEntity);
if (groupLicense == null)
return;
var userProductService = context.GetEntityService<UserProduct>();
var alreadyHasLicense = await userProductService
.Get(up => up.UserId == contextEntity.UserId)
.Where(up => up.Product == groupLicense)
.AnyAsync();
if (alreadyHasLicense)
return;
var limitReached = await IsLimitReached(context, contextEntity, groupLicense);
if (limitReached)
return;
var userProduct = new UserProduct { UserId = contextEntity.UserId, Product = groupLicense };
await userProductService.InsertAsync(userProduct);
}
public override async Task AfterDelete(CustomHooksContext context, UserGroup contextEntity)
{
var groupLicense = await GetGroupLicense(context, contextEntity);
if (groupLicense == null)
return;
var userProductService = context.GetEntityService<UserProduct>();
var userProduct = await userProductService
.Get(up => up.Product == groupLicense)
.FirstOrDefaultAsync(up => up.UserId == contextEntity.UserId);
if (userProduct != null)
await userProductService.DeleteAsync(userProduct.Id);
}
private async Task<LicenseProduct> GetGroupLicense(
CustomHooksContext context,
UserGroup userGroup
)
{
var groupService = context.GetEntityService<Group>();
var group = await groupService.Get(g => g.Id == userGroup.GroupId).FirstAsync();
GroupToLicense.TryGetValue(group.Name, out var groupLicense);
return groupLicense;
}
private async Task<bool> IsLimitReached(
CustomHooksContext context,
UserGroup userGroup,
LicenseProduct license
)
{
var licenseLimits = await context.GetLicenseProductLimitsAsync();
if (!licenseLimits.TryGetValue(license, out var licenseLimit))
return false;
var userGroupService = context.GetEntityService<UserGroup>();
var groupUserIds = await userGroupService
.Get(ug => ug.GroupId == userGroup.GroupId)
.Select(ug => ug.UserId)
.ToListAsync();
var userProductService = context.GetEntityService<UserProduct>();
await userProductService.Get(up => up.Product == license).LockAsync();
var licenseCount = await userProductService
.Get(up => up.Product == license)
.CountAsync();
return licenseCount >= licenseLimit;
}
}
Один из распространённых сценариев в процессе продаж — переход сделки из одного типа в другой. Так, предпродажные активности в случае успеха становятся полноценным тёплым лидом. Для сохранения и передачи всей информации, полученной при первом контакте с клиентом, при переходе от одного типа продаж к другому можно настроить специальный обработчик. Его принцип работы:
public class CustomHooks : EntityTypeCustomHooks<Deal>
{
private const string WarmupDealTypeCode = "WARM";
private const string SalesDealTypeCode = "SALES";
private const string SalesDealStateCode = "NEW";
public override async Task AfterSetState(
CustomHooksContext context,
Deal entity,
Guid oldStateId,
Guid newStateId
)
{
// NOTE: Handle only warmups
var directoryEntryService = context.GetEntityService<DirectoryEntry>();
var dealType = await directoryEntryService.GetAsync(entity.TypeId, checkRights: false);
if (dealType.Code != WarmupDealTypeCode)
return;
// NOTE: If warmup was won - proceed
if (newStateId != Deal.WonState.Id)
return;
await CreateSalesDeal(context, entity);
}
private async Task CreateSalesDeal(CustomHooksContext context, Deal from)
{
var directoryEntryService = context.GetEntityService<DirectoryEntry>();
var salesDealType = await directoryEntryService
.Get(de => de.Code == SalesDealTypeCode)
.FirstAsync();
var lifecycleService = context.GetEntityService<Lifecycle>();
var dealLifecycle = await lifecycleService
.Get(lc => lc.EntityType == nameof(Deal))
.Include(lc => lc.States)
.FirstAsync();
var salesDealState = dealLifecycle.States.First(s => s.Code == SalesDealStateCode);
// NOTE: Create "Sales" deal
var salesDeal = new Deal
{
TypeId = salesDealType.Id,
StateId = salesDealState.Id,
ProjectId = from.ProjectId,
ManagerId = from.ManagerId,
OrganizationId = from.OrganizationId,
SourceId = from.SourceId,
Name = from.Name,
Code = from.Code,
Description = from.Description,
Amount = from.Amount,
Probability = from.Probability,
CheckList = from.CheckList,
};
salesDeal.CopyCustomFields(from);
var dealService = context.GetEntityService<Deal>();
await dealService.InsertAsync(salesDeal);
// NOTE: Copy DealContacts
var dealContactService = context.GetEntityService<DealContact>();
var dealContacts = await dealContactService.Get(dc => dc.DealId == from.Id).ToListAsync();
foreach (var dealContact in dealContacts)
{
var salesDealContact = new DealContact
{
DealId = salesDeal.Id,
ContactId = dealContact.ContactId,
Description = dealContact.Description,
};
await dealContactService.InsertAsync(salesDealContact);
}
// NOTE: Copy interactions
var interactionService = context.GetEntityService<Interaction>();
var dealInteractions = await interactionService
.Get(dc => dc.DealId == from.Id)
.ToListAsync();
foreach (var dealInteraction in dealInteractions)
{
var salesDealInteraction = new Interaction
{
DealId = salesDeal.Id,
Description = dealInteraction.Description,
TypeId = dealInteraction.TypeId,
Date = dealInteraction.Date,
Email = dealInteraction.Email,
IsPlanned = dealInteraction.IsPlanned,
IsSent = dealInteraction.IsSent,
OrganizationId = dealInteraction.OrganizationId,
PerformerId = dealInteraction.PerformerId,
SendAutomatically = dealInteraction.SendAutomatically,
Subject = dealInteraction.Subject,
};
await interactionService.InsertAsync(salesDealInteraction);
// NOTE: Copy interaction contacts
var contactInteractionService = context.GetEntityService<ContactInteraction>();
var contactInteractions = await contactInteractionService
.Get(ci => ci.InteractionId == dealInteraction.Id)
.ToListAsync();
foreach (var contactInteraction in contactInteractions)
{
var salesContactInteraction = new ContactInteraction
{
InteractionId = salesDealInteraction.Id,
ContactId = contactInteraction.ContactId,
};
await contactInteractionService.InsertAsync(salesContactInteraction);
}
}
}
}
Перейти на русскую версию?