Files
iistwin/shared/field-conditions.ts

528 lines
19 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* Утилиты для сравнения значений полей в правилах.
* Используются на сервере (переходы, условная обязательность) и на клиенте (валидация форм).
*/
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;
}
/**
* Преобразует хранимое значение поля в строку для поиска (отображаемый текст).
*/
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": {
if (rawStr.startsWith("user:")) {
const uid = Number(rawStr.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 ||
rawStr
);
}
return rawStr;
}
if (rawStr.startsWith("role:")) {
const rid = Number(rawStr.replace("role:", ""));
const role = context.roles?.find((r) => r.id === rid);
if (role) {
return `Роль: ${role.name || rawStr}`;
}
return rawStr;
}
const uid = Number(rawStr);
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 ||
rawStr
);
}
}
return rawStr;
}
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);
}