Compare commits
4 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 2eb63b5077 | |||
| 547702fda8 | |||
| e7222717d9 | |||
| f4542c7b39 |
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -163,16 +163,36 @@ public sealed class TemplateRouteService(
|
||||
.GroupBy(f => f.FieldName, StringComparer.OrdinalIgnoreCase)
|
||||
.ToDictionary(g => g.Key, g => g.First().Value, StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
// Синтетическое поле условия — переопределяет то, что мог прислать вызывающий
|
||||
// код под тем же именем. Считаем НЕ по должности инициатора из справочника
|
||||
// api.tnsu.kz (employee.Position — она у самого сотрудника чаще всего не
|
||||
// «руководитель»), а по полю Position, заполненному В САМОЙ АНКЕТЕ (например,
|
||||
// «Должность» в Обходном листе — там указывают должность увольняемого, а не
|
||||
// того, кто отправил заявку). Это свободный текст, поэтому определяем
|
||||
// «руководитель или нет» по ключевым словам в нём. Нет такого поля в заявке —
|
||||
// считаем «нет» (а не подставляем должность инициатора как фолбэк).
|
||||
fieldValues.TryGetValue(PositionField, out var formPosition);
|
||||
fieldValues[ManagerConditionField] = IsManagerPosition(formPosition) ? "true" : "false";
|
||||
|
||||
// Должность в заявке не передана вовсе — условие по IsManager вычислить не из
|
||||
// чего. Такой шаг НЕ выбрасываем: молча потерять согласующего хуже, чем показать
|
||||
// лишнего, поэтому шаг попадает в результат и несёт предупреждение (см. ниже
|
||||
// ResolveStepAsync/conditionWarning). Пустая строка или должность, не похожая на
|
||||
// руководящую, — это осознанный «нет», предупреждать не о чем.
|
||||
var positionMissing = string.IsNullOrWhiteSpace(formPosition);
|
||||
|
||||
var matchingSteps = route.Steps
|
||||
.Where(s => s.Status == request.Status)
|
||||
.Where(s => MatchesCondition(s, fieldValues))
|
||||
.Where(s => MatchesCondition(s, fieldValues) || HasUnevaluatableManagerCondition(s, positionMissing))
|
||||
.OrderBy(s => s.OrderNo)
|
||||
.ToList();
|
||||
|
||||
var steps = new List<ResolvedRouteStepDto>();
|
||||
foreach (var step in matchingSteps)
|
||||
steps.Add(await ResolveStepAsync(
|
||||
step, email, employee.Company, projectExecutors, request.DepartmentId, requestType, cancellationToken));
|
||||
step, email, employee.Company, projectExecutors, request.DepartmentId, requestType,
|
||||
HasUnevaluatableManagerCondition(step, positionMissing) ? PositionMissingWarning : null,
|
||||
cancellationToken));
|
||||
|
||||
var collapsedSteps = CollapseByExecutorEmail(steps);
|
||||
|
||||
@@ -214,9 +234,16 @@ public sealed class TemplateRouteService(
|
||||
x.ByProject == byProject,
|
||||
cancellationToken);
|
||||
|
||||
/// <param name="conditionWarning">
|
||||
/// Предупреждение про УСЛОВИЕ шага (в отличие от предупреждений ниже, которые все про
|
||||
/// неопределившегося исполнителя). Приходит снаружи, потому что условия проверяются
|
||||
/// в ResolveAsync, а не здесь. Если исполнитель к тому же не резолвится — тексты
|
||||
/// склеиваются, а не затирают друг друга: это независимые проблемы одного шага.
|
||||
/// </param>
|
||||
private async Task<ResolvedRouteStepDto> ResolveStepAsync(
|
||||
TemplateRouteStep step, string initiatorEmail, string initiatorCompany,
|
||||
IReadOnlyList<ProjectExecutorInfo> projectExecutors, Guid? departmentId, RequestType requestType,
|
||||
string? conditionWarning,
|
||||
CancellationToken cancellationToken)
|
||||
{
|
||||
string? resolvedEmail;
|
||||
@@ -264,6 +291,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))
|
||||
@@ -286,13 +314,16 @@ public sealed class TemplateRouteService(
|
||||
// работает именно так, как задумано — значение шага перекрывает типовое,
|
||||
// и при этом заданный на шаге 0 остаётся нулём, а не подменяется типовым
|
||||
// (0 — осознанное «напоминать сразу», см. TemplateRouteStep.SlaHours).
|
||||
var combinedWarning = string.Join(" ", new[] { conditionWarning, warning }
|
||||
.Where(w => !string.IsNullOrWhiteSpace(w)));
|
||||
|
||||
return new ResolvedRouteStepDto(
|
||||
step.Id,
|
||||
step.OrderNo,
|
||||
resolvedEmail,
|
||||
resolvedPosition,
|
||||
step.AllowReassignToOtherApprovers,
|
||||
warning,
|
||||
combinedWarning.Length == 0 ? null : combinedWarning,
|
||||
step.SlaHours ?? requestType.SlaHours,
|
||||
step.AskOverdueReason ?? requestType.AskOverdueReason);
|
||||
}
|
||||
@@ -328,6 +359,41 @@ public sealed class TemplateRouteService(
|
||||
private static bool TryParseNumber(string? value, out double number) =>
|
||||
double.TryParse(value, NumberStyles.Float, CultureInfo.InvariantCulture, out number);
|
||||
|
||||
/// <summary>Поле заявки, из которого вычисляется <see cref="ManagerConditionField"/>.</summary>
|
||||
private const string PositionField = "Position";
|
||||
|
||||
/// <summary>Синтетическое поле условия шага — вычисляется, а не приходит из заявки.</summary>
|
||||
private const string ManagerConditionField = "IsManager";
|
||||
|
||||
private const string PositionMissingWarning =
|
||||
"В заявке не передана должность (поле «Position») — условие шага по «IsManager» вычислить не из чего, " +
|
||||
"поэтому шаг включён в маршрут, хотя условие могло и не выполниться.";
|
||||
|
||||
/// <summary>
|
||||
/// Условие шага завязано на IsManager, но вычислить его не из чего: должность
|
||||
/// в заявке не передана. Такой шаг попадает в результат вопреки условию и с
|
||||
/// предупреждением — см. ResolveAsync.
|
||||
/// </summary>
|
||||
private static bool HasUnevaluatableManagerCondition(TemplateRouteStep step, bool positionMissing) =>
|
||||
positionMissing &&
|
||||
string.Equals(step.FieldName, ManagerConditionField, StringComparison.OrdinalIgnoreCase) &&
|
||||
step.Operation is not (null or ComparisonOperator.None);
|
||||
|
||||
/// <summary>
|
||||
/// Должность — свободный текст без структуры (нет отдельного флага «это
|
||||
/// руководитель»), поэтому определяем это по ключевым словам в её названии.
|
||||
/// Список подобран по фактическим должностям в справочнике api.tnsu.kz —
|
||||
/// Начальник/Руководитель/Директор/Заместитель/Главный/Заведующий складом.
|
||||
/// «Ведущий», «Старший» и «Менеджер» намеренно не входят: это старший
|
||||
/// специалист, а не должность с подчинёнными.
|
||||
/// </summary>
|
||||
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<ResolvedRouteStepDto> CollapseByExecutorEmail(IReadOnlyList<ResolvedRouteStepDto> steps)
|
||||
{
|
||||
var seenEmails = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
|
||||
@@ -6,6 +6,7 @@ public enum HeadType
|
||||
Chief,
|
||||
DirectionHead,
|
||||
DirectorTansu,
|
||||
ManagingDirectorTansu,
|
||||
|
||||
/// <summary>
|
||||
/// Руководитель отдела, указанного явно в запросе на resolve (см.
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
|
||||
@@ -76,6 +76,20 @@ const OPERATION_OPTIONS = Object.values(ComparisonOperator)
|
||||
.filter(v => v !== ComparisonOperator.None)
|
||||
.map(v => ({ value: v, label: comparisonOperatorLabels[v] }));
|
||||
|
||||
/**
|
||||
* Служебное поле условия, не привязанное к списку SharePoint — вычисляется на бэкенде
|
||||
* (см. TemplateRouteService.ResolveAsync) по значению поля Position В САМОЙ ЗАЯВКЕ
|
||||
* («Должность» — например, у увольняемого в обходном листе), а не по должности того,
|
||||
* кто отправил заявку. Поэтому поле доступно всегда, даже если у типа заявки не
|
||||
* настроен list_name — источник данных не сам список, а fieldValues resolve-запроса.
|
||||
*/
|
||||
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 +146,23 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
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 +177,15 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
// 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 +354,11 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
<Checkbox
|
||||
label={texts.routeSteps.conditionCheckbox}
|
||||
checked={form.hasCondition}
|
||||
disabled={!hasListId}
|
||||
onChange={v => setForm({ ...form, hasCondition: v })}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
{form.hasCondition && hasListId && (
|
||||
{form.hasCondition && (
|
||||
<>
|
||||
<Field
|
||||
label={texts.routeSteps.conditionFieldNameLabel}
|
||||
@@ -361,12 +388,20 @@ export const RouteStepFormModal: React.FC<IRouteStepFormModalProps> = ({
|
||||
/>
|
||||
</Field>
|
||||
<Field label={texts.routeSteps.conditionValueLabel} required>
|
||||
{isManagerField ? (
|
||||
<Select
|
||||
options={IS_MANAGER_VALUE_OPTIONS}
|
||||
value={form.value}
|
||||
onChange={e => setForm({ ...form, value: e.target.value })}
|
||||
/>
|
||||
) : (
|
||||
<TextInput
|
||||
type={isNumericField ? 'number' : 'text'}
|
||||
value={form.value}
|
||||
onChange={e => setForm({ ...form, value: e.target.value })}
|
||||
placeholder={texts.routeSteps.conditionValuePlaceholder}
|
||||
/>
|
||||
)}
|
||||
</Field>
|
||||
</>
|
||||
)}
|
||||
|
||||
@@ -18,10 +18,17 @@
|
||||
.full { grid-column: 1 / -1; }
|
||||
|
||||
.label {
|
||||
// display:block + min-height на 2 строки — так подпись из одной строки
|
||||
// и подпись, свернувшаяся на две (например «Спрашивать причину
|
||||
// просрочки»), одинаково резервируют место, и контролы двух полей одной
|
||||
// строки грида начинаются на одном уровне независимо от переноса текста.
|
||||
display: block;
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
color: $gray-700;
|
||||
letter-spacing: .2px;
|
||||
line-height: 16px;
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.req { color: $gold-600; }
|
||||
|
||||
@@ -22,6 +22,7 @@ export enum HeadType {
|
||||
Chief = 'Chief',
|
||||
DirectionHead = 'DirectionHead',
|
||||
DirectorTansu = 'DirectorTansu',
|
||||
ManagingDirectorTansu = 'ManagingDirectorTansu',
|
||||
// Резолвится по departmentId из запроса на resolve (см. ResolveTemplateRouteQuery),
|
||||
// а не по цепочке подчинённости инициатора, как остальные типы, — поэтому на
|
||||
// экране «Проверка маршрута» появляется отдельное поле выбора отдела.
|
||||
|
||||
@@ -31,6 +31,7 @@ export const headTypeLabels: Record<HeadType, string> = {
|
||||
[HeadType.Chief]: 'Непосредственный руководитель',
|
||||
[HeadType.DirectionHead]: 'Руководитель направления',
|
||||
[HeadType.DirectorTansu]: 'Директор Tansu',
|
||||
[HeadType.ManagingDirectorTansu]: 'Управляющий директор Tansu',
|
||||
[HeadType.DepartmentHead]: 'Руководитель указанного отдела'
|
||||
};
|
||||
|
||||
@@ -233,7 +234,9 @@ export const texts = {
|
||||
askOverdueReasonHint: '«Не задано» — берётся значение типа заявки.',
|
||||
conditionLabel: 'Условие видимости шага',
|
||||
conditionCheckbox: 'Показывать шаг только при выполнении условия',
|
||||
conditionDisabledHint: 'Недоступно: у типа заявки не указан список SharePoint (list_name).',
|
||||
conditionDisabledHint:
|
||||
'У типа заявки не указан список SharePoint (list_name) — доступно только служебное поле «Инициатор — руководитель».',
|
||||
conditionFieldIsManagerLabel: 'Должность в заявке — руководящая (IsManager)',
|
||||
conditionFieldNameLabel: 'Поле списка',
|
||||
conditionFieldNamePlaceholder: 'Выберите столбец списка',
|
||||
conditionFieldNameLoading: 'Загрузка столбцов списка...',
|
||||
|
||||
Reference in New Issue
Block a user