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:
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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).
|
||||
|
||||
@@ -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
7
client/src/lib/format.ts
Normal 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 });
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 */}
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
Reference in New Issue
Block a user