fix(number-fields): избегаем потери точности длинных чисел

- number-поля теперь рендерятся как text + inputMode=numeric,
  чтобы браузер не округлял значения через input type=number
- пробелы при вставке в number-поля удаляются
- бэкенд нормализует значения number-полей в строку перед сохранением
- добавлен хелпер normalizeFieldValueForStorage

Closes: искажение расчётного счёта и других длинных числовых полей
This commit is contained in:
2026-07-07 21:03:40 +03:00
commit 1f5ecb6da4
1089 changed files with 237546 additions and 0 deletions

522
shared/field-conditions.ts Normal file
View 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);
}