From 75ec0f25bbd657737c1be6afbd6d7c71923ec58f Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?=D0=98=D0=BB=D1=8C=D1=8F=D1=81=20=D0=A1=D1=83=D0=BB=D1=82?= =?UTF-8?q?=D0=B0=D0=BD=D0=BE=D0=B2?= Date: Tue, 8 Sep 2026 01:07:55 +0300 Subject: [PATCH] =?UTF-8?q?chore(repo):=20=D0=B4=D0=B5=D0=B4=D1=83=D0=BF?= =?UTF-8?q?=20formatAmount,=20=D0=BF=D0=B8=D0=BD=D1=8B=20=D0=BE=D0=B1?= =?UTF-8?q?=D1=80=D0=B0=D0=B7=D0=BE=D0=B2,=20=D0=B4=D0=B5=D1=84=D0=BE?= =?UTF-8?q?=D0=BB=D1=82=D1=8B=20MinIO,=20=D0=B4=D0=BE=D0=BB=D1=8F=20any?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Шаги 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 --- .env.example | 2 +- .env.local.example | 2 +- IMPLEMENTATION_LOG.md | 23 ++++ _tablefield_orig.txt | 141 ----------------------- client/src/lib/format.ts | 7 ++ client/src/pages/Billing.tsx | 4 +- client/src/pages/SuperAdminOrgDetail.tsx | 4 +- docker-compose.deps.yml | 19 +-- docker-compose.local.yml | 19 +-- docker-compose.yml | 23 ++-- server/routes/shared.ts | 9 +- server/utils/auto-transitions.ts | 69 ++++++----- 12 files changed, 118 insertions(+), 204 deletions(-) delete mode 100644 _tablefield_orig.txt create mode 100644 client/src/lib/format.ts diff --git a/.env.example b/.env.example index 8b804cf..2a76628 100644 --- a/.env.example +++ b/.env.example @@ -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 diff --git a/.env.local.example b/.env.local.example index 81cc7cf..d91cf2f 100644 --- a/.env.local.example +++ b/.env.local.example @@ -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 diff --git a/IMPLEMENTATION_LOG.md b/IMPLEMENTATION_LOG.md index 802bf51..a22411a 100644 --- a/IMPLEMENTATION_LOG.md +++ b/IMPLEMENTATION_LOG.md @@ -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). diff --git a/_tablefield_orig.txt b/_tablefield_orig.txt deleted file mode 100644 index 66a6bad..0000000 --- a/_tablefield_orig.txt +++ /dev/null @@ -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(null); - - if (!tableId) { - return
Таблица не настроена
; - } - - if (isLoading) { - return ; - } - - if (error) { - return
Нет доступа к таблице
; - } - - const table = tableData?.table; - if (!table || !table.rows?.length) { - return
Таблица пуста
; - } - - // Находим индекс столбца для отображения, с 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 ( - - -
- { - setSearchTerm(e.target.value); - setIsOpen(true); - }} - onFocus={() => { - setSearchTerm(''); - setIsOpen(true); - }} - data-testid={`select-table-${formField.code}`} - /> -
-
- e.preventDefault()} - onInteractOutside={(e) => { - if (inputRef.current && inputRef.current.contains(e.target as Node)) { - e.preventDefault(); - } - }} - > -
    -
  • { e.preventDefault(); handleSelect(''); }} - > - — -
  • - {filtered.map(row => ( -
  • { e.preventDefault(); handleSelect(row.id.toString()); }} - > -
    - - {row.values[displayColumnIndex!] || row.values[0]} - - - ))} - {filtered.length === 0 && ( -
  • Нет совпадений
  • - )} - - - - ); - } - - // Компонент выбора связанной задачи из другой формы - function TaskFieldSelect({ formField, value, onChange }: { - - diff --git a/client/src/lib/format.ts b/client/src/lib/format.ts new file mode 100644 index 0000000..bee2684 --- /dev/null +++ b/client/src/lib/format.ts @@ -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 }); +} diff --git a/client/src/pages/Billing.tsx b/client/src/pages/Billing.tsx index a75a9ed..2922f89 100644 --- a/client/src/pages/Billing.tsx +++ b/client/src/pages/Billing.tsx @@ -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) { diff --git a/client/src/pages/SuperAdminOrgDetail.tsx b/client/src/pages/SuperAdminOrgDetail.tsx index 2a419f1..8f422e6 100644 --- a/client/src/pages/SuperAdminOrgDetail.tsx +++ b/client/src/pages/SuperAdminOrgDetail.tsx @@ -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 (
    {/* Header */} diff --git a/docker-compose.deps.yml b/docker-compose.deps.yml index b57e1d1..44cf264 100644 --- a/docker-compose.deps.yml +++ b/docker-compose.deps.yml @@ -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: diff --git a/docker-compose.local.yml b/docker-compose.local.yml index 6d337ed..4723082 100644 --- a/docker-compose.local.yml +++ b/docker-compose.local.yml @@ -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: diff --git a/docker-compose.yml b/docker-compose.yml index cc95568..dda7262 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -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 diff --git a/server/routes/shared.ts b/server/routes/shared.ts index af84edb..82d0860 100644 --- a/server/routes/shared.ts +++ b/server/routes/shared.ts @@ -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; + statuses?: FormStatus[]; + usersMap?: Map; } 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> { @@ -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) { diff --git a/server/utils/auto-transitions.ts b/server/utils/auto-transitions.ts index 4eab112..1a1de16 100644 --- a/server/utils/auto-transitions.ts +++ b/server/utils/auto-transitions.ts @@ -8,6 +8,10 @@ */ import { storage } from "../storage"; +import type { FormField, FormStatus, SafeUser, StatusTransition, Task } from "@shared/schema"; + +// Тип роли с метаданными, как её возвращает storage.getRoles +type OrgRole = Awaited>[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; 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(); for (const f of formFields) { - if ((f as any).type === "table") { - const tid = ((f as any).options as Record | 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; 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 { +): Promise { 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);