chore(repo): дедуп formatAmount, пины образов, дефолты MinIO, доля any

Шаги 2.3 и 2.4 плана production-готовности:
- lib/format.ts formatAmount вместо 2 локальных копий
- пины тегов minio/rclone/ollama/traccar/pgvector + osrm по digest
- minioadmin без дефолта (env required, dev-подсказки в .env.example)
- 28 any в auto-transitions/shared типизированы из @shared/schema
- удалён _tablefield_orig.txt
- check/lint/build чисто, vitest 104/104
This commit is contained in:
2026-09-08 01:07:55 +03:00
parent 5750a3106f
commit 75ec0f25bb
12 changed files with 118 additions and 204 deletions

View File

@@ -5,6 +5,7 @@ import { logAudit } from '../utils/audit';
import { db } from '../db';
import { formatUserName } from '../utils/formatUserName';
import { tasks, forms, roles } from '@shared/schema';
import type { FormStatus, Task } from '@shared/schema';
import { eq, and, inArray } from 'drizzle-orm';
import { SseConnectionIndex } from '../utils/sse-connection-index';
import { logger } from '../utils/logger';
@@ -334,8 +335,8 @@ export const sseLimiter = rateLimit({
});
export interface SystemFieldContext {
statuses?: any[];
usersMap?: Map<number, any>;
statuses?: FormStatus[];
usersMap?: Map<number, { firstName?: string | null; lastName?: string | null; middleName?: string | null; email?: string | null } | undefined>;
}
function safeParseOptions(opts: unknown): unknown {
@@ -606,7 +607,7 @@ export async function resolveTaskFieldTitles(
}
export async function buildSystemFieldValues(
task: any,
task: Task,
orgId: number,
ctx?: SystemFieldContext
): Promise<Map<string, string>> {
@@ -618,7 +619,7 @@ export async function buildSystemFieldValues(
// Use preloaded statuses if provided, otherwise fetch
const statuses = ctx?.statuses
?? (task.currentStatusId && task.formId ? await storage.getFormStatuses(task.formId, orgId) : []);
const status = (statuses as any[]).find((s: any) => s.id === task.currentStatusId);
const status = statuses.find((s) => s.id === task.currentStatusId);
map.set('_status', status?.name ?? '');
if (task.assignedTo) {

View File

@@ -8,6 +8,10 @@
*/
import { storage } from "../storage";
import type { FormField, FormStatus, SafeUser, StatusTransition, Task } from "@shared/schema";
// Тип роли с метаданными, как её возвращает storage.getRoles
type OrgRole = Awaited<ReturnType<typeof storage.getRoles>>[number];
import { fieldConditionsMatch, resolveFieldDisplayText, type FieldConditionContext, SYSTEM_TASK_FIELDS, buildSystemFieldValuesForConditions } from "./field-conditions";
import { resolveTaskFieldTitles, buildTableRowMap, formatFieldValueForTitle, eventBus } from "../routes/shared";
@@ -22,11 +26,11 @@ export interface EvaluateAutoTransitionsOptions {
triggeredBy?: number | null;
maxDepth?: number;
preloaded?: {
statuses?: any[];
formFields?: any[];
users?: any[];
roles?: any[];
transitions?: any[];
statuses?: FormStatus[];
formFields?: FormField[];
users?: SafeUser[];
roles?: OrgRole[];
transitions?: StatusTransition[];
tableMap?: Map<number, { rows: Array<{ id: number; values: string[] }>; columns: Array<{ name: string }> }>;
};
}
@@ -55,7 +59,7 @@ export async function evaluateAutoTransitions(
if (!_task) {
return { changed: false, task: { id: taskId, currentStatusId: 0, title: "", formId: 0, assignedTo: null }, visitedStatusIds: [] };
}
let task: any = _task;
let task: Task = _task;
const formId = task.formId;
const [statuses, formFields, fieldValues] = await Promise.all([
@@ -65,7 +69,7 @@ export async function evaluateAutoTransitions(
]);
// Merge system fields so rules can reference _assignee, _status, _due_date, etc.
const currentStatusName = statuses.find((s: any) => s.id === task.currentStatusId)?.name;
const currentStatusName = statuses.find((s) => s.id === task.currentStatusId)?.name;
const systemFieldValues = buildSystemFieldValuesForConditions(task, { statusName: currentStatusName });
const allFields = [...formFields, ...SYSTEM_TASK_FIELDS];
let allFieldValues = [...fieldValues, ...systemFieldValues];
@@ -74,27 +78,26 @@ export async function evaluateAutoTransitions(
const orgUsers = options.preloaded?.users ? options.preloaded.users : await storage.getUsersByOrganization(organizationId);
const orgRoles = options.preloaded?.roles ? options.preloaded.roles : await storage.getRoles(organizationId);
const usersWithFullName = orgUsers.map((u: any) => ({
const usersWithFullName = orgUsers.map((u) => ({
...u,
fullName: `${u.firstName || ""} ${u.middleName || ""} ${u.lastName || ""}`.trim() || u.email,
}));
const valueByFieldId = new Map(fieldValues.map((v: any) => [v.fieldId, v.value]));
const valueByFieldId = new Map(fieldValues.map((v) => [v.fieldId, v.value]));
const taskTitleMap = await resolveTaskFieldTitles(valueByFieldId, formFields, organizationId);
const tableIds = new Set<number>();
for (const f of formFields) {
if ((f as any).type === "table") {
const tid = ((f as any).options as Record<string, number> | undefined)?.tableId;
if (f.type === "table") {
const tid = (f.options as { tableId?: number } | null)?.tableId;
if (tid) tableIds.add(tid);
}
}
let tableMap = options.preloaded?.tableMap;
if (!tableMap) {
tableMap = new Map<number, { rows: Array<{ id: number; values: string[] }>; columns: Array<{ name: string }> }>();
const _storage = storage as any;
for (const tableId of tableIds) {
const fullTable = await _storage.getDataTableFull(tableId, organizationId);
const fullTable = await storage.getDataTableFull(tableId, organizationId);
if (fullTable) {
tableMap.set(tableId, {
rows: fullTable.rows as Array<{ id: number; values: string[] }>,
@@ -153,7 +156,7 @@ export async function evaluateAutoTransitions(
task = await applyAutoTransition(task, targetStatusId, statuses, allFields, allFieldValues, conditionContext, organizationId, transitions, options.triggeredBy);
// Refresh system field values after transition so chained transitions evaluate against updated state
const newStatusName = statuses.find((s: any) => s.id === task.currentStatusId)?.name;
const newStatusName = statuses.find((s) => s.id === task.currentStatusId)?.name;
const newSystemFieldValues = buildSystemFieldValuesForConditions(task, { statusName: newStatusName });
allFieldValues = [...fieldValues, ...newSystemFieldValues];
}
@@ -170,16 +173,24 @@ export async function evaluateAutoTransitions(
* Применяет один автопереход с учётом side effects.
*/
async function applyAutoTransition(
task: any,
task: Task,
targetStatusId: number,
statuses: any[],
formFields: any[],
fieldValues: any[],
statuses: FormStatus[],
// Поля формы + системные поля (_assignee, _status и т.п.) — структурный тип как в fieldConditionsMatch
formFields: Array<{
id: number;
code: string;
type?: string;
options?: unknown;
autoProlongationLabel?: string | null;
}>,
// Значения полей + системные значения — структурный тип как в fieldConditionsMatch
fieldValues: Array<{ fieldId: number; value: unknown }>,
conditionContext: FieldConditionContext,
organizationId: number,
transitions: any[],
transitions: StatusTransition[],
triggeredBy: number | null = null
): Promise<any> {
): Promise<Task> {
const fromStatus = statuses.find((s) => s.id === task.currentStatusId);
const toStatus = statuses.find((s) => s.id === targetStatusId);
const isFinalStatus = toStatus?.isFinal || false;
@@ -187,7 +198,7 @@ async function applyAutoTransition(
// Determine assignee from transitions (if any transition from current → target has assignee rules)
let targetAssigneeId: number | null = task.assignedTo;
const matchingTransitions = transitions.filter(
(t: any) => t.fromStatusId === task.currentStatusId && t.toStatusId === targetStatusId
(t) => t.fromStatusId === task.currentStatusId && t.toStatusId === targetStatusId
);
for (const transition of matchingTransitions) {
@@ -209,7 +220,7 @@ async function applyAutoTransition(
}
if (assignee.type === "role") {
const members = await storage.getRoleMembersByRole(assignee.id);
const firstUser = members.find((m: any) => m.userId);
const firstUser = members.find((m) => m.userId);
if (firstUser) {
targetAssigneeId = firstUser.userId;
break;
@@ -223,7 +234,7 @@ async function applyAutoTransition(
userId = task.createdBy ?? undefined;
} else {
const field = formFields.find(f => f.code === assignee.fieldCode);
const fieldValue = fieldValues.find((fv: any) => fv.fieldId === field?.id);
const fieldValue = fieldValues.find((fv) => fv.fieldId === field?.id);
userId = fieldValue?.value ? parseInt(String(fieldValue.value)) : undefined;
}
if (userId && userId > 0) {
@@ -233,10 +244,10 @@ async function applyAutoTransition(
}
if (assignee.type === 'table_column_user') {
const field = formFields.find(f => f.code === assignee.fieldCode);
const fieldValue = fieldValues.find((fv: any) => fv.fieldId === field?.id);
const fieldValue = fieldValues.find((fv) => fv.fieldId === field?.id);
const rowId = fieldValue?.value ? parseInt(String(fieldValue.value)) : NaN;
if (!isNaN(rowId) && rowId > 0) {
const tableId = (field as any)?.options?.tableId as number;
const tableId = (field?.options as { tableId?: number } | null)?.tableId;
if (tableId) {
const row = await storage.getDataTableRow(rowId, tableId, organizationId);
if (row) {
@@ -337,10 +348,10 @@ async function applyAutoTransition(
storage.getRoles(organizationId),
]);
const { buildSystemFieldValues } = await import("../routes/shared.js");
const fieldCodeMap = new Map(allFormFields.map((f: any) => [f.code, f]));
const valueByFieldId = new Map(savedValues.map((v: any) => [v.fieldId, v.value]));
const usersMap = new Map(titleUsers.map((u: any) => [u.id, u]));
const rolesMap = new Map(titleRoles.map((r: any) => [r.id, r]));
const fieldCodeMap = new Map(allFormFields.map((f) => [f.code, f]));
const valueByFieldId = new Map(savedValues.map((v) => [v.fieldId, v.value]));
const usersMap = new Map(titleUsers.map((u) => [u.id, u]));
const rolesMap = new Map(titleRoles.map((r) => [r.id, r]));
const sysValues = await buildSystemFieldValues(updatedTask, organizationId, { statuses: titleStatuses, usersMap });
const titleTaskTitleMap = await resolveTaskFieldTitles(valueByFieldId, allFormFields, organizationId);
const tableRowMap = await buildTableRowMap(allFormFields, organizationId);