Инфраструктура автоматизаций: - триггер task.status_changed оживлён: вызов из executeTaskTransition (fire-and-forget) + фильтр triggerConfig.statusId - ctx.users.setFieldValue(userId, fieldCodeOrId, value): запись user_profile_field_values с аудитом; для history-number — append в user_field_history (миграция 0078) - документация sandbox обновлена (Automations.tsx, mcp.ts, AGENTS.md) по критическому правилу 2 Мультивыбор пользователей и ролей в user-поле: - чекбокс «Множественный выбор (пользователи и роли)» в настройках поля (form_fields.allow_multiple) - значение — массив ['user:ID','role:ID'], обратная совместимость с одиночными/legacy значениями - UserRoleMultiSelect в карточке задачи, реестре и диалоге создания (роли добавлены и в одиночный сценарий создания) - синк в task_assignees итерирует массив; role:ID раскрывается в членов роли; PATCH single — diff старого/нового значений - отображение массивов: реестр, title-шаблоны, условия полей, MCP update_task_fields - список орг-ролей (/api/roles) загружается всем пользователям (сервер и так был открыт) — роли доступны не только админам График history-number в профиле: - GET /api/users/:id/field-history/:fieldId?period=week|month|quarter|year (агрегация AVG по бакетам) - диалог с LineChart (recharts) и переключателем периодов по клику на иконку у поля
551 lines
19 KiB
TypeScript
551 lines
19 KiB
TypeScript
/**
|
||
* Утилиты для сравнения значений полей в правилах.
|
||
* Используются на сервере (переходы, условная обязательность) и на клиенте (валидация форм).
|
||
*/
|
||
|
||
export type TableMapEntry =
|
||
| Map<number, string>
|
||
| { rows: Array<{ id: number; values: string[] }>; columns: Array<{ name: string }> };
|
||
|
||
export interface FieldConditionContext {
|
||
users?: Array<{
|
||
id: number;
|
||
firstName?: string | null;
|
||
middleName?: string | null;
|
||
lastName?: string | null;
|
||
email?: string;
|
||
fullName?: string;
|
||
}>;
|
||
roles?: Array<{ id: number; name?: string }>;
|
||
taskTitleMap?: Map<number, string>;
|
||
tableMap?: Map<number, TableMapEntry>;
|
||
/**
|
||
* true — мы находимся в модалке первичного создания задачи (псевдо-статус "Заполнение").
|
||
* В этом режиме правила mode "creation" / "in_or_creation" считаются выполненными,
|
||
* а правила mode "in" (в выбранных статусах) НЕ выполняются — чтобы поля можно было
|
||
* заполнять при создании. Для действия на этапе создания используйте "creation" или "in_or_creation".
|
||
*/
|
||
isCreation?: boolean;
|
||
/**
|
||
* Список статусов формы с position. Нужен для режимов "до статуса" / "после статуса".
|
||
*/
|
||
statuses?: Array<{ id: number; position: number; isFinal?: boolean | null }>;
|
||
}
|
||
|
||
function safeString(val: unknown): string {
|
||
if (val === null || val === undefined) return "";
|
||
return String(val);
|
||
}
|
||
|
||
/**
|
||
* Проверяет, считается ли значение поля "пустым" (не заполненным).
|
||
*/
|
||
export function isFieldValueEmpty(value: unknown, fieldType?: string): boolean {
|
||
if (value === null || value === undefined || value === "") return true;
|
||
if (Array.isArray(value) && value.length === 0) return true;
|
||
if (typeof value === "object" && value !== null) {
|
||
const obj = value as Record<string, unknown>;
|
||
const keys = Object.keys(obj);
|
||
if (keys.length === 0) return true;
|
||
const hasNonEmpty = keys.some((k) => {
|
||
const v = obj[k];
|
||
return v !== null && v !== undefined && v !== "" && !(Array.isArray(v) && v.length === 0);
|
||
});
|
||
if (!hasNonEmpty) return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
/**
|
||
* Отображаемый текст одного элемента user-поля (`user:<id>` / `role:<id>` / legacy-число).
|
||
*/
|
||
function resolveUserEntryText(entry: string, context: FieldConditionContext): string {
|
||
if (entry.startsWith("user:")) {
|
||
const uid = Number(entry.replace("user:", ""));
|
||
const user = context.users?.find((u) => u.id === uid);
|
||
if (user) {
|
||
return (
|
||
user.fullName ||
|
||
`${user.firstName || ""} ${user.middleName || ""} ${user.lastName || ""}`.trim() ||
|
||
user.email ||
|
||
entry
|
||
);
|
||
}
|
||
return entry;
|
||
}
|
||
if (entry.startsWith("role:")) {
|
||
const rid = Number(entry.replace("role:", ""));
|
||
const role = context.roles?.find((r) => r.id === rid);
|
||
if (role) {
|
||
return `Роль: ${role.name || entry}`;
|
||
}
|
||
return entry;
|
||
}
|
||
const uid = Number(entry);
|
||
if (!isNaN(uid)) {
|
||
const user = context.users?.find((u) => u.id === uid);
|
||
if (user) {
|
||
return (
|
||
user.fullName ||
|
||
`${user.firstName || ""} ${user.middleName || ""} ${user.lastName || ""}`.trim() ||
|
||
user.email ||
|
||
entry
|
||
);
|
||
}
|
||
}
|
||
return entry;
|
||
}
|
||
|
||
/**
|
||
* Преобразует хранимое значение поля в строку для поиска (отображаемый текст).
|
||
*/
|
||
export function resolveFieldDisplayText(
|
||
value: unknown,
|
||
field: {
|
||
type?: string;
|
||
options?: unknown;
|
||
autoProlongationLabel?: string | null;
|
||
},
|
||
context: FieldConditionContext = {}
|
||
): string {
|
||
if (value === null || value === undefined || value === "") return "";
|
||
|
||
const fieldType = field.type || "";
|
||
const rawStr = safeString(value);
|
||
|
||
switch (fieldType) {
|
||
case "user": {
|
||
// Мультивыбор (allow_multiple): массив ["user:5", "role:2"] — имена через запятую
|
||
let parsedVal: unknown = value;
|
||
if (typeof value === "string" && value.startsWith("[")) {
|
||
try {
|
||
const parsed = JSON.parse(value);
|
||
if (Array.isArray(parsed)) parsedVal = parsed;
|
||
} catch {
|
||
parsedVal = value;
|
||
}
|
||
}
|
||
if (Array.isArray(parsedVal)) {
|
||
return parsedVal
|
||
.map((entry) => resolveUserEntryText(safeString(entry), context))
|
||
.filter(Boolean)
|
||
.join(", ");
|
||
}
|
||
return resolveUserEntryText(rawStr, context);
|
||
}
|
||
|
||
case "task": {
|
||
const taskId =
|
||
typeof value === "string" ? parseInt(value, 10) : Number(value);
|
||
if (!isNaN(taskId)) {
|
||
const title = context.taskTitleMap?.get(taskId);
|
||
if (title) return `#${taskId}. ${title}`;
|
||
}
|
||
if (typeof value === "object" && value !== null) {
|
||
const obj = value as Record<string, unknown>;
|
||
const tId =
|
||
typeof obj.taskId === "number"
|
||
? obj.taskId
|
||
: typeof obj.id === "number"
|
||
? obj.id
|
||
: NaN;
|
||
if (!isNaN(tId)) {
|
||
const title = context.taskTitleMap?.get(tId);
|
||
if (title) return `#${tId}. ${title}`;
|
||
}
|
||
}
|
||
return rawStr;
|
||
}
|
||
|
||
case "table": {
|
||
const tableId = (field.options as Record<string, number> | undefined)?.tableId;
|
||
const rowId = Number(rawStr);
|
||
if (tableId && !isNaN(rowId) && context.tableMap?.has(tableId)) {
|
||
const tableData = context.tableMap.get(tableId)!;
|
||
if (tableData instanceof Map) {
|
||
return tableData.get(rowId) || rawStr;
|
||
}
|
||
const displayColumn = (field.options as Record<string, string> | undefined)?.displayColumn;
|
||
const columns = tableData.columns || [];
|
||
const colIndex = displayColumn
|
||
? columns.findIndex((c) => c.name === displayColumn)
|
||
: 0;
|
||
const row = tableData.rows.find((r) => r.id === rowId);
|
||
if (row) {
|
||
return row.values[colIndex >= 0 ? colIndex : 0] || `#${row.id}`;
|
||
}
|
||
}
|
||
return rawStr;
|
||
}
|
||
|
||
case "select":
|
||
case "radio-group": {
|
||
const opts = Array.isArray(field.options)
|
||
? (field.options as Array<{ label?: string; value?: string; id?: string }>)
|
||
: [];
|
||
const opt = opts.find((o) => (o.value ?? o.id) === rawStr);
|
||
return opt?.label || rawStr;
|
||
}
|
||
|
||
case "contract-number": {
|
||
if (typeof value === "object" && value !== null) {
|
||
const obj = value as Record<string, unknown>;
|
||
return safeString(obj.value || obj.number || "");
|
||
}
|
||
return rawStr;
|
||
}
|
||
|
||
case "company": {
|
||
if (typeof value === "object" && value !== null) {
|
||
const obj = value as Record<string, unknown>;
|
||
return safeString(
|
||
obj.shortName || obj.name || obj.fullName || obj.value || ""
|
||
);
|
||
}
|
||
return rawStr;
|
||
}
|
||
|
||
case "file": {
|
||
if (Array.isArray(value)) {
|
||
return value
|
||
.map((v) =>
|
||
typeof v === "object" && v !== null
|
||
? safeString((v as Record<string, unknown>).name || v)
|
||
: safeString(v)
|
||
)
|
||
.join(", ");
|
||
}
|
||
if (typeof value === "object" && value !== null) {
|
||
return safeString(
|
||
(value as Record<string, unknown>).name ||
|
||
(value as Record<string, unknown>).url ||
|
||
rawStr
|
||
);
|
||
}
|
||
return rawStr;
|
||
}
|
||
|
||
case "geo": {
|
||
if (typeof value === "object" && value !== null) {
|
||
const obj = value as Record<string, unknown>;
|
||
return safeString(obj.address || obj.name || obj.value || "");
|
||
}
|
||
return rawStr;
|
||
}
|
||
|
||
case "toggle":
|
||
case "checkbox": {
|
||
if (typeof value === "boolean") {
|
||
return value ? "Да" : "Нет";
|
||
}
|
||
return rawStr;
|
||
}
|
||
|
||
case "checklist": {
|
||
if (Array.isArray(value)) {
|
||
const checkedCount = value.filter((it: any) => it?.checked).length;
|
||
const totalCount = Array.isArray(field.options) ? field.options.length : 0;
|
||
return `Выполнено ${checkedCount} из ${totalCount}`;
|
||
}
|
||
return rawStr;
|
||
}
|
||
|
||
case "date":
|
||
case "datetime": {
|
||
if (value === "__auto_prolongation__") {
|
||
return field.autoProlongationLabel || "Автопролонгация";
|
||
}
|
||
if (typeof value === "string" || value instanceof Date) {
|
||
const d = value instanceof Date ? value : new Date(value);
|
||
if (!isNaN(d.getTime())) {
|
||
return d.toLocaleDateString("ru-RU");
|
||
}
|
||
}
|
||
return rawStr;
|
||
}
|
||
|
||
default:
|
||
return rawStr;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Системные поля задачи, доступные для использования в правилах.
|
||
*/
|
||
export const SYSTEM_TASK_FIELDS = [
|
||
{ id: -1, code: "_id", name: "ID задачи", type: "number" },
|
||
{ id: -2, code: "_number", name: "Номер задачи", type: "number" },
|
||
{ id: -3, code: "_due_date", name: "Срок выполнения", type: "date" },
|
||
{ id: -4, code: "_status", name: "Статус", type: "text" },
|
||
{ id: -5, code: "_assignee", name: "Ответственный", type: "user" },
|
||
{ id: -6, code: "_author", name: "Автор", type: "user" },
|
||
{ id: -7, code: "title", name: "Название", type: "text" },
|
||
{ id: -8, code: "description", name: "Описание", type: "textarea" },
|
||
{ id: -9, code: "createdAt", name: "Дата создания", type: "datetime" },
|
||
{ id: -10, code: "updatedAt", name: "Дата обновления", type: "datetime" },
|
||
{ id: -11, code: "completedAt", name: "Дата завершения", type: "datetime" },
|
||
{ id: -12, code: "isCompleted", name: "Завершена", type: "toggle" },
|
||
] as const;
|
||
|
||
/**
|
||
* Строит виртуальные значения системных полей задачи для проверки условий.
|
||
*/
|
||
export function buildSystemFieldValuesForConditions(
|
||
task: any,
|
||
options?: { statusName?: string }
|
||
): Array<{ fieldId: number; value: unknown }> {
|
||
if (!task) return [];
|
||
return [
|
||
{ fieldId: -1, value: task.id },
|
||
{ fieldId: -2, value: task.id },
|
||
{ fieldId: -3, value: task.dueDate },
|
||
{ fieldId: -4, value: options?.statusName ?? task.currentStatusId },
|
||
{ fieldId: -5, value: task.assignedTo ? `user:${task.assignedTo}` : null },
|
||
{ fieldId: -6, value: task.createdBy ? `user:${task.createdBy}` : null },
|
||
{ fieldId: -7, value: task.title },
|
||
{ fieldId: -8, value: task.description },
|
||
{ fieldId: -9, value: task.createdAt },
|
||
{ fieldId: -10, value: task.updatedAt },
|
||
{ fieldId: -11, value: task.completedAt },
|
||
{ fieldId: -12, value: task.isCompleted },
|
||
];
|
||
}
|
||
|
||
/**
|
||
* Проверяет, соответствуют ли значения полей заданным правилам.
|
||
*/
|
||
export function fieldConditionsMatch(
|
||
rules: { fieldCode?: string; value?: string; operator?: "contains" | "empty" | "filled"; tableMode?: "value" | "column_user" }[],
|
||
fieldValues: Array<{ fieldId: number; value: unknown }>,
|
||
fields: Array<{
|
||
id: number;
|
||
code: string;
|
||
type?: string;
|
||
options?: unknown;
|
||
autoProlongationLabel?: string | null;
|
||
}>,
|
||
context: FieldConditionContext = {}
|
||
): boolean {
|
||
if (!rules || rules.length === 0) return true;
|
||
|
||
for (const rule of rules) {
|
||
if (!rule.fieldCode) return false;
|
||
|
||
const field = fields.find((f) => f.code === rule.fieldCode);
|
||
if (!field) return false;
|
||
|
||
// Режим "пользователь из колонки" — это конфигурация выборки согласующего,
|
||
// а не условие на значение поля. Пропускаем, чтобы не ломать проверку.
|
||
if (rule.tableMode === "column_user") continue;
|
||
|
||
const fv = fieldValues.find((fv) => fv.fieldId === field.id);
|
||
const operator = rule.operator || "contains";
|
||
|
||
if (operator === "empty") {
|
||
if (!isFieldValueEmpty(fv?.value, field.type)) return false;
|
||
continue;
|
||
}
|
||
|
||
if (operator === "filled") {
|
||
if (isFieldValueEmpty(fv?.value, field.type)) return false;
|
||
continue;
|
||
}
|
||
|
||
if (!rule.value) return false;
|
||
|
||
const displayText = resolveFieldDisplayText(fv?.value, field, context);
|
||
const ruleVal = rule.value.toLowerCase();
|
||
|
||
if (!displayText.toLowerCase().includes(ruleVal)) return false;
|
||
}
|
||
|
||
return true;
|
||
}
|
||
|
||
/**
|
||
* Режимы условия по статусу для правила поля.
|
||
*/
|
||
export type FieldRuleStatusMode = "any" | "creation" | "in" | "in_or_creation";
|
||
|
||
/**
|
||
* Условие по статусу: в каких статусах применяется правило.
|
||
*/
|
||
export interface FieldRuleStatusCondition {
|
||
mode: FieldRuleStatusMode;
|
||
/** Набор статусов для режимов in / in_or_creation. */
|
||
statusIds?: number[];
|
||
}
|
||
|
||
/**
|
||
* Универсальная модель условного правила поля.
|
||
* Используется для обязательности, неизменности и видимости.
|
||
*/
|
||
export interface FieldRule {
|
||
enabled?: boolean;
|
||
/** Условие по статусу (новое). */
|
||
statusCondition?: FieldRuleStatusCondition | null;
|
||
/** Legacy: старая привязка к одному статусу. Оставлено для обратной совместимости. */
|
||
statusId?: number | "__creation__" | null;
|
||
logic?: "and" | "or";
|
||
conditions?: Array<{
|
||
fieldCode: string;
|
||
operator?: "contains" | "empty" | "filled";
|
||
value?: string;
|
||
tableMode?: "value" | "column_user";
|
||
}>;
|
||
}
|
||
|
||
/** Обратная совместимость со старым названием. */
|
||
export type FieldRequiredRules = FieldRule;
|
||
|
||
/**
|
||
* Нормализует правило: превращает legacy statusId в statusCondition.
|
||
*/
|
||
export function normalizeFieldRule(rules: FieldRule | null | undefined): FieldRule | null {
|
||
if (!rules) return null;
|
||
if (rules.statusCondition != null) {
|
||
return rules;
|
||
}
|
||
|
||
const { statusId, ...rest } = rules;
|
||
let statusCondition: FieldRuleStatusCondition;
|
||
|
||
if (statusId === "__creation__") {
|
||
statusCondition = { mode: "creation" };
|
||
} else if (statusId == null) {
|
||
statusCondition = { mode: "any" };
|
||
} else {
|
||
statusCondition = { mode: "in", statusIds: [statusId] };
|
||
}
|
||
|
||
return { ...rest, statusCondition };
|
||
}
|
||
|
||
function ruleStatusConditionMatches(
|
||
statusCondition: FieldRuleStatusCondition | null | undefined,
|
||
task: { currentStatusId?: number | null } | null,
|
||
context: FieldConditionContext
|
||
): boolean {
|
||
const sc = statusCondition ?? { mode: "any" as const };
|
||
const currentStatusId = task?.currentStatusId;
|
||
|
||
switch (sc.mode) {
|
||
case "any":
|
||
return true;
|
||
case "creation":
|
||
return !!context.isCreation;
|
||
case "in":
|
||
// Режим "in" — только в выбранных реальных статусах, НЕ при первичном создании.
|
||
// Чтобы правило работало и при создании, используйте "in_or_creation".
|
||
return !context.isCreation && currentStatusId != null && (sc.statusIds ?? []).includes(currentStatusId);
|
||
case "in_or_creation":
|
||
return (
|
||
!!context.isCreation ||
|
||
(currentStatusId != null && (sc.statusIds ?? []).includes(currentStatusId))
|
||
);
|
||
default:
|
||
// Неизвестный режим считаем неактивным (безопасный дефолт).
|
||
return false;
|
||
}
|
||
}
|
||
|
||
/**
|
||
* Универсально проверяет, выполняется ли правило для поля.
|
||
*/
|
||
export function evaluateFieldRule(
|
||
rules: FieldRule | null | undefined,
|
||
task: { currentStatusId?: number | null } | null,
|
||
fieldValues: Array<{ fieldId: number; value: unknown }>,
|
||
fields: Array<{ id: number; code: string; type?: string; options?: unknown; autoProlongationLabel?: string | null }>,
|
||
context: FieldConditionContext = {}
|
||
): boolean {
|
||
const normalized = normalizeFieldRule(rules);
|
||
if (!normalized || !normalized.enabled) {
|
||
return false;
|
||
}
|
||
|
||
if (!ruleStatusConditionMatches(normalized.statusCondition, task, context)) {
|
||
return false;
|
||
}
|
||
|
||
const conditions = normalized.conditions || [];
|
||
if (conditions.length === 0) {
|
||
return true;
|
||
}
|
||
|
||
const logic = normalized.logic || "and";
|
||
if (logic === "or") {
|
||
return conditions.some((rule) =>
|
||
fieldConditionsMatch([rule], fieldValues, fields, context)
|
||
);
|
||
}
|
||
|
||
return fieldConditionsMatch(conditions, fieldValues, fields, context);
|
||
}
|
||
|
||
/**
|
||
* Проверяет, является ли поле обязательным с учётом условных правил.
|
||
*/
|
||
export function isFieldRequired(
|
||
field: {
|
||
id: number;
|
||
code: string;
|
||
type?: string;
|
||
isRequired?: boolean | null;
|
||
requiredRules?: FieldRule | null;
|
||
},
|
||
task: { currentStatusId?: number | null } | null,
|
||
fieldValues: Array<{ fieldId: number; value: unknown }>,
|
||
fields: Array<{ id: number; code: string; type?: string; options?: unknown; autoProlongationLabel?: string | null }>,
|
||
context: FieldConditionContext = {}
|
||
): boolean {
|
||
// Если включено условное правило, оно полностью определяет обязательность поля.
|
||
// Старый boolean-флаг isRequired используется только когда условных правил нет или они выключены.
|
||
// Это предотвращает ситуацию, когда поле остаётся обязательным при создании,
|
||
// хотя правило настроено только для конкретного статуса (mode "in").
|
||
if (field.requiredRules && field.requiredRules.enabled) {
|
||
return evaluateFieldRule(field.requiredRules, task, fieldValues, fields, context);
|
||
}
|
||
return !!field.isRequired;
|
||
}
|
||
|
||
/**
|
||
* Проверяет, является ли поле неизменным (read-only) с учётом условных правил.
|
||
*/
|
||
export function isFieldImmutable(
|
||
field: {
|
||
id: number;
|
||
code: string;
|
||
type?: string;
|
||
immutableRules?: FieldRule | null;
|
||
},
|
||
task: { currentStatusId?: number | null } | null,
|
||
fieldValues: Array<{ fieldId: number; value: unknown }>,
|
||
fields: Array<{ id: number; code: string; type?: string; options?: unknown; autoProlongationLabel?: string | null }>,
|
||
context: FieldConditionContext = {}
|
||
): boolean {
|
||
return evaluateFieldRule(field.immutableRules, task, fieldValues, fields, context);
|
||
}
|
||
|
||
/**
|
||
* Проверяет, должно ли поле быть видимым с учётом условных правил.
|
||
* Если правила не включены — поле видимо по умолчанию.
|
||
*/
|
||
export function isFieldVisible(
|
||
field: {
|
||
id: number;
|
||
code: string;
|
||
type?: string;
|
||
visibilityRules?: FieldRule | null;
|
||
},
|
||
task: { currentStatusId?: number | null } | null,
|
||
fieldValues: Array<{ fieldId: number; value: unknown }>,
|
||
fields: Array<{ id: number; code: string; type?: string; options?: unknown; autoProlongationLabel?: string | null }>,
|
||
context: FieldConditionContext = {}
|
||
): boolean {
|
||
if (!field.visibilityRules || !field.visibilityRules.enabled) {
|
||
return true;
|
||
}
|
||
return evaluateFieldRule(field.visibilityRules, task, fieldValues, fields, context);
|
||
}
|