fix(number-fields): избегаем потери точности длинных чисел
- number-поля теперь рендерятся как text + inputMode=numeric, чтобы браузер не округлял значения через input type=number - пробелы при вставке в number-поля удаляются - бэкенд нормализует значения number-полей в строку перед сохранением - добавлен хелпер normalizeFieldValueForStorage Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
522
shared/field-conditions.ts
Normal file
522
shared/field-conditions.ts
Normal file
@@ -0,0 +1,522 @@
|
||||
/**
|
||||
* Утилиты для сравнения значений полей в правилах.
|
||||
* Используются на сервере (переходы, условная обязательность) и на клиенте (валидация форм).
|
||||
*/
|
||||
|
||||
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" }[],
|
||||
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;
|
||||
|
||||
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;
|
||||
}>;
|
||||
}
|
||||
|
||||
/** Обратная совместимость со старым названием. */
|
||||
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);
|
||||
}
|
||||
Reference in New Issue
Block a user