diff --git a/backend/Tnsu.ApprovalRoute.Api/appsettings.json b/backend/Tnsu.ApprovalRoute.Api/appsettings.json index 8062415..ef7f102 100644 --- a/backend/Tnsu.ApprovalRoute.Api/appsettings.json +++ b/backend/Tnsu.ApprovalRoute.Api/appsettings.json @@ -34,6 +34,7 @@ "ClientId": "9232d9e2-9807-4753-808f-07250e5474b0", "ClientSecret": "", "Scope": "", - "DirectorTansuEmail": "a.samayeva@tnsu.kz" + "DirectorTansuEmail": "a.samayeva@tnsu.kz", + "ManagingDirectorTansuEmail": "nkim@tnsu.kz" } } diff --git a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs index 3d210d4..d29eea0 100644 --- a/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs +++ b/backend/Tnsu.ApprovalRoute.Application/TemplateRoutes/TemplateRouteService.cs @@ -163,6 +163,11 @@ public sealed class TemplateRouteService( .GroupBy(f => f.FieldName, StringComparer.OrdinalIgnoreCase) .ToDictionary(g => g.Key, g => g.First().Value, StringComparer.OrdinalIgnoreCase); + // Синтетическое поле условия (не пришедшее из request.FieldValues, а вычисленное + // здесь): должность в справочнике — свободный текст, поэтому «руководитель или + // нет» определяем по ключевым словам в её названии, а не по отдельному флагу. + fieldValues["IsManager"] = IsManagerPosition(employee.Position) ? "true" : "false"; + var matchingSteps = route.Steps .Where(s => s.Status == request.Status) .Where(s => MatchesCondition(s, fieldValues)) @@ -264,6 +269,7 @@ public sealed class TemplateRouteService( HeadType.Chief => await portalClient.GetManagerAsync(initiatorEmail, "chief", cancellationToken), HeadType.DirectionHead => await portalClient.GetManagerAsync(initiatorEmail, "directionHead", cancellationToken), HeadType.DirectorTansu => portalOptions.Value.DirectorTansuEmail, + HeadType.ManagingDirectorTansu => portalOptions.Value.ManagingDirectorTansuEmail, _ => null }; if (string.IsNullOrWhiteSpace(resolvedEmail)) @@ -328,6 +334,21 @@ public sealed class TemplateRouteService( private static bool TryParseNumber(string? value, out double number) => double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out number); + /// + /// Должность в справочнике api.tnsu.kz — свободный текст без структуры (нет + /// отдельного флага «это руководитель»), поэтому определяем это по ключевым + /// словам в названии должности. Список подобран по фактическим должностям + /// в справочнике — Начальник/Руководитель/Директор/Заместитель/Главный/ + /// Заведующий складом. «Ведущий», «Старший» и «Менеджер» намеренно не входят: + /// это старший специалист, а не должность с подчинёнными. + /// + private static readonly string[] ManagerPositionKeywords = + ["начальник", "руководитель", "директор", "заместитель", "главный", "заведующий склад"]; + + private static bool IsManagerPosition(string? position) => + !string.IsNullOrWhiteSpace(position) && + ManagerPositionKeywords.Any(keyword => position.Contains(keyword, StringComparison.OrdinalIgnoreCase)); + private static IReadOnlyList CollapseByExecutorEmail(IReadOnlyList steps) { var seenEmails = new HashSet(StringComparer.OrdinalIgnoreCase); diff --git a/backend/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs b/backend/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs index 13ae882..6dacd1d 100644 --- a/backend/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs +++ b/backend/Tnsu.ApprovalRoute.Domain/Enums/HeadType.cs @@ -6,6 +6,7 @@ public enum HeadType Chief, DirectionHead, DirectorTansu, + ManagingDirectorTansu, /// /// Руководитель отдела, указанного явно в запросе на resolve (см. diff --git a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalOptions.cs b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalOptions.cs index a0adf35..b95b2f6 100644 --- a/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalOptions.cs +++ b/backend/Tnsu.ApprovalRoute.Infrastructure/TnsuPortal/TnsuPortalOptions.cs @@ -8,4 +8,5 @@ public sealed class TnsuPortalOptions public string ClientSecret { get; set; } = string.Empty; public string Scope { get; set; } = string.Empty; public string DirectorTansuEmail { get; set; } = "director.tansu@tnsu.kz"; + public string ManagingDirectorTansuEmail { get; set; } = "nkim@tnsu.kz"; } diff --git a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx index 43b1643..80b73fb 100644 --- a/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx +++ b/frontend/src/webparts/approvalAdmin/components/screens/RouteStepFormModal.tsx @@ -76,6 +76,18 @@ const OPERATION_OPTIONS = Object.values(ComparisonOperator) .filter(v => v !== ComparisonOperator.None) .map(v => ({ value: v, label: comparisonOperatorLabels[v] })); +/** + * Служебное поле условия, не привязанное к списку SharePoint — вычисляется на бэкенде + * из должности инициатора (см. TemplateRouteService.ResolveAsync), а не из данных заявки. + * Поэтому оно доступно всегда, даже если у типа заявки не настроен list_name. + */ +const IS_MANAGER_FIELD = 'IsManager'; +const SYNTHETIC_FIELD_OPTIONS = [{ value: IS_MANAGER_FIELD, label: texts.routeSteps.conditionFieldIsManagerLabel }]; +const IS_MANAGER_VALUE_OPTIONS = [ + { value: 'true', label: texts.common.yes }, + { value: 'false', label: texts.common.no } +]; + function toFormState(editing: TemplateRouteStep | undefined, nextOrderNo: number): IFormState { if (!editing) { return { @@ -132,18 +144,23 @@ export const RouteStepFormModal: React.FC = ({ const sharePointFields = useSharePointFields(sharePoint, requestType?.siteUrl ?? undefined, requestType?.listId ?? undefined); const fieldNameOptions = React.useMemo(() => { - const options = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title })); + const listOptions = sharePointFields.items.map(f => ({ value: f.internalName, label: f.title })); + const options = [...SYNTHETIC_FIELD_OPTIONS, ...listOptions]; if (form.fieldName && !options.some(o => o.value === form.fieldName)) { - options.unshift({ value: form.fieldName, label: form.fieldName }); + options.push({ value: form.fieldName, label: form.fieldName }); } return options; }, [sharePointFields.items, form.fieldName]); + const isManagerField = form.fieldName === IS_MANAGER_FIELD; + // Тип столбца известен, только если он есть среди подгруженных полей списка — // для значения, введённого до появления поля в списке (или после его удаления), // ограничения ниже не действуют, чтобы не блокировать уже сохранённые данные. + // IsManager — служебное булево поле, а не колонка списка, поэтому для него + // числовые ограничения не действуют в любом случае. const selectedField = sharePointFields.items.find(f => f.internalName === form.fieldName); - const isNumericField = selectedField ? isNumericFieldType(selectedField.typeAsString) : true; + const isNumericField = isManagerField ? false : selectedField ? isNumericFieldType(selectedField.typeAsString) : true; const operationOptions = isNumericField ? OPERATION_OPTIONS @@ -158,6 +175,15 @@ export const RouteStepFormModal: React.FC = ({ // eslint-disable-next-line react-hooks/exhaustive-deps }, [isNumericField]); + // IsManager — булево поле: значение должно быть одним из IS_MANAGER_VALUE_OPTIONS, + // а не произвольным текстом, который пользователь мог набрать для другого field_name. + React.useEffect(() => { + if (isManagerField && form.value !== 'true' && form.value !== 'false') { + setForm(f => ({ ...f, value: 'true' })); + } + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [isManagerField]); + const submitForm = (): void => { const orderNo = Number(form.orderNo); if (!orderNo || orderNo < 1) { @@ -326,12 +352,11 @@ export const RouteStepFormModal: React.FC = ({ setForm({ ...form, hasCondition: v })} /> - {form.hasCondition && hasListId && ( + {form.hasCondition && ( <> = ({ /> - setForm({ ...form, value: e.target.value })} - placeholder={texts.routeSteps.conditionValuePlaceholder} - /> + {isManagerField ? ( +