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

@@ -63,7 +63,7 @@ DATABASE_URL=postgresql://user:password@host/dbname?sslmode=require
# The app automatically switches to S3 mode when MINIO_ENDPOINT is set.
# MINIO_ENDPOINT=http://minio:9000 # use "minio" as hostname inside Docker network
# MINIO_ACCESS_KEY=minioadmin # same as MINIO_ROOT_USER in docker-compose
# MINIO_ACCESS_KEY=minioadmin # пример для dev; в production обязательно сменить дефолтные креды
# MINIO_SECRET_KEY=your-strong-minio-password
# MINIO_BUCKET=files

View File

@@ -35,7 +35,7 @@ DATABASE_URL=postgresql://appuser:local_db_password_replace_me@db:5432/appdb
# File storage (local MinIO)
# =============================================
MINIO_ENDPOINT=http://minio:9000
MINIO_ACCESS_KEY=minioadmin
MINIO_ACCESS_KEY=minioadmin # только для локального стенда; в production сменить
# Generate a strong local password with: openssl rand -hex 16
MINIO_SECRET_KEY=local_minio_secret_replace_me
MINIO_BUCKET=files

View File

@@ -325,3 +325,26 @@
- Как проверялось: 8 новых тестов (tests/auth-security.test.ts) — vitest 104/104; check/lint чисто.
- Влияние на поиск/UX: нет; UX логина: деактивированный аккаунт видит «Неверный email или пароль» (цель anti-enumeration), после 10 попыток — сообщение о 15-минутной блокировке.
- Подводные камни: блокировки in-memory (рестарт обнуляет; multi-instance делит лимит — как остальные кэши).
---
## [2.3] Дедупликация
- Статус: ✅ done
- Зачем: единая точка форматирования сумм (Фаза 2).
- Что изменено: создан `client/src/lib/format.ts` с `formatAmount` (ru-RU, 2 знака, пустое → «0.00» — покрывает обе семантики копий); локальные копии в Billing.tsx и SuperAdminOrgDetail.tsx заменены импортом. Дубля ActiveFilterBadges/ColumnFilter нет — finance/-дубль удалён ранее вместе со старым модулем, канонические в ui/.
- Как проверялось: check/lint/build чисто; vitest 104/104.
- Влияние на поиск/UX: нет (отображение сумм идентично).
## [2.4] Гигиена репозитория
- Статус: ✅ done
- Зачем: мусор, пины образов, дефолты MinIO, доля any (Фаза 2).
- Что изменено:
- Удалён `_tablefield_orig.txt`.
- Пины образов во всех compose (по фактически работающим версиям на проде, существование тегов подтверждено docker manifest inspect): minio RELEASE.2025-09-07T16-13-09Z, rclone 1.74.0, ollama 0.30.10, traccar 6.15.2, pgvector 0.8.4-pg16, osrm latest@sha256:af5d… (тега v6.0.0 не существует — digest, совпал с продовым).
- Дефолты minioadmin убраны: `${MINIO_ACCESS_KEY:?...}` + комментарии в .env.example/.env.local.example (dev-only дефолт).
- 28 замен `: any` + 4 `as any` в server/utils/auto-transitions.ts (23→0, типы из @shared/schema) и server/routes/shared.ts (5) — без изменения логики. finance-di2/routes.ts (113 any) сознательно не тронут.
- Как проверялось: docker compose config валиден для всех 4 файлов; check/lint/build чисто; vitest 104/104.
- Влияние на поиск/UX: нет.
- Подводные камни: пины синхронизированы в серверный бэкап .bak.serverfix (крит. правило №1) — только image-теги, креды бэкапа не тронуты; локальный .env.local требует MINIO_ACCESS_KEY (есть в .env.local.example).

View File

@@ -1,141 +0,0 @@

> function TableFieldSelect({ formField, value, onChange }: {
formField: FormFieldData;
value: any;
onChange: (value: string) => void;
}) {
const options = formField.options && !Array.isArray(formField.options) ? formField.options : {};
const tableId = options.tableId;
const displayColumn = options.displayColumn || '';
const { data: tableData, isLoading, error } = useQuery<{ table: { rows: Array<{ id: number; values: string[] }>; co
lumns: Array<{ name: string }> } }>({
queryKey: ['/api/directories', tableId],
enabled: !!tableId,
staleTime: 5 * 60 * 1000,
gcTime: 10 * 60 * 1000,
retry: false,
});
const [searchTerm, setSearchTerm] = useState('');
const [isOpen, setIsOpen] = useState(false);
const inputRef = useRef<HTMLInputElement>(null);
if (!tableId) {
return <div className="text-sm text-muted-foreground">Таблица не настроена</div>;
}
if (isLoading) {
return <Input disabled className="h-8" placeholder="Загрузка..." />;
}
if (error) {
return <div className="text-sm text-destructive">Нет доступа к таблице</div>;
}
const table = tableData?.table;
if (!table || !table.rows?.length) {
return <div className="text-sm text-muted-foreground">Таблица пуста</div>;
}
// Находим индекс столбца для отображения, с fallback на первый столбец
let displayColumnIndex = table.columns?.findIndex(c => c.name === displayColumn);
if (displayColumnIndex === undefined || displayColumnIndex < 0) {
displayColumnIndex = 0;
}
const getDisplayValue = (rowId: string | number) => {
const row = table.rows?.find(r => r.id.toString() === rowId?.toString());
return row?.values[displayColumnIndex!] || row?.values[0] || '';
};
const selectedRow = table.rows?.find(r => r.id.toString() === value?.toString());
const selectedDisplay = selectedRow ? (selectedRow.values[displayColumnIndex!] || selectedRow.values[0] || '') : ''
;
const sortedRows = useMemo(() => {
return [...table.rows].sort((a, b) => {
const av = a.values[displayColumnIndex!] || a.values[0] || '';
const bv = b.values[displayColumnIndex!] || b.values[0] || '';
return av.localeCompare(bv, 'ru');
});
}, [table.rows, displayColumnIndex]);
const filtered = useMemo(() => {
if (!searchTerm.trim()) return sortedRows;
const term = searchTerm.toLowerCase();
return sortedRows.filter(r => {
const display = r.values[displayColumnIndex!] || r.values[0] || '';
return display.toLowerCase().includes(term);
});
}, [sortedRows, searchTerm, displayColumnIndex]);
const handleSelect = (rowId: string) => {
onChange(rowId);
setSearchTerm('');
setIsOpen(false);
};
return (
<Popover open={isOpen} onOpenChange={setIsOpen}>
<PopoverAnchor asChild>
<div className="relative w-full">
<Input
ref={inputRef}
className="h-8"
placeholder={formField.placeholder || 'Выберите значение...'}
value={isOpen ? searchTerm : (selectedDisplay || '')}
onChange={(e) => {
setSearchTerm(e.target.value);
setIsOpen(true);
}}
onFocus={() => {
setSearchTerm('');
setIsOpen(true);
}}
data-testid={`select-table-${formField.code}`}
/>
</div>
</PopoverAnchor>
<PopoverContent
className="p-0"
style={{ width: 'var(--radix-popper-anchor-width)' }}
onOpenAutoFocus={(e) => e.preventDefault()}
onInteractOutside={(e) => {
if (inputRef.current && inputRef.current.contains(e.target as Node)) {
e.preventDefault();
}
}}
>
<ul className="max-h-60 overflow-auto py-1">
<li
className="cursor-pointer px-3 py-2 hover:bg-accent text-sm text-muted-foreground"
onMouseDown={(e) => { e.preventDefault(); handleSelect(''); }}
>
—
</li>
{filtered.map(row => (
<li
key={row.id}
className="cursor-pointer px-3 py-2 hover:bg-accent"
onMouseDown={(e) => { e.preventDefault(); handleSelect(row.id.toString()); }}
>
<div className="flex items-center gap-2 text-sm">
<Table className="w-3 h-3 text-muted-foreground" />
{row.values[displayColumnIndex!] || row.values[0]}
</div>
</li>
))}
{filtered.length === 0 && (
<li className="px-3 py-2 text-sm text-muted-foreground">Нет совпадений</li>
)}
</ul>
</PopoverContent>
</Popover>
);
}
// Компонент выбора связанной задачи из другой формы
function TaskFieldSelect({ formField, value, onChange }: {

7
client/src/lib/format.ts Normal file
View File

@@ -0,0 +1,7 @@
// Форматирование денежных сумм: разделители разрядов ru-RU, всегда 2 знака после запятой.
// Пустое/некорректное значение отображается как "0.00".
export function formatAmount(amount: string | number | undefined | null): string {
const value = typeof amount === "number" ? amount : parseFloat(amount ?? "");
if (!isFinite(value)) return "0.00";
return value.toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
}

View File

@@ -19,6 +19,7 @@ import { Wallet, Users, TrendingDown, Calendar, ArrowUpCircle, ArrowDownCircle,
import { format } from "date-fns";
import { ru } from "date-fns/locale";
import { apiRequest } from "@/lib/queryClient";
import { formatAmount } from "@/lib/format";
import { useToast } from "@/hooks/use-toast";
interface BillingSummary {
@@ -149,9 +150,6 @@ export default function Billing() {
const currency = summary?.currency ?? "RUB";
const isMockProvider = summary?.paymentProvider === "mock";
const formatAmount = (amount: string) =>
parseFloat(amount).toLocaleString("ru-RU", { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const handleTopUp = () => {
const amountNum = parseFloat(amount.replace(",", ".").replace(/\s/g, ""));
if (!amountNum || isNaN(amountNum) || amountNum <= 0) {

View File

@@ -14,6 +14,7 @@ import { useState } from "react";
import { useToast } from "@/hooks/use-toast";
import { format } from "date-fns";
import { ru } from "date-fns/locale";
import { formatAmount } from "@/lib/format";
interface BillingTransaction {
id: number;
@@ -176,9 +177,6 @@ export default function SuperAdminOrgDetail() {
const billing = billingData?.billing;
const transactions = txData?.transactions ?? [];
const formatAmount = (amount: string | undefined) =>
amount ? parseFloat(amount).toLocaleString("ru-RU", { minimumFractionDigits: 2 }) : "0.00";
return (
<div className="min-h-screen bg-slate-950 text-white">
{/* Header */}

View File

@@ -12,7 +12,8 @@
services:
db:
image: pgvector/pgvector:pg16
# Тег запинен: pgvector 0.8.4 + PostgreSQL 16 (совпадает с локальным стендом)
image: pgvector/pgvector:0.8.4-pg16
environment:
POSTGRES_DB: ${POSTGRES_DB:-appdb}
POSTGRES_USER: ${POSTGRES_USER:-appuser}
@@ -30,13 +31,15 @@ services:
start_period: 10s
minio:
image: minio/minio:latest
# Тег запинен: совпадает с версией на продакшене (RELEASE.2025-09-07T16-13-09Z)
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
command: server /data --console-address :9001
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin}
# Креды MinIO — только из .env, дефолтов нет: в production обязательно сменить minioadmin/minioadmin
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required}
volumes:
- minio_data:/data
@@ -49,7 +52,8 @@ services:
start_period: 15s
webdav:
image: rclone/rclone:latest
# Тег запинен: совпадает с версией на продакшене (rclone v1.74.0)
image: rclone/rclone:1.74.0
entrypoint: /bin/sh
command: >
-c "rclone serve webdav
@@ -59,7 +63,7 @@ services:
--no-checksum
:s3:$${MINIO_BUCKET:-files}
--s3-provider=Minio
--s3-access-key-id=$${MINIO_ACCESS_KEY:-minioadmin}
--s3-access-key-id=$${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required}
--s3-secret-access-key=$${MINIO_SECRET_KEY}
--s3-endpoint=http://minio:9000
--s3-no-check-bucket"
@@ -68,7 +72,7 @@ services:
environment:
WEBDAV_USER: ${WEBDAV_USER:-admin}
WEBDAV_PASSWORD: ${WEBDAV_PASSWORD:-}
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin}
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-}
MINIO_BUCKET: ${MINIO_BUCKET:-files}
restart: unless-stopped
@@ -101,7 +105,8 @@ services:
start_period: 30s
ollama:
image: ollama/ollama:latest
# Тег запинен: совпадает с версией на продакшене (ollama 0.30.10)
image: ollama/ollama:0.30.10
profiles:
- with-ollama
ports:

View File

@@ -40,7 +40,8 @@ services:
required: false
db:
image: pgvector/pgvector:pg16
# Тег запинен: pgvector 0.8.4 + PostgreSQL 16 (совпадает с локальным стендом)
image: pgvector/pgvector:0.8.4-pg16
environment:
POSTGRES_DB: ${POSTGRES_DB:-appdb}
POSTGRES_USER: ${POSTGRES_USER:-appuser}
@@ -56,13 +57,15 @@ services:
start_period: 10s
minio:
image: minio/minio:latest
# Тег запинен: совпадает с версией на продакшене (RELEASE.2025-09-07T16-13-09Z)
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
command: server /data --console-address :9001
ports:
- "9000:9000"
- "9001:9001"
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin}
# Креды MinIO — только из .env.local, дефолтов нет: в production обязательно сменить minioadmin/minioadmin
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required}
volumes:
- minio_data:/data
@@ -75,7 +78,8 @@ services:
start_period: 15s
webdav:
image: rclone/rclone:latest
# Тег запинен: совпадает с версией на продакшене (rclone v1.74.0)
image: rclone/rclone:1.74.0
entrypoint: /bin/sh
command: >
-c "rclone serve webdav
@@ -85,7 +89,7 @@ services:
--no-checksum
:s3:$${MINIO_BUCKET:-files}
--s3-provider=Minio
--s3-access-key-id=$${MINIO_ACCESS_KEY:-minioadmin}
--s3-access-key-id=$${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required}
--s3-secret-access-key=$${MINIO_SECRET_KEY}
--s3-endpoint=http://minio:9000
--s3-no-check-bucket"
@@ -94,7 +98,7 @@ services:
environment:
WEBDAV_USER: ${WEBDAV_USER:-admin}
WEBDAV_PASSWORD: ${WEBDAV_PASSWORD:-}
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin}
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-}
MINIO_BUCKET: ${MINIO_BUCKET:-files}
restart: unless-stopped
@@ -103,7 +107,8 @@ services:
condition: service_healthy
ollama:
image: ollama/ollama:latest
# Тег запинен: совпадает с версией на продакшене (ollama 0.30.10)
image: ollama/ollama:0.30.10
profiles:
- with-ollama
ports:

View File

@@ -43,7 +43,8 @@ services:
start_period: 10s
minio:
image: minio/minio:latest
# Тег запинен: совпадает с версией на продакшене (RELEASE.2025-09-07T16-13-09Z)
image: minio/minio:RELEASE.2025-09-07T16-13-09Z
profiles:
- with-minio
command: server /data --console-address :9001
@@ -51,7 +52,8 @@ services:
- "9000:9000" # S3 API (используется приложением)
- "9001:9001" # MinIO Web Console (http://localhost:9001)
environment:
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:-minioadmin}
# Креды MinIO — только из .env, дефолтов нет: в production обязательно сменить minioadmin/minioadmin
MINIO_ROOT_USER: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required}
MINIO_ROOT_PASSWORD: ${MINIO_SECRET_KEY:?MINIO_SECRET_KEY is required}
volumes:
- minio_data:/data
@@ -64,7 +66,8 @@ services:
start_period: 15s
webdav:
image: rclone/rclone:latest
# Тег запинен: совпадает с версией на продакшене (rclone v1.74.0)
image: rclone/rclone:1.74.0
profiles:
- with-minio
entrypoint: /bin/sh
@@ -76,7 +79,7 @@ services:
--no-checksum
:s3:$${MINIO_BUCKET:-files}
--s3-provider=Minio
--s3-access-key-id=$${MINIO_ACCESS_KEY:-minioadmin}
--s3-access-key-id=$${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required}
--s3-secret-access-key=$${MINIO_SECRET_KEY}
--s3-endpoint=http://minio:9000
--s3-no-check-bucket"
@@ -85,7 +88,7 @@ services:
environment:
WEBDAV_USER: ${WEBDAV_USER:-admin}
WEBDAV_PASSWORD: ${WEBDAV_PASSWORD:-}
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:-minioadmin}
MINIO_ACCESS_KEY: ${MINIO_ACCESS_KEY:?MINIO_ACCESS_KEY is required}
MINIO_SECRET_KEY: ${MINIO_SECRET_KEY:-}
MINIO_BUCKET: ${MINIO_BUCKET:-files}
restart: unless-stopped
@@ -94,7 +97,8 @@ services:
condition: service_healthy
ollama:
image: ollama/ollama:latest
# Тег запинен: совпадает с версией на продакшене (ollama 0.30.10)
image: ollama/ollama:0.30.10
profiles:
- with-ollama
ports:
@@ -138,7 +142,8 @@ services:
start_period: 30s
traccar:
image: traccar/traccar:latest
# Тег запинен: совпадает с версией на продакшене (traccar 6.15.2)
image: traccar/traccar:6.15.2
container_name: traccar
restart: unless-stopped
ports:
@@ -164,7 +169,9 @@ services:
# Map matching (привязка GPS-треков к дорогам) — OSRM, карта Приволжского округа.
# Только внутренняя Docker-сеть (приложение ходит по http://osrm:5000).
osrm:
image: osrm/osrm-backend:latest
# Пин по digest: тега v6.0.0 не существует, есть только latest
# (digest совпадает с образом на продакшене на момент пина)
image: osrm/osrm-backend:latest@sha256:af5d4a83fb90086a43b1ae2ca22872e6768766ad5fcbb07a29ff90ec644ee409
container_name: osrm
restart: unless-stopped
command: osrm-routed --algorithm mld /data/region.osrm

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);