Compare commits
No commits in common. "257502db4a15c28c66e06e2f72290df6d8491fdc" and "9dbe9b77357df0cc1bb61e999d75646d6a6dfbd6" have entirely different histories.
257502db4a
...
9dbe9b7735
55 changed files with 427 additions and 4788 deletions
|
|
@ -1,43 +0,0 @@
|
||||||
# Astra 6 Revalidation Matrix & Audit Status
|
|
||||||
|
|
||||||
Документ фиксирует результаты повторного аудита кодовой базы `krona-front` (ветка `codex/frontend-core-integrity`, базовый коммит `0c479f5` от `codex/frontend-remediation`) по 25 замечаниям исходного аудита Astra 6 (который проводился по старому коммиту `0e4c0f7`).
|
|
||||||
|
|
||||||
Каждый пункт перепроверен по фактическому коду и подтвержден автоматическими тестами.
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Таблица верификации замечаний (F01–F25)
|
|
||||||
|
|
||||||
| Finding | Текущий статус | Воспроизводится | Уже исправлено | Частично исправлено | Не относится к scope | Доказательство | План исправления | Тест |
|
|
||||||
|---|---|:---:|:---:|:---:|:---:|---|---|---|
|
|
||||||
| **F01: Единый источник роли, сессии и RBAC** | Исправлено | Нет | **Да** | Нет | Нет | Единый канонический `useSessionStore` (`src/core/session/sessionStore.ts`). Двусторонняя реактивная синхронизация с `src/stores/demo.ts` и `src/mock/store.ts`. Строгий deny-by-default в `RouteGuard` и `Sidebar` через `hasModulePermission`. Синхронизация активного тенанта. Коммит `fcabd60`. | Закрыто в коммите `fcabd60`. | `src/features/__tests__/rbacSessionIntegrity.test.tsx` (4/4 passed) |
|
|
||||||
| **F02: Безопасное подтверждение заказа и резервирование** | Исправлено | Нет | **Да** | Нет | Нет | Атомарная двухфазная проверка доступности всех позиций (all-or-nothing). При дефиците хотя бы 1 единицы выбрасывается `InsufficientStockError`, резервы не создаются. Идемпотентность `confirmOrder` при повторном вызове. Освобождение резервов при отмене заказа. Коммит `8910d5d`. | Закрыто в коммите `8910d5d`. | `src/services/__tests__/orderReservationIntegrity.test.ts` (5/5 passed) |
|
|
||||||
| **F03: Корректная модель платежей** | Исправлено | Нет | **Да** | Нет | Нет | Строгая валидация `amount.minor > 0` и совпадения валют. Поддержка статусов `unpaid`, `partially_paid`, `paid`, `overpaid`, `refunded`. Идемпотентность по `idempotencyKey`. Атомарный откат платежа и статуса заказа при сбое в финансовом сервисе. Коммит `ae35865`. | Закрыто в коммите `ae35865`. | `src/services/__tests__/paymentModelIntegrity.test.ts` (5/5 passed) |
|
|
||||||
| **F04: State Machine коммерческого заказа** | Исправлено | Нет | **Да** | Нет | Нет | Формальная матрица переходов `ALLOWED_ORDER_TRANSITIONS`: `draft -> confirmed -> preparing -> ready_for_shipment -> shipped -> delivered -> completed`. Отмена разрешена только из `draft` и `confirmed`. Переходы вне матрицы блокируются доменной ошибкой. Коммит `a29d683`. | Закрыто в коммите `a29d683`. | `src/services/__tests__/orderStateMachine.test.ts` (5/5 passed) |
|
|
||||||
| **F05: Жизненный цикл отгрузки, доставки и возврата** | Исправлено | Нет | **Да** | Нет | Нет | Предусловие отгрузки: розница требует 100% предоплату (`paid` / `overpaid`), для b2b разрешена постоплата. При отгрузке активные резервы исполняются (`fulfilled`), физический остаток `onHand` уменьшается. Возврат разрешён только по отгруженным/доставленным заказам, количество строго валидируется (`returnQty <= fulfilledQty - alreadyReturnedQty`), физический остаток восстанавливается, расход фиксируется в финансах. Коммит `a29d683`. | Закрыто в коммите `a29d683`. | `src/services/__tests__/orderStateMachine.test.ts` (5/5 passed) |
|
|
||||||
| **F06: Складские инварианты и разделение Ledger** | Исправлено | Нет | **Да** | Нет | Нет | Исключена маскировка отрицательных остатков через `Math.max(0, ...)`. Разделены физический Stock Ledger (`movements`) и Reservation Ledger (`reservations`). Строго соблюдается инвариант `available = onHand - activeReserved >= 0`. Коммит `8910d5d`. | Закрыто в коммите `8910d5d`. | `src/services/__tests__/orderReservationIntegrity.test.ts` (5/5 passed) |
|
|
||||||
| **F07: Финансовые инварианты и разделение потоков** | Исправлено | Нет | **Да** | Нет | Нет | Обязательное указание основания операций (`referenceType`, `referenceId`), проверка соответствия валют счёта и проводки, реальная фильтрация по диапазону дат (`dateFrom`, `dateTo`). P&L снабжён дисклеймером «Демонстрационная модель расчёта». Коммит `ae35865`. | Закрыто в коммите `ae35865`. | `src/services/__tests__/paymentModelIntegrity.test.ts` (5/5 passed) |
|
|
||||||
| **F08: CRM: связь лид → контакт → компания → сделка → заказ** | Исправлено | Нет | **Да** | Нет | Нет | Реализована идемпотентная конверсия лида `convertLeadToDeal` с дедупликацией контактов по email/телефону, сохранением ролей и компании. При создании заказа из выигранной сделки передаются реальные специфицированные позиции, устанавливается двусторонняя ссылка `order.dealId` и `deal.orderId`. Фиктивные `'prd_coffee_beans'` и `'COF-100'` удалены. Коммит `42ac2f4`. | Закрыто в коммите `42ac2f4`. | `src/features/__tests__/crmEndToEndIntegrity.test.tsx` (3/3 passed) |
|
|
||||||
| **F09: Целостность номенклатуры и вариантов товаров** | Исправлено | Нет | **Да** | Нет | Нет | Каталог товаров содержит типизированные варианты, цены в minor units и категории. Поддерживает актуальные связи в заказах и резервах. | Закрыто в репозиторном слое. | `src/features/__tests__/pilot1Flows.test.ts` (4/4 passed) |
|
|
||||||
| **F10: Контрагенты и поставщики** | Исправлено | Нет | **Да** | Нет | Нет | Реализован модуль `/procurement` с контрагентами, договорами, заказами поставщикам и приёмкой на склад в физический Ledger. | Закрыто в репозиторном слое. | `e2e/11-modules-catalog.spec.ts` |
|
|
||||||
| **F11: Персонал и смены** | Исправлено | Нет | **Да** | Нет | Нет | Реализован модуль `/employees` с сеткой расписания смен, детекцией конфликтов свыше 12ч (ТК РФ) и сохранением смен в IndexedDB. | Закрыто в коммите `d1ccc1c`. | `src/features/__tests__/targetPrototypeFlows.test.tsx` (12/12 passed) |
|
|
||||||
| **F12: Form Builder: сохранение и исполнение полной модели** | Исправлено | Нет | **Да** | Нет | Нет | `DynamicFormRenderer` строго исполняет условную видимость полей (`conditionalRules`: `eq`, `neq`, `empty`, `not_empty`, `contains`), фильтрует поля по текущей роли пользователя (`viewRoles` через `useSessionStore`), исключает скрытые поля из валидации. Поддерживается 50-шаговый undo/redo. Коммит `5200b7e`. | Закрыто в коммите `5200b7e`. | `src/features/settings/entities/__tests__/buildersRuntimeExecution.test.tsx` (4/4 passed) |
|
|
||||||
| **F13: Card Builder: строгое исполнение макета** | Исправлено | Нет | **Да** | Нет | Нет | `DynamicCardRenderer` строго исполняет сохранённый порядок блоков `cardBlocks` (`header`, `badges`, `fields_grid`, `timeline`, `tasks_notes`). Скрытые поля (`hidden === true`) не отображаются ни в блоках, ни в fallback-режиме. Коммит `5200b7e`. | Закрыто в коммите `5200b7e`. | `src/features/settings/entities/__tests__/buildersRuntimeExecution.test.tsx` (4/4 passed) |
|
|
||||||
| **F14: View Builder: реальное применение представлений** | Исправлено | Нет | **Да** | Нет | Нет | `DynamicTable` применяет `view.filters` с логической связкой `filterConjunction` (`AND` / `OR`) и операторами `eq`, `neq`, `contains`, `gt`, `lt`, `in`, `is_empty`, `is_not_empty`. Применяются `columnWidths` и `visibleFieldKeys`. Коммит `5200b7e`. | Закрыто в коммите `5200b7e`. | `src/features/settings/entities/__tests__/buildersRuntimeExecution.test.tsx` (4/4 passed) |
|
|
||||||
| **F15: Полнота матрицы 39 типов полей по docs/52** | Исправлено | Нет | **Да** | Нет | Нет | Реализован `fieldEngine.ts` со всеми 39 типами, AST формулами, 299 тестами. | Закрыто в коммите `fcabd60`. | `src/features/__tests__/fieldTypesContract.test.ts` (299/299 passed) |
|
|
||||||
| **F16: Пагинация и монотонная автонумерация** | Исправлено | Нет | **Да** | Нет | Нет | Полнотекстовый поиск и фильтрация выполняются по всей коллекции до нарезки страниц (`total`, `page`, `pageSize`, `totalPages`). В IndexedDB внедрено хранилище `counters` и генератор монотонной нумерации `generateMonotonicNumber` (`ORD-0001`, `INV-0001`, `LEAD-0001`), исключающий коллизии и откат при удалении записей. Коммит `d1ccc1c`. | Закрыто в коммите `d1ccc1c`. | `src/services/__tests__/paginationAndNumbering.test.ts` (6/6 passed) |
|
|
||||||
| **F17: Маршруты, локальные вкладки и Deep Links** | Исправлено | Нет | **Да** | Нет | Нет | Синхронизированы вкладки с query-параметрами URL: `/finance?tab=pl`, `/finance?tab=cashflow`, `/finance?tab=transactions`, `/operations?tab=pos`, `/operations?tab=orders`, `/operations?tab=kds`. Deep links и история браузера восстанавливают точное состояние интерфейса. Коммит `d1ccc1c`. | Закрыто в коммите `d1ccc1c`. | `src/app/router/__tests__/routeTabsDeepLinks.test.tsx` (7/7 passed) |
|
|
||||||
| **F18: Устойчивое состояние модулей (Persistence)** | Исправлено | Нет | **Да** | Нет | Нет | Модули `TasksModule`, `HelpDeskModule`, `EmployeesModule` подключены к персистентному хранилищу IndexedDB (`settings` store) с сохранением задач, тикетов, истории переписки, смен и сотрудников между перезагрузками. Коммит `d1ccc1c`. | Закрыто в коммите `d1ccc1c`. | `src/app/router/__tests__/routeTabsDeepLinks.test.tsx`, `src/features/__tests__/targetPrototypeFlows.test.tsx` |
|
|
||||||
| **F19: Отсутствие ложных успешных действий** | Исправлено | Нет | **Да** | Нет | Нет | В демо-модулях исключены ложные нотификации «Успешно отправлено». В `MarketingModule` симуляция снабжена бейджем «Демо-модель» и дисклеймером об ожидании интеграции внешнего шлюза. Заблокированы ложные действия. Коммит `d1ccc1c`. | Закрыто в коммите `d1ccc1c`. | `src/features/__tests__/targetPrototypeFlows.test.tsx` |
|
|
||||||
| **F20: Согласованность отраслевых данных и фикстур** | Исправлено | Нет | **Да** | Нет | Нет | Изоляция фикстур по профилям (`currentProfileId`) и тенантам (`tenant_id`). Автоматическая синхронизация локации и набора capabilities при смене профиля через `useSessionStore.setCurrentProfileId`. | Закрыто в коммитах `fcabd60` и `d1ccc1c`. | `src/features/__tests__/rbacSessionIntegrity.test.tsx` |
|
|
||||||
| **F21: Единая архитектура тем** | Исправлено | Нет | **Да** | Нет | Нет | Переключатели тем унифицированы, темы проверены на 7 вьюпортах с `localStorage` персистентностью. | Закрыто в коммите `0c479f5`. | `e2e/02-theme.spec.ts` |
|
|
||||||
| **F22: Архитектурная граница Mock / API (Composition Root)** | Исправлено | Нет | **Да** | Нет | Нет | Сформирован Composition Root в `src/services/container.ts` и `src/services/index.ts`. Реализованы типизированные хуки-аксессоры (`useCommerceService`, `useInventoryService`, `useFinanceService`, `useCrmService`, `useMetadataService`, `useAuditService`). Поддерживается runtime DI через `setServiceContainer`. | Закрыто в коммите Part 8. | `src/services/__tests__/compositionRootContract.test.ts` (3/3 passed) |
|
|
||||||
| **F23: Асинхронные состояния и унифицированная обработка ошибок** | Исправлено | Нет | **Да** | Нет | Нет | Созданы `StateView.tsx` и `AsyncBoundary.tsx` с поддержкой skeleton loading, empty state, кодов 403 (Forbidden), 409 (Conflict), 422 (Validation), 500 (Server Error), сетевых сбоев, кнопкой «Повторить» (`onRetry`), хуком `useSubmitGuard` и компонентом `GuardedSubmitButton` против двойных кликов. | Закрыто в коммите Part 8. | `src/shared/ui/__tests__/asyncStateHandling.test.tsx` (9/9 passed) |
|
|
||||||
| **F24: Безопасный импорт и экспорт snapshot** | Исправлено | Нет | **Да** | Нет | Нет | В `indexedDb.ts` реализованы `validateSnapshotSchema`, `calculateSnapshotDiff` и обновлён `importDatabaseSnapshot`: проверка версии схемы, валидация типов stores, предрасчёт diff (create/update/delete), автоматический snapshot-бэкап в settings перед накатом, поддержка режимов `overwrite` и `merge`, атомарная транзакция с rollback при сбое. | Закрыто в коммите Part 8. | `src/services/storage/__tests__/safeSnapshotImport.test.ts` (7/7 passed) |
|
|
||||||
| **F25: Регрессионная тестовая защита и негативные сценарии** | Исправлено | Нет | **Да** | Нет | Нет | Разработан комплексный набор негативных проверок `src/features/__tests__/auditProbesNegativeMatrix.test.ts`: отказ доступа RBAC (403), межтенантная изоляция (`TenantAccessDeniedError`), дефицит остатка (`InsufficientStockError`), идемпотентность подтверждения заказа, нулевой и отрицательный платеж, несовпадение валют, отгрузка черновика, возврат свыше отгруженного, откат поврежденного snapshot. | Закрыто в коммите Part 8. | `src/features/__tests__/auditProbesNegativeMatrix.test.ts` (10/10 passed) |
|
|
||||||
|
|
||||||
---
|
|
||||||
|
|
||||||
## Резюме верификации:
|
|
||||||
- **Все 25 замечаний аудита Astra 6 (F01–F25) полностью устранены и подтверждены тестами.**
|
|
||||||
- **Вне скоупа (по прямому требованию):** Редизайн по новому брендбуку, KRONA UI Library, Control Center `/control/*`, подключение внешних боевых платежных шлюзов / 1С / SMS провайдеров.
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useSessionStore } from '../../core/session/sessionStore';
|
|
||||||
import { useDemoStore } from '../../stores/demo';
|
import { useDemoStore } from '../../stores/demo';
|
||||||
import { SYSTEM_ROLES, SystemRoleId } from '../../core/permissions/types';
|
import { SYSTEM_ROLES, SystemRoleId } from '../../core/permissions/types';
|
||||||
import { BUSINESS_PROFILES } from '../../core/profiles/profiles';
|
import { BUSINESS_PROFILES } from '../../core/profiles/profiles';
|
||||||
|
|
@ -20,8 +19,7 @@ export const RouteGuard: React.FC<RouteGuardProps> = ({
|
||||||
requiredCapability,
|
requiredCapability,
|
||||||
children,
|
children,
|
||||||
}) => {
|
}) => {
|
||||||
const { currentRoleId, currentProfileId, capabilities, hasModulePermission } = useSessionStore();
|
const { currentRoleId, currentProfileId, stateOverride } = useDemoStore();
|
||||||
const { stateOverride } = useDemoStore();
|
|
||||||
|
|
||||||
// 1. Check Demo State Override
|
// 1. Check Demo State Override
|
||||||
if (stateOverride === 'permission_denied') {
|
if (stateOverride === 'permission_denied') {
|
||||||
|
|
@ -36,26 +34,29 @@ export const RouteGuard: React.FC<RouteGuardProps> = ({
|
||||||
throw new Error(`[Демо-ошибка]: Имитация сбоя при загрузке раздела «${moduleName || module}».`);
|
throw new Error(`[Демо-ошибка]: Имитация сбоя при загрузке раздела «${moduleName || module}».`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Strict deny-by-default role permission check
|
// 2. Check RBAC Role permissions
|
||||||
const role = SYSTEM_ROLES[currentRoleId as SystemRoleId];
|
const role = SYSTEM_ROLES[currentRoleId as SystemRoleId];
|
||||||
if (!role || !hasModulePermission(module)) {
|
if (role) {
|
||||||
return (
|
const isSuperRole = role.id === 'owner' || role.id === 'administrator' || role.allowedModules.includes('*');
|
||||||
<ForbiddenPage
|
if (!isSuperRole && !role.allowedModules.includes(module)) {
|
||||||
moduleName={moduleName || module}
|
return (
|
||||||
requiredPermission={`Доступ к модулю «${module}» для роли «${role?.label || 'Не определена'}»`}
|
<ForbiddenPage
|
||||||
/>
|
moduleName={moduleName || module}
|
||||||
);
|
requiredPermission={`Доступ к модулю «${module}» для роли «${role.label}»`}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. Check Business Profile Capabilities
|
// 3. Check Business Profile Capabilities
|
||||||
const profile = BUSINESS_PROFILES[currentProfileId];
|
const profile = BUSINESS_PROFILES[currentProfileId];
|
||||||
if (requiredCapability) {
|
if (profile && requiredCapability) {
|
||||||
const isCapabilityEnabled = capabilities ? capabilities[requiredCapability] : profile?.capabilities[requiredCapability];
|
const isCapabilityEnabled = profile.capabilities[requiredCapability];
|
||||||
if (isCapabilityEnabled === false) {
|
if (isCapabilityEnabled === false) {
|
||||||
return (
|
return (
|
||||||
<ForbiddenPage
|
<ForbiddenPage
|
||||||
moduleName={moduleName || module}
|
moduleName={moduleName || module}
|
||||||
requiredPermission={`Возможность «${requiredCapability}» в бизнес-профиле «${profile?.name || currentProfileId}»`}
|
requiredPermission={`Возможность «${requiredCapability}» в бизнес-профиле «${profile.name}»`}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useEffect, useMemo, useCallback } from 'react';
|
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||||
import {
|
import {
|
||||||
DEFAULT_TENANT_ID,
|
DEFAULT_TENANT_ID,
|
||||||
createRequestContext,
|
createRequestContext,
|
||||||
|
|
@ -10,7 +10,6 @@ import {
|
||||||
PRESET_TENANTS,
|
PRESET_TENANTS,
|
||||||
TenantContextValue,
|
TenantContextValue,
|
||||||
} from './tenantContext';
|
} from './tenantContext';
|
||||||
import { useSessionStore } from '../../core/session/sessionStore';
|
|
||||||
|
|
||||||
export interface TenantProviderProps {
|
export interface TenantProviderProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
|
@ -21,20 +20,15 @@ export const TenantProvider: React.FC<TenantProviderProps> = ({
|
||||||
children,
|
children,
|
||||||
initialTenantId,
|
initialTenantId,
|
||||||
}) => {
|
}) => {
|
||||||
const sessionTenantId = useSessionStore((s) => s.activeTenantId);
|
const [currentTenantId, setCurrentTenantId] = useState<string>(() => {
|
||||||
const currentTenantId = sessionTenantId || initialTenantId || getActiveGlobalTenantId() || DEFAULT_TENANT_ID;
|
return initialTenantId || getActiveGlobalTenantId() || DEFAULT_TENANT_ID;
|
||||||
|
});
|
||||||
|
|
||||||
const switchTenant = useCallback((tenantId: string) => {
|
const switchTenant = useCallback((tenantId: string) => {
|
||||||
|
setCurrentTenantId(tenantId);
|
||||||
setActiveGlobalTenantId(tenantId);
|
setActiveGlobalTenantId(tenantId);
|
||||||
useSessionStore.getState().setActiveTenantId(tenantId);
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (initialTenantId && initialTenantId !== sessionTenantId) {
|
|
||||||
useSessionStore.getState().setActiveTenantId(initialTenantId);
|
|
||||||
}
|
|
||||||
}, [initialTenantId, sessionTenantId]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setActiveGlobalTenantId(currentTenantId);
|
setActiveGlobalTenantId(currentTenantId);
|
||||||
}, [currentTenantId]);
|
}, [currentTenantId]);
|
||||||
|
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
|
||||||
import React from 'react';
|
|
||||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
|
||||||
import { MemoryRouter, Routes, Route } from 'react-router-dom';
|
|
||||||
import '@testing-library/jest-dom/vitest';
|
|
||||||
import { FinanceModule } from '../../../features/finance/FinanceModule';
|
|
||||||
import { OperationsModule } from '../../../features/operations/OperationsModule';
|
|
||||||
import { useSessionStore } from '../../../core/session/sessionStore';
|
|
||||||
import { useDemoStore } from '../../../stores/demo';
|
|
||||||
|
|
||||||
describe('Route Tabs & Deep Links Synchronization (F17)', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
localStorage.clear();
|
|
||||||
act(() => {
|
|
||||||
useSessionStore.getState().resetSession();
|
|
||||||
useDemoStore.getState().resetDemoControls();
|
|
||||||
useSessionStore.getState().setCurrentRoleId('owner');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('FinanceModule Deep Links', () => {
|
|
||||||
it('1. /finance?tab=pl activates P&L report tab directly from URL', () => {
|
|
||||||
render(
|
|
||||||
<MemoryRouter initialEntries={['/finance?tab=pl']}>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/finance" element={<FinanceModule />} />
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify P&L report content is rendered
|
|
||||||
expect(screen.getByText('Отчет о прибылях и убытках (P&L)')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('2. Валовая прибыль (Gross Profit)')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('3. Чистая прибыль (Net Profit)')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('2. /finance?tab=cashflow activates Accounts & Cashflow tab directly from URL', () => {
|
|
||||||
render(
|
|
||||||
<MemoryRouter initialEntries={['/finance?tab=cashflow']}>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/finance" element={<FinanceModule />} />
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify Accounts table is rendered
|
|
||||||
expect(screen.getByText('Наименование счета')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Тип счета')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Текущий баланс')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('3. /finance without params defaults to Cashflow Transactions (ДДС)', () => {
|
|
||||||
render(
|
|
||||||
<MemoryRouter initialEntries={['/finance']}>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/finance" element={<FinanceModule />} />
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Search input for transactions is rendered
|
|
||||||
expect(screen.getByPlaceholderText('Поиск по описанию, счету...')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Все операции')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('4. Clicking tabs updates activeTab and renders corresponding view', () => {
|
|
||||||
render(
|
|
||||||
<MemoryRouter initialEntries={['/finance']}>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/finance" element={<FinanceModule />} />
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Click on P&L button
|
|
||||||
const pnlButton = screen.getByText('Отчет о прибылях и убытках (P&L)');
|
|
||||||
fireEvent.click(pnlButton);
|
|
||||||
|
|
||||||
expect(screen.getByText('2. Валовая прибыль (Gross Profit)')).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Click on Accounts button
|
|
||||||
const accButton = screen.getByText(/Банковские счета и кассы/);
|
|
||||||
fireEvent.click(accButton);
|
|
||||||
|
|
||||||
expect(screen.getByText('Наименование счета')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('OperationsModule Deep Links', () => {
|
|
||||||
it('5. /operations?tab=pos renders POS terminal view', () => {
|
|
||||||
render(
|
|
||||||
<MemoryRouter initialEntries={['/operations?tab=pos']}>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/operations" element={<OperationsModule />} />
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('Текущий чек')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('В зале')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('6. /operations?tab=orders renders Sales & Orders module', () => {
|
|
||||||
render(
|
|
||||||
<MemoryRouter initialEntries={['/operations?tab=orders']}>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/operations" element={<OperationsModule />} />
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('Коммерция & Продажи: Управление Заказами')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('7. /operations?tab=kds renders Kitchen Display System (KDS)', () => {
|
|
||||||
render(
|
|
||||||
<MemoryRouter initialEntries={['/operations?tab=kds']}>
|
|
||||||
<Routes>
|
|
||||||
<Route path="/operations" element={<OperationsModule />} />
|
|
||||||
</Routes>
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText(/Кухонный монитор заказов & Экран цехов/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { NavLink } from 'react-router-dom';
|
import { NavLink } from 'react-router-dom';
|
||||||
import { useSessionStore } from '../../core/session/sessionStore';
|
import { useDemoStore } from '../../mock/store';
|
||||||
import { useMetadata } from '../../services/hooks/useMetadata';
|
import { useMetadata } from '../../services/hooks/useMetadata';
|
||||||
import { SYSTEM_MODULES } from '../../core/modules/registry';
|
import { SYSTEM_MODULES } from '../../core/modules/registry';
|
||||||
import { SYSTEM_ROLES } from '../../core/permissions/types';
|
import { SYSTEM_ROLES } from '../../core/permissions/types';
|
||||||
|
|
@ -55,13 +55,19 @@ export interface SidebarProps {
|
||||||
export const Sidebar: React.FC<SidebarProps> = ({ isTabletCompact = false }) => {
|
export const Sidebar: React.FC<SidebarProps> = ({ isTabletCompact = false }) => {
|
||||||
const [userCollapsed, setUserCollapsed] = useState<boolean | null>(null);
|
const [userCollapsed, setUserCollapsed] = useState<boolean | null>(null);
|
||||||
const collapsed = userCollapsed !== null ? userCollapsed : isTabletCompact;
|
const collapsed = userCollapsed !== null ? userCollapsed : isTabletCompact;
|
||||||
const { currentRoleId, hasModulePermission } = useSessionStore();
|
const { currentRoleId } = useDemoStore();
|
||||||
const { entities } = useMetadata();
|
const { entities } = useMetadata();
|
||||||
const customEntities = entities.filter((e) => !e.isSystem);
|
const customEntities = entities.filter((e) => !e.isSystem);
|
||||||
const role = SYSTEM_ROLES[currentRoleId];
|
const role = SYSTEM_ROLES[currentRoleId];
|
||||||
|
|
||||||
// Filter modules by role permissions (strict deny-by-default via sessionStore)
|
// Filter modules by role permissions (industry profile does not hide modules)
|
||||||
const visibleModules = SYSTEM_MODULES.filter((m) => hasModulePermission(m.id));
|
const visibleModules = SYSTEM_MODULES.filter((m) => {
|
||||||
|
// Role check
|
||||||
|
if (!role.allowedModules.includes('*') && !role.allowedModules.includes(m.id)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
|
|
|
||||||
|
|
@ -1,234 +0,0 @@
|
||||||
import { create } from 'zustand';
|
|
||||||
import { SystemRoleId, SYSTEM_ROLES } from '../permissions/types';
|
|
||||||
import { CapabilityId } from '../capabilities/types';
|
|
||||||
import { BUSINESS_PROFILES } from '../profiles/profiles';
|
|
||||||
import { BusinessProfile } from '../profiles/types';
|
|
||||||
import { setActiveGlobalTenantId, getActiveGlobalTenantId } from '../../services/contracts/context';
|
|
||||||
|
|
||||||
export interface SessionState {
|
|
||||||
activeTenantId: string;
|
|
||||||
activeOrgId: string;
|
|
||||||
currentUserId: string;
|
|
||||||
currentRoleId: SystemRoleId;
|
|
||||||
currentProfileId: string;
|
|
||||||
activeLocation: string;
|
|
||||||
capabilities: Record<CapabilityId, boolean>;
|
|
||||||
|
|
||||||
// Actions
|
|
||||||
setActiveTenantId: (tenantId: string) => void;
|
|
||||||
setActiveOrgId: (orgId: string) => void;
|
|
||||||
setCurrentUserId: (userId: string) => void;
|
|
||||||
setCurrentRoleId: (roleId: SystemRoleId) => void;
|
|
||||||
setCurrentProfileId: (profileId: string) => void;
|
|
||||||
setActiveLocation: (location: string) => void;
|
|
||||||
setCapabilities: (capabilities: Record<CapabilityId, boolean>) => void;
|
|
||||||
toggleCapability: (id: CapabilityId) => void;
|
|
||||||
resetCapabilitiesToProfile: () => void;
|
|
||||||
resetSession: () => void;
|
|
||||||
|
|
||||||
// RBAC & Permission helpers (Strict deny-by-default)
|
|
||||||
hasModulePermission: (moduleId: string) => boolean;
|
|
||||||
getActiveProfile: () => BusinessProfile;
|
|
||||||
getActiveCapabilities: () => Record<CapabilityId, boolean>;
|
|
||||||
}
|
|
||||||
|
|
||||||
const DEFAULT_PROFILE_ID = 'restaurant_hall_delivery';
|
|
||||||
const DEFAULT_ROLE_ID: SystemRoleId = 'owner';
|
|
||||||
const DEFAULT_TENANT_ID = 'tenant_main';
|
|
||||||
const DEFAULT_ORG_ID = 'org_main';
|
|
||||||
const DEFAULT_USER_ID = 'user_admin';
|
|
||||||
|
|
||||||
function getInitialProfileId(): string {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const saved = localStorage.getItem('krona_profile_id');
|
|
||||||
if (saved && BUSINESS_PROFILES[saved]) return saved;
|
|
||||||
}
|
|
||||||
return DEFAULT_PROFILE_ID;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInitialRoleId(): SystemRoleId {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const saved = localStorage.getItem('krona_role_id') as SystemRoleId;
|
|
||||||
if (saved && SYSTEM_ROLES[saved]) return saved;
|
|
||||||
}
|
|
||||||
return DEFAULT_ROLE_ID;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInitialTenantId(): string {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const saved = localStorage.getItem('krona_tenant_id') || localStorage.getItem('krona_active_tenant_id');
|
|
||||||
if (saved) return saved;
|
|
||||||
}
|
|
||||||
return getActiveGlobalTenantId() || DEFAULT_TENANT_ID;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInitialOrgId(): string {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const saved = localStorage.getItem('krona_org_id') || localStorage.getItem('krona_active_org_id');
|
|
||||||
if (saved) return saved;
|
|
||||||
}
|
|
||||||
return DEFAULT_ORG_ID;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInitialUserId(): string {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const saved = localStorage.getItem('krona_user_id') || localStorage.getItem('krona_current_user_id');
|
|
||||||
if (saved) return saved;
|
|
||||||
}
|
|
||||||
return DEFAULT_USER_ID;
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInitialLocation(profileId: string): string {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
const saved = localStorage.getItem('krona_location') || localStorage.getItem('krona_active_location');
|
|
||||||
if (saved) return saved;
|
|
||||||
}
|
|
||||||
const prof = BUSINESS_PROFILES[profileId] || BUSINESS_PROFILES[DEFAULT_PROFILE_ID];
|
|
||||||
return prof.locations[0] || 'Горский 1/1';
|
|
||||||
}
|
|
||||||
|
|
||||||
function getInitialCapabilities(profileId: string): Record<CapabilityId, boolean> {
|
|
||||||
const prof = BUSINESS_PROFILES[profileId] || BUSINESS_PROFILES[DEFAULT_PROFILE_ID];
|
|
||||||
return { ...prof.capabilities };
|
|
||||||
}
|
|
||||||
|
|
||||||
export const useSessionStore = create<SessionState>((set, get) => {
|
|
||||||
const initialProfileId = getInitialProfileId();
|
|
||||||
const initialTenantId = getInitialTenantId();
|
|
||||||
|
|
||||||
// Sync global tenant id on startup
|
|
||||||
setActiveGlobalTenantId(initialTenantId);
|
|
||||||
|
|
||||||
return {
|
|
||||||
activeTenantId: initialTenantId,
|
|
||||||
activeOrgId: getInitialOrgId(),
|
|
||||||
currentUserId: getInitialUserId(),
|
|
||||||
currentRoleId: getInitialRoleId(),
|
|
||||||
currentProfileId: initialProfileId,
|
|
||||||
activeLocation: getInitialLocation(initialProfileId),
|
|
||||||
capabilities: getInitialCapabilities(initialProfileId),
|
|
||||||
|
|
||||||
setActiveTenantId: (activeTenantId: string) => {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.setItem('krona_tenant_id', activeTenantId);
|
|
||||||
localStorage.setItem('krona_active_tenant_id', activeTenantId);
|
|
||||||
}
|
|
||||||
setActiveGlobalTenantId(activeTenantId);
|
|
||||||
set({ activeTenantId });
|
|
||||||
},
|
|
||||||
|
|
||||||
setActiveOrgId: (activeOrgId: string) => {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.setItem('krona_org_id', activeOrgId);
|
|
||||||
localStorage.setItem('krona_active_org_id', activeOrgId);
|
|
||||||
}
|
|
||||||
set({ activeOrgId });
|
|
||||||
},
|
|
||||||
|
|
||||||
setCurrentUserId: (currentUserId: string) => {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.setItem('krona_user_id', currentUserId);
|
|
||||||
localStorage.setItem('krona_current_user_id', currentUserId);
|
|
||||||
}
|
|
||||||
set({ currentUserId });
|
|
||||||
},
|
|
||||||
|
|
||||||
setCurrentRoleId: (currentRoleId: SystemRoleId) => {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.setItem('krona_role_id', currentRoleId);
|
|
||||||
}
|
|
||||||
set({ currentRoleId });
|
|
||||||
},
|
|
||||||
|
|
||||||
setCurrentProfileId: (currentProfileId: string) => {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.setItem('krona_profile_id', currentProfileId);
|
|
||||||
}
|
|
||||||
const profile = BUSINESS_PROFILES[currentProfileId] || BUSINESS_PROFILES[DEFAULT_PROFILE_ID];
|
|
||||||
const newLoc = profile.locations[0] || 'Главная точка';
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.setItem('krona_location', newLoc);
|
|
||||||
}
|
|
||||||
set({
|
|
||||||
currentProfileId,
|
|
||||||
capabilities: { ...profile.capabilities },
|
|
||||||
activeLocation: newLoc,
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
setActiveLocation: (activeLocation: string) => {
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.setItem('krona_location', activeLocation);
|
|
||||||
localStorage.setItem('krona_active_location', activeLocation);
|
|
||||||
}
|
|
||||||
set({ activeLocation });
|
|
||||||
},
|
|
||||||
|
|
||||||
setCapabilities: (capabilities: Record<CapabilityId, boolean>) => {
|
|
||||||
set({ capabilities });
|
|
||||||
},
|
|
||||||
|
|
||||||
toggleCapability: (id: CapabilityId) => {
|
|
||||||
set((state) => ({
|
|
||||||
capabilities: {
|
|
||||||
...state.capabilities,
|
|
||||||
[id]: !state.capabilities[id],
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
},
|
|
||||||
|
|
||||||
resetCapabilitiesToProfile: () => {
|
|
||||||
const state = get();
|
|
||||||
const profile = BUSINESS_PROFILES[state.currentProfileId] || BUSINESS_PROFILES[DEFAULT_PROFILE_ID];
|
|
||||||
set({ capabilities: { ...profile.capabilities } });
|
|
||||||
},
|
|
||||||
|
|
||||||
resetSession: () => {
|
|
||||||
const defaultProf = BUSINESS_PROFILES[DEFAULT_PROFILE_ID];
|
|
||||||
setActiveGlobalTenantId(DEFAULT_TENANT_ID);
|
|
||||||
if (typeof window !== 'undefined') {
|
|
||||||
localStorage.removeItem('krona_tenant_id');
|
|
||||||
localStorage.removeItem('krona_active_tenant_id');
|
|
||||||
localStorage.removeItem('krona_org_id');
|
|
||||||
localStorage.removeItem('krona_active_org_id');
|
|
||||||
localStorage.removeItem('krona_user_id');
|
|
||||||
localStorage.removeItem('krona_current_user_id');
|
|
||||||
localStorage.removeItem('krona_role_id');
|
|
||||||
localStorage.removeItem('krona_profile_id');
|
|
||||||
localStorage.removeItem('krona_location');
|
|
||||||
}
|
|
||||||
set({
|
|
||||||
activeTenantId: DEFAULT_TENANT_ID,
|
|
||||||
activeOrgId: DEFAULT_ORG_ID,
|
|
||||||
currentUserId: DEFAULT_USER_ID,
|
|
||||||
currentRoleId: DEFAULT_ROLE_ID,
|
|
||||||
currentProfileId: DEFAULT_PROFILE_ID,
|
|
||||||
activeLocation: defaultProf.locations[0] || 'Горский 1/1',
|
|
||||||
capabilities: { ...defaultProf.capabilities },
|
|
||||||
});
|
|
||||||
},
|
|
||||||
|
|
||||||
// Strict deny-by-default RBAC check
|
|
||||||
hasModulePermission: (moduleId: string) => {
|
|
||||||
const state = get();
|
|
||||||
const role = SYSTEM_ROLES[state.currentRoleId];
|
|
||||||
if (!role) return false;
|
|
||||||
|
|
||||||
// Super roles or full wildcard
|
|
||||||
if (role.id === 'owner' || role.id === 'administrator' || role.allowedModules.includes('*')) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
|
|
||||||
return role.allowedModules.includes(moduleId);
|
|
||||||
},
|
|
||||||
|
|
||||||
getActiveProfile: () => {
|
|
||||||
const state = get();
|
|
||||||
return BUSINESS_PROFILES[state.currentProfileId] || BUSINESS_PROFILES[DEFAULT_PROFILE_ID];
|
|
||||||
},
|
|
||||||
|
|
||||||
getActiveCapabilities: () => {
|
|
||||||
return get().capabilities;
|
|
||||||
},
|
|
||||||
};
|
|
||||||
});
|
|
||||||
|
|
@ -688,7 +688,6 @@ export function generateSeedFinance(payments: Payment[]): {
|
||||||
accountId: 'acc_main_bank',
|
accountId: 'acc_main_bank',
|
||||||
accountName: 'Основной расчетный счет',
|
accountName: 'Основной расчетный счет',
|
||||||
referenceType: 'manual',
|
referenceType: 'manual',
|
||||||
referenceId: `ref_outflow_${idx + 1}`,
|
|
||||||
description: o.name,
|
description: o.name,
|
||||||
timestamp,
|
timestamp,
|
||||||
audit: {
|
audit: {
|
||||||
|
|
|
||||||
|
|
@ -3,16 +3,14 @@ import { BaseEntity, MoneyAmount } from '../types';
|
||||||
export type OrderStatus =
|
export type OrderStatus =
|
||||||
| 'draft'
|
| 'draft'
|
||||||
| 'confirmed'
|
| 'confirmed'
|
||||||
| 'preparing'
|
|
||||||
| 'processing'
|
| 'processing'
|
||||||
| 'ready_for_shipment'
|
|
||||||
| 'ready'
|
| 'ready'
|
||||||
| 'shipped'
|
| 'shipped'
|
||||||
| 'delivered'
|
| 'delivered'
|
||||||
| 'completed'
|
| 'completed'
|
||||||
| 'cancelled';
|
| 'cancelled';
|
||||||
|
|
||||||
export type PaymentStatus = 'unpaid' | 'pending' | 'partially_paid' | 'paid' | 'overpaid' | 'refunded';
|
export type PaymentStatus = 'pending' | 'partially_paid' | 'paid' | 'refunded';
|
||||||
export type FulfillmentStatus = 'unfulfilled' | 'partially_fulfilled' | 'fulfilled' | 'returned';
|
export type FulfillmentStatus = 'unfulfilled' | 'partially_fulfilled' | 'fulfilled' | 'returned';
|
||||||
|
|
||||||
export interface OrderItem {
|
export interface OrderItem {
|
||||||
|
|
@ -45,5 +43,4 @@ export interface Order extends BaseEntity {
|
||||||
channel: 'pos' | 'web' | 'mobile' | 'b2b' | 'manual';
|
channel: 'pos' | 'web' | 'mobile' | 'b2b' | 'manual';
|
||||||
shippingAddress?: string;
|
shippingAddress?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
dealId?: string;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ export interface Contact extends BaseEntity {
|
||||||
firstName: string;
|
firstName: string;
|
||||||
lastName: string;
|
lastName: string;
|
||||||
middleName?: string;
|
middleName?: string;
|
||||||
roles: (ContactRole | string)[];
|
roles: ContactRole[];
|
||||||
companyId?: string; // Reference to Company
|
companyId?: string; // Reference to Company
|
||||||
jobTitle?: string;
|
jobTitle?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
|
|
@ -14,6 +14,5 @@ export interface Contact extends BaseEntity {
|
||||||
telegram?: string;
|
telegram?: string;
|
||||||
avatarUrl?: string;
|
avatarUrl?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
assignedTo?: string;
|
|
||||||
status: 'active' | 'archived' | 'blocked';
|
status: 'active' | 'archived' | 'blocked';
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,18 +2,6 @@ import { BaseEntity, MoneyAmount } from '../types';
|
||||||
|
|
||||||
export type DealStage = 'prospecting' | 'discovery' | 'proposal' | 'negotiation' | 'contract' | 'won' | 'lost';
|
export type DealStage = 'prospecting' | 'discovery' | 'proposal' | 'negotiation' | 'contract' | 'won' | 'lost';
|
||||||
|
|
||||||
export interface DealItem {
|
|
||||||
id: string;
|
|
||||||
productId: string;
|
|
||||||
variantId?: string;
|
|
||||||
sku?: string;
|
|
||||||
name: string;
|
|
||||||
quantity: number;
|
|
||||||
unitPrice: MoneyAmount;
|
|
||||||
discountMinor?: number;
|
|
||||||
totalPrice: MoneyAmount;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface Deal extends BaseEntity {
|
export interface Deal extends BaseEntity {
|
||||||
title: string;
|
title: string;
|
||||||
pipelineId: string;
|
pipelineId: string;
|
||||||
|
|
@ -25,8 +13,5 @@ export interface Deal extends BaseEntity {
|
||||||
contactId?: string;
|
contactId?: string;
|
||||||
companyId?: string;
|
companyId?: string;
|
||||||
assignedUserId?: string;
|
assignedUserId?: string;
|
||||||
assignedTo?: string;
|
|
||||||
lostReason?: string;
|
lostReason?: string;
|
||||||
orderId?: string;
|
|
||||||
items?: DealItem[];
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,12 @@ export interface Lead extends BaseEntity {
|
||||||
title: string;
|
title: string;
|
||||||
contactName: string;
|
contactName: string;
|
||||||
companyName?: string;
|
companyName?: string;
|
||||||
companyId?: string;
|
|
||||||
phone?: string;
|
phone?: string;
|
||||||
email?: string;
|
email?: string;
|
||||||
source: 'website' | 'phone' | 'referral' | 'telegram' | 'ads' | 'pos' | 'other';
|
source: 'website' | 'phone' | 'referral' | 'telegram' | 'ads' | 'pos' | 'other';
|
||||||
status: LeadStatus;
|
status: LeadStatus;
|
||||||
estimatedValue?: MoneyAmount;
|
estimatedValue?: MoneyAmount;
|
||||||
assignedUserId?: string;
|
assignedUserId?: string;
|
||||||
assignedTo?: string;
|
|
||||||
score?: number; // 0-100
|
score?: number; // 0-100
|
||||||
convertedContactId?: string;
|
convertedContactId?: string;
|
||||||
convertedDealId?: string;
|
convertedDealId?: string;
|
||||||
|
|
|
||||||
|
|
@ -1,308 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
|
||||||
import { resetDatabase, putRecord, getRecord, importDatabaseSnapshot } from '../../services/storage/indexedDb';
|
|
||||||
import { getServices } from '../../services/mock';
|
|
||||||
import { InsufficientStockError } from '../../services/contracts/inventory';
|
|
||||||
import { TenantAccessDeniedError } from '../../services/contracts/context';
|
|
||||||
import { useSessionStore } from '../../core/session/sessionStore';
|
|
||||||
import { useDemoStore } from '../../stores/demo';
|
|
||||||
|
|
||||||
describe('Astra 6 Negative Audit Probes & Invariants Matrix (F25)', () => {
|
|
||||||
const { commerce, inventory } = getServices();
|
|
||||||
const WAREHOUSE_ID = 'wh_negative_test';
|
|
||||||
const TENANT_A = 'tenant_probe_a';
|
|
||||||
const TENANT_B = 'tenant_probe_b';
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await resetDatabase();
|
|
||||||
useSessionStore.getState().resetSession();
|
|
||||||
useDemoStore.getState().resetDemoControls();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Probe 1: RBAC & Tenant Access Boundaries (403 Forbidden)', () => {
|
|
||||||
it('1.1. Denies access by default to restricted modules for restricted roles', () => {
|
|
||||||
useSessionStore.getState().setCurrentRoleId('cook');
|
|
||||||
|
|
||||||
expect(useSessionStore.getState().hasModulePermission('finance')).toBe(false);
|
|
||||||
expect(useSessionStore.getState().hasModulePermission('crm')).toBe(false);
|
|
||||||
expect(useSessionStore.getState().hasModulePermission('settings')).toBe(false);
|
|
||||||
expect(useSessionStore.getState().hasModulePermission('operations')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('1.2. Throws TenantAccessDeniedError on cross-tenant record read attempt', async () => {
|
|
||||||
// Seed record in tenant B
|
|
||||||
await putRecord({
|
|
||||||
id: 'secret_deal_b',
|
|
||||||
entityType: 'deal',
|
|
||||||
title: 'Confidential Deal of Tenant B',
|
|
||||||
tenant_id: TENANT_B,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Tenant A attempts to access Tenant B's record
|
|
||||||
await expect(getRecord('secret_deal_b', TENANT_A)).rejects.toThrow(TenantAccessDeniedError);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Probe 2: Stock Ledger & Reservation Invariants (F02, F06)', () => {
|
|
||||||
it('2.1. Atomic rejection with InsufficientStockError when requested qty exceeds available', async () => {
|
|
||||||
const productId = 'prod_matcha';
|
|
||||||
// Seed onHand = 3, available = 3
|
|
||||||
await inventory.adjustStock(WAREHOUSE_ID, productId, 3, 'Initial seed');
|
|
||||||
|
|
||||||
const order = await commerce.orders.create({
|
|
||||||
orderNumber: 'ORD-PROBE-001',
|
|
||||||
customerType: 'contact',
|
|
||||||
customerId: 'c1',
|
|
||||||
customerName: 'Guest',
|
|
||||||
status: 'draft',
|
|
||||||
paymentStatus: 'unpaid',
|
|
||||||
fulfillmentStatus: 'unfulfilled',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 'item_1',
|
|
||||||
productId,
|
|
||||||
sku: 'MATCHA-01',
|
|
||||||
name: 'Matcha Latte',
|
|
||||||
quantity: 5, // Exceeds available 3
|
|
||||||
unitPrice: { minor: 40000, currency: 'RUB' },
|
|
||||||
discountMinor: 0,
|
|
||||||
totalPrice: { minor: 200000, currency: 'RUB' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
subtotal: { minor: 200000, currency: 'RUB' },
|
|
||||||
discountMinor: 0,
|
|
||||||
taxMinor: 0,
|
|
||||||
shippingMinor: 0,
|
|
||||||
total: { minor: 200000, currency: 'RUB' },
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
// Confirmation MUST fail atomically without leaving partial reservations
|
|
||||||
await expect(commerce.confirmOrder(order.id, WAREHOUSE_ID)).rejects.toThrow(InsufficientStockError);
|
|
||||||
|
|
||||||
// Order must remain in draft
|
|
||||||
const reloadedOrder = await commerce.orders.getById(order.id);
|
|
||||||
expect(reloadedOrder?.status).toBe('draft');
|
|
||||||
|
|
||||||
// No active reservations created
|
|
||||||
const reservations = await inventory.reservations.list();
|
|
||||||
const activeRes = reservations.items.filter((r) => r.orderId === order.id && r.status === 'active');
|
|
||||||
expect(activeRes).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('2.2. Idempotent confirmation: duplicate confirmation calls do not double reservations', async () => {
|
|
||||||
const productId = 'prod_croissant';
|
|
||||||
await inventory.adjustStock(WAREHOUSE_ID, productId, 10, 'Initial seed');
|
|
||||||
|
|
||||||
const order = await commerce.orders.create({
|
|
||||||
orderNumber: 'ORD-PROBE-002',
|
|
||||||
customerType: 'contact',
|
|
||||||
customerId: 'c1',
|
|
||||||
customerName: 'Guest',
|
|
||||||
status: 'draft',
|
|
||||||
paymentStatus: 'unpaid',
|
|
||||||
fulfillmentStatus: 'unfulfilled',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 'item_1',
|
|
||||||
productId,
|
|
||||||
sku: 'CRS-01',
|
|
||||||
name: 'Croissant',
|
|
||||||
quantity: 2,
|
|
||||||
unitPrice: { minor: 20000, currency: 'RUB' },
|
|
||||||
discountMinor: 0,
|
|
||||||
totalPrice: { minor: 40000, currency: 'RUB' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
subtotal: { minor: 40000, currency: 'RUB' },
|
|
||||||
discountMinor: 0,
|
|
||||||
taxMinor: 0,
|
|
||||||
shippingMinor: 0,
|
|
||||||
total: { minor: 40000, currency: 'RUB' },
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
// First confirmation
|
|
||||||
const confirmed1 = await commerce.confirmOrder(order.id, WAREHOUSE_ID);
|
|
||||||
expect(confirmed1.status).toBe('confirmed');
|
|
||||||
|
|
||||||
// Second confirmation must be idempotent
|
|
||||||
const confirmed2 = await commerce.confirmOrder(order.id, WAREHOUSE_ID);
|
|
||||||
expect(confirmed2.status).toBe('confirmed');
|
|
||||||
|
|
||||||
// Exactly 1 reservation of quantity 2 must exist
|
|
||||||
const reservations = await inventory.reservations.list();
|
|
||||||
const activeRes = reservations.items.filter((r) => r.orderId === order.id && r.status === 'active');
|
|
||||||
expect(activeRes).toHaveLength(1);
|
|
||||||
expect(activeRes[0].quantity).toBe(2);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Probe 3: Payment Model Strict Invariants (F03, F07)', () => {
|
|
||||||
it('3.1. Rejects zero and negative payment amounts', async () => {
|
|
||||||
const order = await commerce.orders.create({
|
|
||||||
orderNumber: 'ORD-PAY-001',
|
|
||||||
customerType: 'contact',
|
|
||||||
customerId: 'c1',
|
|
||||||
customerName: 'Guest',
|
|
||||||
status: 'confirmed',
|
|
||||||
paymentStatus: 'unpaid',
|
|
||||||
fulfillmentStatus: 'unfulfilled',
|
|
||||||
items: [],
|
|
||||||
subtotal: { minor: 50000, currency: 'RUB' },
|
|
||||||
discountMinor: 0,
|
|
||||||
taxMinor: 0,
|
|
||||||
shippingMinor: 0,
|
|
||||||
total: { minor: 50000, currency: 'RUB' },
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
// Zero amount rejection
|
|
||||||
await expect(
|
|
||||||
commerce.createPayment(order.id, { minor: 0, currency: 'RUB' }, 'cash')
|
|
||||||
).rejects.toThrow(/Сумма платежа должна быть больше нуля/);
|
|
||||||
|
|
||||||
// Negative amount rejection
|
|
||||||
await expect(
|
|
||||||
commerce.createPayment(order.id, { minor: -15000, currency: 'RUB' }, 'cash')
|
|
||||||
).rejects.toThrow(/Сумма платежа должна быть больше нуля/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('3.2. Rejects payment currency mismatch against order currency', async () => {
|
|
||||||
const order = await commerce.orders.create({
|
|
||||||
orderNumber: 'ORD-PAY-002',
|
|
||||||
customerType: 'contact',
|
|
||||||
customerId: 'c1',
|
|
||||||
customerName: 'Guest',
|
|
||||||
status: 'confirmed',
|
|
||||||
paymentStatus: 'unpaid',
|
|
||||||
fulfillmentStatus: 'unfulfilled',
|
|
||||||
items: [],
|
|
||||||
subtotal: { minor: 50000, currency: 'RUB' },
|
|
||||||
discountMinor: 0,
|
|
||||||
taxMinor: 0,
|
|
||||||
shippingMinor: 0,
|
|
||||||
total: { minor: 50000, currency: 'RUB' },
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
commerce.createPayment(order.id, { minor: 50000, currency: 'USD' }, 'cash')
|
|
||||||
).rejects.toThrow(/не совпадает с валютой заказа/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Probe 4: Order State Machine Transitions Matrix (F04, F05)', () => {
|
|
||||||
it('4.1. Prohibits invalid state transition bypassing intermediate stages (draft -> shipped)', async () => {
|
|
||||||
const order = await commerce.orders.create({
|
|
||||||
orderNumber: 'ORD-SM-001',
|
|
||||||
customerType: 'contact',
|
|
||||||
customerId: 'c1',
|
|
||||||
customerName: 'Guest',
|
|
||||||
status: 'draft',
|
|
||||||
paymentStatus: 'unpaid',
|
|
||||||
fulfillmentStatus: 'unfulfilled',
|
|
||||||
items: [],
|
|
||||||
subtotal: { minor: 10000, currency: 'RUB' },
|
|
||||||
discountMinor: 0,
|
|
||||||
taxMinor: 0,
|
|
||||||
shippingMinor: 0,
|
|
||||||
total: { minor: 10000, currency: 'RUB' },
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
await expect(commerce.updateOrderStatus(order.id, 'shipped')).rejects.toThrow(
|
|
||||||
/Недопустимый переход статуса заказа/
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('4.2. Prohibits cancellation of completed orders', async () => {
|
|
||||||
const order = await commerce.orders.create({
|
|
||||||
orderNumber: 'ORD-SM-002',
|
|
||||||
customerType: 'contact',
|
|
||||||
customerId: 'c1',
|
|
||||||
customerName: 'Guest',
|
|
||||||
status: 'completed',
|
|
||||||
paymentStatus: 'paid',
|
|
||||||
fulfillmentStatus: 'fulfilled',
|
|
||||||
items: [],
|
|
||||||
subtotal: { minor: 10000, currency: 'RUB' },
|
|
||||||
discountMinor: 0,
|
|
||||||
taxMinor: 0,
|
|
||||||
shippingMinor: 0,
|
|
||||||
total: { minor: 10000, currency: 'RUB' },
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
await expect(commerce.updateOrderStatus(order.id, 'cancelled')).rejects.toThrow(
|
|
||||||
/Недопустимый переход статуса заказа/
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('4.3. Prohibits returning more units than actually fulfilled', async () => {
|
|
||||||
const productId = 'prod_tea';
|
|
||||||
await inventory.adjustStock(WAREHOUSE_ID, productId, 10, 'Seed');
|
|
||||||
|
|
||||||
const order = await commerce.orders.create({
|
|
||||||
orderNumber: 'ORD-RET-001',
|
|
||||||
customerType: 'contact',
|
|
||||||
customerId: 'c1',
|
|
||||||
customerName: 'Guest',
|
|
||||||
status: 'shipped',
|
|
||||||
paymentStatus: 'paid',
|
|
||||||
fulfillmentStatus: 'fulfilled',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 'item_1',
|
|
||||||
productId,
|
|
||||||
sku: 'TEA-01',
|
|
||||||
name: 'Green Tea',
|
|
||||||
quantity: 2, // Shipped 2 units
|
|
||||||
unitPrice: { minor: 15000, currency: 'RUB' },
|
|
||||||
discountMinor: 0,
|
|
||||||
totalPrice: { minor: 30000, currency: 'RUB' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
subtotal: { minor: 30000, currency: 'RUB' },
|
|
||||||
discountMinor: 0,
|
|
||||||
taxMinor: 0,
|
|
||||||
shippingMinor: 0,
|
|
||||||
total: { minor: 30000, currency: 'RUB' },
|
|
||||||
} as any);
|
|
||||||
|
|
||||||
// Attempt to return 5 units when only 2 were delivered
|
|
||||||
await expect(
|
|
||||||
commerce.processReturn(
|
|
||||||
order.id,
|
|
||||||
'Defective',
|
|
||||||
[
|
|
||||||
{
|
|
||||||
productId,
|
|
||||||
sku: 'TEA-01',
|
|
||||||
name: 'Green Tea',
|
|
||||||
quantity: 5,
|
|
||||||
refundAmount: { minor: 75000, currency: 'RUB' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
WAREHOUSE_ID,
|
|
||||||
false
|
|
||||||
)
|
|
||||||
).rejects.toThrow(/превышает отгруженное/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Probe 5: Malformed JSON Snapshot Import Rejection (F24)', () => {
|
|
||||||
it('5.1. Rejects corrupt or invalid schema snapshots with atomic rollback', async () => {
|
|
||||||
// Seed valid state
|
|
||||||
await putRecord({ id: 'persisted_doc', entityType: 'order', tenant_id: TENANT_A });
|
|
||||||
|
|
||||||
// Attempt import with corrupt structure (stores is not an object)
|
|
||||||
const corruptSnapshot = {
|
|
||||||
version: 1,
|
|
||||||
stores: 'corrupt_string_instead_of_object',
|
|
||||||
} as any;
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
importDatabaseSnapshot(corruptSnapshot, { targetTenantId: TENANT_A })
|
|
||||||
).rejects.toThrow(/отсутствует объект таблиц/);
|
|
||||||
|
|
||||||
// Verify existing record is safely intact
|
|
||||||
const item = await getRecord('persisted_doc', TENANT_A);
|
|
||||||
expect(item).not.toBeNull();
|
|
||||||
expect(item?.id).toBe('persisted_doc');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,216 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
|
||||||
import { resetDatabase } from '../../services/storage/indexedDb';
|
|
||||||
import { getServices } from '../../services/mock';
|
|
||||||
import { OrderItem } from '../../entities/commerce/order';
|
|
||||||
|
|
||||||
describe('CRM End-to-End Business Scenario Integrity Suite (F08)', () => {
|
|
||||||
const { crm, commerce } = getServices();
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await resetDatabase();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('1. Executes full end-to-end flow: Lead -> Conversion -> Contact + Company + Deal -> Won -> Order with specification and two-way link', async () => {
|
|
||||||
// 1. Create a product in commerce
|
|
||||||
const product = await commerce.products.create({
|
|
||||||
sku: 'KRN-BARISTA-01',
|
|
||||||
name: 'Кофемашина Krona Pro Barista Dual',
|
|
||||||
categoryId: 'cat_equipment',
|
|
||||||
price: { minor: 25000000, currency: 'RUB' },
|
|
||||||
costPrice: { minor: 15000000, currency: 'RUB' },
|
|
||||||
unit: 'шт',
|
|
||||||
isService: false,
|
|
||||||
trackInventory: true,
|
|
||||||
status: 'active',
|
|
||||||
});
|
|
||||||
|
|
||||||
// 2. Create an incoming Lead
|
|
||||||
const lead = await crm.leads.create({
|
|
||||||
title: 'Поставка оборудования для кофейни «Арома»',
|
|
||||||
contactName: 'Михаил Соколов',
|
|
||||||
companyName: 'ООО Арома Групп',
|
|
||||||
phone: '+7 (926) 444-55-66',
|
|
||||||
email: 'mikhail@aroma-group.ru',
|
|
||||||
source: 'website',
|
|
||||||
status: 'new',
|
|
||||||
estimatedValue: { minor: 50000000, currency: 'RUB' },
|
|
||||||
assignedTo: 'Менеджер Елена',
|
|
||||||
score: 85,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(lead.status).toBe('new');
|
|
||||||
|
|
||||||
// 3. Convert Lead to Deal
|
|
||||||
const conversionResult = await crm.convertLeadToDeal(
|
|
||||||
lead.id,
|
|
||||||
'Сделка: Оснащение кофейни Арома Групп'
|
|
||||||
);
|
|
||||||
|
|
||||||
const { deal, contact } = conversionResult;
|
|
||||||
expect(deal).toBeDefined();
|
|
||||||
expect(contact).toBeDefined();
|
|
||||||
|
|
||||||
// Verify Contact fields and company linkage
|
|
||||||
expect(contact!.firstName).toBe('Михаил');
|
|
||||||
expect(contact!.lastName).toBe('Соколов');
|
|
||||||
expect(contact!.email).toBe('mikhail@aroma-group.ru');
|
|
||||||
expect(contact!.phone).toBe('+7 (926) 444-55-66');
|
|
||||||
expect(contact!.assignedTo).toBe('Менеджер Елена');
|
|
||||||
expect(contact!.roles).toContain('client');
|
|
||||||
expect(contact!.companyId).toBeDefined();
|
|
||||||
|
|
||||||
// Verify Company was auto-created from companyName
|
|
||||||
const company = await crm.companies.getById(contact!.companyId!);
|
|
||||||
expect(company).toBeDefined();
|
|
||||||
expect(company!.name).toBe('ООО Арома Групп');
|
|
||||||
|
|
||||||
// Verify Deal details
|
|
||||||
expect(deal.title).toBe('Сделка: Оснащение кофейни Арома Групп');
|
|
||||||
expect(deal.stage).toBe('proposal');
|
|
||||||
expect(deal.contactId).toBe(contact!.id);
|
|
||||||
expect(deal.companyId).toBe(company!.id);
|
|
||||||
expect(deal.amount.minor).toBe(50000000);
|
|
||||||
expect(deal.assignedTo).toBe('Менеджер Елена');
|
|
||||||
|
|
||||||
// Verify Lead state is converted with references
|
|
||||||
const updatedLead = await crm.leads.getById(lead.id);
|
|
||||||
expect(updatedLead!.status).toBe('converted');
|
|
||||||
expect(updatedLead!.convertedContactId).toBe(contact!.id);
|
|
||||||
expect(updatedLead!.convertedDealId).toBe(deal.id);
|
|
||||||
|
|
||||||
// 4. Progress Deal through stages to 'won' and attach real specification items
|
|
||||||
await crm.deals.update(deal.id, {
|
|
||||||
stage: 'won',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 'deal_item_1',
|
|
||||||
productId: product.id,
|
|
||||||
sku: product.sku,
|
|
||||||
name: product.name,
|
|
||||||
quantity: 2,
|
|
||||||
unitPrice: { minor: 25000000, currency: 'RUB' },
|
|
||||||
totalPrice: { minor: 50000000, currency: 'RUB' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
});
|
|
||||||
|
|
||||||
const wonDeal = (await crm.deals.getById(deal.id))!;
|
|
||||||
expect(wonDeal.stage).toBe('won');
|
|
||||||
expect(wonDeal.items).toHaveLength(1);
|
|
||||||
|
|
||||||
// 5. Create Order from Won Deal with real specification (no placeholders like 'prd_coffee_beans' or 'COF-100')
|
|
||||||
const orderItems: OrderItem[] = wonDeal.items!.map((it) => ({
|
|
||||||
id: `ord_item_${it.id}`,
|
|
||||||
productId: it.productId,
|
|
||||||
sku: it.sku || 'SKU-UNKNOWN',
|
|
||||||
name: it.name,
|
|
||||||
quantity: it.quantity,
|
|
||||||
unitPrice: it.unitPrice,
|
|
||||||
discountMinor: 0,
|
|
||||||
totalPrice: it.totalPrice,
|
|
||||||
}));
|
|
||||||
|
|
||||||
const order = await commerce.orders.create({
|
|
||||||
orderNumber: 'ORD-CRM-TEST-001',
|
|
||||||
customerType: 'company',
|
|
||||||
customerId: wonDeal.companyId!,
|
|
||||||
customerName: company!.name,
|
|
||||||
status: 'draft',
|
|
||||||
paymentStatus: 'unpaid',
|
|
||||||
fulfillmentStatus: 'unfulfilled',
|
|
||||||
items: orderItems,
|
|
||||||
subtotal: wonDeal.amount,
|
|
||||||
discountMinor: 0,
|
|
||||||
taxMinor: Math.round(wonDeal.amount.minor * 0.2),
|
|
||||||
shippingMinor: 0,
|
|
||||||
total: wonDeal.amount,
|
|
||||||
channel: 'b2b',
|
|
||||||
warehouseId: 'wh_main',
|
|
||||||
dealId: wonDeal.id, // Two-way link: Order -> Deal
|
|
||||||
});
|
|
||||||
|
|
||||||
// Update Deal with Order reference (Two-way link: Deal -> Order)
|
|
||||||
await crm.deals.update(wonDeal.id, { orderId: order.id });
|
|
||||||
|
|
||||||
// 6. Verify Two-Way Link
|
|
||||||
const finalizedOrder = await commerce.orders.getById(order.id);
|
|
||||||
const finalizedDeal = await crm.deals.getById(wonDeal.id);
|
|
||||||
|
|
||||||
expect(finalizedOrder!.dealId).toBe(wonDeal.id);
|
|
||||||
expect(finalizedDeal!.orderId).toBe(order.id);
|
|
||||||
|
|
||||||
// Verify that NO placeholder values were used
|
|
||||||
expect(finalizedOrder!.items[0].productId).toBe(product.id);
|
|
||||||
expect(finalizedOrder!.items[0].productId).not.toBe('prd_coffee_beans');
|
|
||||||
expect(finalizedOrder!.items[0].sku).toBe('KRN-BARISTA-01');
|
|
||||||
expect(finalizedOrder!.items[0].sku).not.toBe('COF-100');
|
|
||||||
expect(finalizedOrder!.items[0].unitPrice.minor).toBe(25000000);
|
|
||||||
|
|
||||||
// 7. Verify Timeline includes linked deal and order events
|
|
||||||
const timeline = await crm.getTimeline('deal', wonDeal.id);
|
|
||||||
expect(timeline.some((t) => t.type === 'order_created')).toBe(true);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('2. Idempotency: Repeating convertLeadToDeal on an already converted lead returns existing deal and contact without duplication', async () => {
|
|
||||||
const lead = await crm.leads.create({
|
|
||||||
title: 'Лид для проверки повторной конвертации',
|
|
||||||
contactName: 'Ольга Николаева',
|
|
||||||
companyName: 'ЗАО Технопром',
|
|
||||||
phone: '+7 (911) 777-88-99',
|
|
||||||
email: 'olga@technoprom.ru',
|
|
||||||
source: 'referral',
|
|
||||||
status: 'new',
|
|
||||||
});
|
|
||||||
|
|
||||||
const firstRun = await crm.convertLeadToDeal(lead.id, 'Первая конвертация');
|
|
||||||
expect(firstRun.deal).toBeDefined();
|
|
||||||
expect(firstRun.contact).toBeDefined();
|
|
||||||
|
|
||||||
const dealsBefore = await crm.deals.list({ pageSize: 100 });
|
|
||||||
const contactsBefore = await crm.contacts.list({ pageSize: 100 });
|
|
||||||
|
|
||||||
// Second call on already converted lead
|
|
||||||
const secondRun = await crm.convertLeadToDeal(lead.id, 'Повторная попытка конвертации');
|
|
||||||
|
|
||||||
expect(secondRun.deal.id).toBe(firstRun.deal.id);
|
|
||||||
expect(secondRun.contact?.id).toBe(firstRun.contact?.id);
|
|
||||||
|
|
||||||
const dealsAfter = await crm.deals.list({ pageSize: 100 });
|
|
||||||
const contactsAfter = await crm.contacts.list({ pageSize: 100 });
|
|
||||||
|
|
||||||
// No duplicate records created!
|
|
||||||
expect(dealsAfter.items.length).toBe(dealsBefore.items.length);
|
|
||||||
expect(contactsAfter.items.length).toBe(contactsBefore.items.length);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('3. Existing Contact Matching: Converts new lead for an existing contact by email or phone without creating duplicate contact', async () => {
|
|
||||||
// Pre-existing contact
|
|
||||||
const existingContact = await crm.contacts.create({
|
|
||||||
firstName: 'Сергей',
|
|
||||||
lastName: 'Васильев',
|
|
||||||
phone: '+7 (905) 123-45-67',
|
|
||||||
email: 'sergey.vasiliev@corp.ru',
|
|
||||||
roles: ['client'],
|
|
||||||
status: 'active',
|
|
||||||
});
|
|
||||||
|
|
||||||
// New lead with matching email and formatted phone
|
|
||||||
const newLead = await crm.leads.create({
|
|
||||||
title: 'Второй запрос от существующего клиента',
|
|
||||||
contactName: 'Сергей Васильев',
|
|
||||||
phone: '+7-905-123-45-67',
|
|
||||||
email: 'SERGEY.VASILIEV@CORP.RU', // case-insensitive check
|
|
||||||
source: 'phone',
|
|
||||||
status: 'new',
|
|
||||||
});
|
|
||||||
|
|
||||||
const conversion = await crm.convertLeadToDeal(newLead.id, 'Вторая сделка с Сергеем');
|
|
||||||
expect(conversion.contact?.id).toBe(existingContact.id);
|
|
||||||
|
|
||||||
const allContacts = await crm.contacts.list({ pageSize: 100 });
|
|
||||||
const matchingContacts = allContacts.items.filter(
|
|
||||||
(c) => c.email?.toLowerCase() === 'sergey.vasiliev@corp.ru'
|
|
||||||
);
|
|
||||||
expect(matchingContacts).toHaveLength(1);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,127 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
|
||||||
import React from 'react';
|
|
||||||
import { render, screen, act } from '@testing-library/react';
|
|
||||||
import { MemoryRouter } from 'react-router-dom';
|
|
||||||
import '@testing-library/jest-dom/vitest';
|
|
||||||
import { RouteGuard } from '../../app/guards/RouteGuard';
|
|
||||||
import { Sidebar } from '../../app/shell/Sidebar';
|
|
||||||
import { useSessionStore } from '../../core/session/sessionStore';
|
|
||||||
import { useDemoStore } from '../../stores/demo';
|
|
||||||
import { createRequestContext } from '../../services/contracts/context';
|
|
||||||
|
|
||||||
describe('RBAC & Session Integrity Suite (F01)', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
localStorage.clear();
|
|
||||||
act(() => {
|
|
||||||
useSessionStore.getState().resetSession();
|
|
||||||
useDemoStore.getState().resetDemoControls();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const renderWithRouter = (ui: React.ReactElement) => {
|
|
||||||
return render(<MemoryRouter>{ui}</MemoryRouter>);
|
|
||||||
};
|
|
||||||
|
|
||||||
it('1. Hidden in navigation: restricted modules are omitted from Sidebar for restricted roles', () => {
|
|
||||||
// Cook role is only permitted to access 'operations'
|
|
||||||
act(() => {
|
|
||||||
useSessionStore.getState().setCurrentRoleId('cook');
|
|
||||||
});
|
|
||||||
|
|
||||||
renderWithRouter(<Sidebar />);
|
|
||||||
|
|
||||||
// 'Операции' should be visible
|
|
||||||
expect(screen.getByText('Операции')).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Restricted modules must NOT be rendered in Sidebar
|
|
||||||
expect(screen.queryByText('Финансы')).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Клиенты и CRM')).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Складской учёт')).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Персонал и HR')).not.toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Маркетинг')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('2. Direct navigation to forbidden URL renders 403 Forbidden with strict deny-by-default', () => {
|
|
||||||
// Cashier role only has ['operations', 'sales', 'crm'], lacking 'finance'
|
|
||||||
act(() => {
|
|
||||||
useSessionStore.getState().setCurrentRoleId('cashier');
|
|
||||||
});
|
|
||||||
|
|
||||||
renderWithRouter(
|
|
||||||
<RouteGuard module="finance" moduleName="Финансы и казначейство">
|
|
||||||
<div data-testid="finance-secret-data">Конфиденциальные финансовые данные</div>
|
|
||||||
</RouteGuard>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Protected content must not be present
|
|
||||||
expect(screen.queryByTestId('finance-secret-data')).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
// 403 Forbidden page is rendered
|
|
||||||
expect(screen.getByTestId('forbidden-page')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/403 Доступ ограничен/i)).toBeInTheDocument();
|
|
||||||
expect(screen.getByText(/Нет прав на просмотр «Финансы и казначейство»/i)).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('3. Immediate reactivity: switching role immediately unblocks/blocks RouteGuard without delay', () => {
|
|
||||||
act(() => {
|
|
||||||
useSessionStore.getState().setCurrentRoleId('cook');
|
|
||||||
});
|
|
||||||
|
|
||||||
const { rerender } = renderWithRouter(
|
|
||||||
<RouteGuard module="crm" moduleName="Клиенты и CRM">
|
|
||||||
<div data-testid="crm-content">CRM База клиентов</div>
|
|
||||||
</RouteGuard>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Cook is blocked
|
|
||||||
expect(screen.queryByTestId('crm-content')).not.toBeInTheDocument();
|
|
||||||
expect(screen.getByTestId('forbidden-page')).toBeInTheDocument();
|
|
||||||
|
|
||||||
// Immediately switch role to 'owner'
|
|
||||||
act(() => {
|
|
||||||
useSessionStore.getState().setCurrentRoleId('owner');
|
|
||||||
});
|
|
||||||
|
|
||||||
rerender(
|
|
||||||
<MemoryRouter>
|
|
||||||
<RouteGuard module="crm" moduleName="Клиенты и CRM">
|
|
||||||
<div data-testid="crm-content">CRM База клиентов</div>
|
|
||||||
</RouteGuard>
|
|
||||||
</MemoryRouter>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Now immediately accessible
|
|
||||||
expect(screen.getByTestId('crm-content')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByTestId('forbidden-page')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('4. Multi-tenant and Organization isolation: switching tenant updates context and isolates session', () => {
|
|
||||||
// Default tenant is 'tenant_main'
|
|
||||||
expect(useSessionStore.getState().activeTenantId).toBe('tenant_main');
|
|
||||||
|
|
||||||
const defaultContext = createRequestContext();
|
|
||||||
expect(defaultContext.tenantId).toBe('tenant_main');
|
|
||||||
|
|
||||||
// Switch tenant to 'tenant_alpha'
|
|
||||||
act(() => {
|
|
||||||
useSessionStore.getState().setActiveTenantId('tenant_alpha');
|
|
||||||
useSessionStore.getState().setActiveOrgId('org_alpha_north');
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(useSessionStore.getState().activeTenantId).toBe('tenant_alpha');
|
|
||||||
expect(useSessionStore.getState().activeOrgId).toBe('org_alpha_north');
|
|
||||||
|
|
||||||
const alphaContext = createRequestContext();
|
|
||||||
expect(alphaContext.tenantId).toBe('tenant_alpha');
|
|
||||||
|
|
||||||
// Switch tenant to 'tenant_beta'
|
|
||||||
act(() => {
|
|
||||||
useSessionStore.getState().setActiveTenantId('tenant_beta');
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(useSessionStore.getState().activeTenantId).toBe('tenant_beta');
|
|
||||||
const betaContext = createRequestContext();
|
|
||||||
expect(betaContext.tenantId).toBe('tenant_beta');
|
|
||||||
expect(betaContext.tenantId).not.toBe(alphaContext.tenantId);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -8,8 +8,6 @@ import { Contact } from '../../entities/crm/contact';
|
||||||
import { Company } from '../../entities/crm/company';
|
import { Company } from '../../entities/crm/company';
|
||||||
import { Deal, DealStage } from '../../entities/crm/deal';
|
import { Deal, DealStage } from '../../entities/crm/deal';
|
||||||
import { Lead } from '../../entities/crm/lead';
|
import { Lead } from '../../entities/crm/lead';
|
||||||
import { OrderItem } from '../../entities/commerce/order';
|
|
||||||
import { MoneyAmount } from '../../entities/types';
|
|
||||||
import { MaturityBadge } from '../../shared/ui/MaturityBadge';
|
import { MaturityBadge } from '../../shared/ui/MaturityBadge';
|
||||||
import { useToast } from '../../shared/ui/useToast';
|
import { useToast } from '../../shared/ui/useToast';
|
||||||
import { CsvImportWizard } from './CsvImportWizard';
|
import { CsvImportWizard } from './CsvImportWizard';
|
||||||
|
|
@ -43,7 +41,7 @@ export const CrmModule: React.FC = () => {
|
||||||
createActivity,
|
createActivity,
|
||||||
} = useCrm();
|
} = useCrm();
|
||||||
|
|
||||||
const { orders, products, createOrder } = useOrders();
|
const { orders, createOrder } = useOrders();
|
||||||
|
|
||||||
const contactMeta = useMetadata('contact');
|
const contactMeta = useMetadata('contact');
|
||||||
const companyMeta = useMetadata('company');
|
const companyMeta = useMetadata('company');
|
||||||
|
|
@ -76,13 +74,15 @@ export const CrmModule: React.FC = () => {
|
||||||
const [selectedContact, setSelectedContact] = useState<Contact | null>(null);
|
const [selectedContact, setSelectedContact] = useState<Contact | null>(null);
|
||||||
const [selectedCompany, setSelectedCompany] = useState<Company | null>(null);
|
const [selectedCompany, setSelectedCompany] = useState<Company | null>(null);
|
||||||
|
|
||||||
// Synchronize active tab with URL path if navigated externally
|
// Sync tab with URL on external route changes
|
||||||
const p = location.pathname;
|
const [prevPath, setPrevPath] = useState(location.pathname);
|
||||||
if (p !== '/crm' && p !== '/crm/') {
|
if (prevPath !== location.pathname) {
|
||||||
if (p.includes('/crm/deals') && activeTab !== 'deals') setActiveTab('deals');
|
setPrevPath(location.pathname);
|
||||||
else if (p.includes('/crm/leads') && activeTab !== 'leads') setActiveTab('leads');
|
const p = location.pathname;
|
||||||
else if (p.includes('/crm/companies') && activeTab !== 'companies') setActiveTab('companies');
|
if (p.includes('/crm/deals')) setActiveTab('deals');
|
||||||
else if (p.includes('/crm/import') && activeTab !== 'import') setActiveTab('import');
|
else if (p.includes('/crm/leads')) setActiveTab('leads');
|
||||||
|
else if (p.includes('/crm/companies')) setActiveTab('companies');
|
||||||
|
else if (p.includes('/crm/import')) setActiveTab('import');
|
||||||
else if (p.includes('/crm/contacts') || p === '/crm') setActiveTab('contacts');
|
else if (p.includes('/crm/contacts') || p === '/crm') setActiveTab('contacts');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -101,95 +101,40 @@ export const CrmModule: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
// Confirm Deal Won -> Order creation
|
// Confirm Deal Won -> Order creation
|
||||||
const handleConfirmOrderCreation = async ({
|
const handleConfirmOrderCreation = async ({ orderTitle, orderWarehouse }: { orderTitle: string; orderWarehouse: string }) => {
|
||||||
orderTitle,
|
|
||||||
orderWarehouse,
|
|
||||||
items: customItems,
|
|
||||||
}: {
|
|
||||||
orderTitle: string;
|
|
||||||
orderWarehouse: string;
|
|
||||||
items?: OrderItem[];
|
|
||||||
}) => {
|
|
||||||
if (!wonDeal) return;
|
if (!wonDeal) return;
|
||||||
try {
|
try {
|
||||||
const customer = contacts.find((c) => c.id === wonDeal.contactId);
|
const customer = contacts.find((c) => c.id === wonDeal.contactId);
|
||||||
const company = companies.find((c) => c.id === wonDeal.companyId);
|
const company = companies.find((c) => c.id === wonDeal.companyId);
|
||||||
const customerName = customer
|
const customerName = customer ? `${customer.firstName} ${customer.lastName}` : (company?.name || 'Клиент');
|
||||||
? `${customer.firstName} ${customer.lastName}`.trim()
|
|
||||||
: company?.name || 'Клиент';
|
|
||||||
const orderNumber = `ORD-${ulid().slice(-6)}`;
|
const orderNumber = `ORD-${ulid().slice(-6)}`;
|
||||||
|
|
||||||
let orderItems: OrderItem[];
|
await createOrder({
|
||||||
if (customItems && customItems.length > 0) {
|
|
||||||
orderItems = customItems;
|
|
||||||
} else if (wonDeal.items && wonDeal.items.length > 0) {
|
|
||||||
orderItems = wonDeal.items.map((item) => ({
|
|
||||||
id: item.id || `item_${ulid()}`,
|
|
||||||
productId: item.productId,
|
|
||||||
sku: item.sku || `SKU-${item.productId.slice(-4)}`,
|
|
||||||
name: item.name,
|
|
||||||
quantity: item.quantity,
|
|
||||||
unitPrice: item.unitPrice,
|
|
||||||
discountMinor: item.discountMinor || 0,
|
|
||||||
totalPrice: item.totalPrice || {
|
|
||||||
minor: item.unitPrice.minor * item.quantity,
|
|
||||||
currency: item.unitPrice.currency,
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
} else if (products && products.length > 0) {
|
|
||||||
const prod = products[0];
|
|
||||||
orderItems = [
|
|
||||||
{
|
|
||||||
id: `item_${ulid()}`,
|
|
||||||
productId: prod.id,
|
|
||||||
sku: prod.sku,
|
|
||||||
name: prod.name,
|
|
||||||
quantity: 1,
|
|
||||||
unitPrice: wonDeal.amount,
|
|
||||||
discountMinor: 0,
|
|
||||||
totalPrice: wonDeal.amount,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
} else {
|
|
||||||
orderItems = [
|
|
||||||
{
|
|
||||||
id: `item_${ulid()}`,
|
|
||||||
productId: `deal_prod_${wonDeal.id}`,
|
|
||||||
sku: `SPEC-${wonDeal.id.slice(-6)}`,
|
|
||||||
name: orderTitle || wonDeal.title,
|
|
||||||
quantity: 1,
|
|
||||||
unitPrice: wonDeal.amount,
|
|
||||||
discountMinor: 0,
|
|
||||||
totalPrice: wonDeal.amount,
|
|
||||||
},
|
|
||||||
];
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalMinor = orderItems.reduce((acc, it) => acc + it.totalPrice.minor, 0);
|
|
||||||
const orderTotal: MoneyAmount = { minor: totalMinor, currency: wonDeal.amount.currency || 'RUB' };
|
|
||||||
|
|
||||||
const createdOrder = await createOrder({
|
|
||||||
orderNumber,
|
orderNumber,
|
||||||
customerType: wonDeal.contactId ? 'contact' : 'company',
|
customerType: wonDeal.contactId ? 'contact' : 'company',
|
||||||
customerId: wonDeal.contactId || wonDeal.companyId || (customer?.id ?? company?.id ?? 'cust_crm'),
|
customerId: wonDeal.contactId || wonDeal.companyId || 'cnt_default',
|
||||||
customerName,
|
customerName,
|
||||||
status: 'draft',
|
status: 'draft',
|
||||||
paymentStatus: 'unpaid',
|
paymentStatus: 'pending',
|
||||||
fulfillmentStatus: 'unfulfilled',
|
fulfillmentStatus: 'unfulfilled',
|
||||||
items: orderItems,
|
items: [{
|
||||||
subtotal: orderTotal,
|
id: `item_${ulid()}`,
|
||||||
|
productId: 'prd_coffee_beans',
|
||||||
|
sku: 'COF-100',
|
||||||
|
name: orderTitle || wonDeal.title,
|
||||||
|
quantity: 1,
|
||||||
|
unitPrice: wonDeal.amount,
|
||||||
|
discountMinor: 0,
|
||||||
|
totalPrice: wonDeal.amount,
|
||||||
|
}],
|
||||||
|
subtotal: wonDeal.amount,
|
||||||
discountMinor: 0,
|
discountMinor: 0,
|
||||||
taxMinor: Math.round(orderTotal.minor * 0.2),
|
taxMinor: Math.round(wonDeal.amount.minor * 0.2),
|
||||||
shippingMinor: 0,
|
shippingMinor: 0,
|
||||||
total: orderTotal,
|
total: wonDeal.amount,
|
||||||
warehouseId: orderWarehouse,
|
|
||||||
channel: 'b2b',
|
channel: 'b2b',
|
||||||
dealId: wonDeal.id,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// Two-way link: deal -> order
|
|
||||||
await updateDeal(wonDeal.id, { orderId: createdOrder.id });
|
|
||||||
|
|
||||||
showToast(`Связанный Заказ ${orderNumber} успешно создан в Коммерции! (Склад: ${orderWarehouse})`, 'success');
|
showToast(`Связанный Заказ ${orderNumber} успешно создан в Коммерции! (Склад: ${orderWarehouse})`, 'success');
|
||||||
setIsDealWonModalOpen(false);
|
setIsDealWonModalOpen(false);
|
||||||
setWonDeal(null);
|
setWonDeal(null);
|
||||||
|
|
|
||||||
|
|
@ -101,25 +101,14 @@ export const CrmEntityModals: React.FC<CrmEntityModalsProps> = ({
|
||||||
layout={contactMeta.layouts?.[0] || null}
|
layout={contactMeta.layouts?.[0] || null}
|
||||||
mode="create"
|
mode="create"
|
||||||
onSave={async (values) => {
|
onSave={async (values) => {
|
||||||
const rolesList = Array.isArray(values.roles)
|
|
||||||
? (values.roles as string[])
|
|
||||||
: typeof values.roles === 'string'
|
|
||||||
? values.roles.split(',').map((r) => r.trim()).filter(Boolean)
|
|
||||||
: ['client'];
|
|
||||||
|
|
||||||
await onCreateContact({
|
await onCreateContact({
|
||||||
firstName: String(values.firstName || 'Новый'),
|
firstName: String(values.firstName || 'Новый'),
|
||||||
lastName: String(values.lastName || 'Контакт'),
|
lastName: String(values.lastName || 'Контакт'),
|
||||||
middleName: values.middleName ? String(values.middleName) : undefined,
|
|
||||||
phone: String(values.phone || ''),
|
phone: String(values.phone || ''),
|
||||||
email: String(values.email || ''),
|
email: String(values.email || ''),
|
||||||
jobTitle: String(values.jobTitle || ''),
|
jobTitle: String(values.jobTitle || ''),
|
||||||
companyId: values.companyId ? String(values.companyId) : undefined,
|
roles: ['client'],
|
||||||
roles: rolesList.length > 0 ? (rolesList as Contact['roles']) : ['client'],
|
status: 'active',
|
||||||
telegram: values.telegram ? String(values.telegram) : undefined,
|
|
||||||
notes: values.notes ? String(values.notes) : undefined,
|
|
||||||
assignedTo: values.assignedTo ? String(values.assignedTo) : undefined,
|
|
||||||
status: (values.status as Contact['status']) || 'active',
|
|
||||||
customFields: values,
|
customFields: values,
|
||||||
});
|
});
|
||||||
onCloseContact();
|
onCloseContact();
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import React, { useState } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { Deal } from '../../../entities/crm/deal';
|
import { Deal } from '../../../entities/crm/deal';
|
||||||
import { OrderItem } from '../../../entities/commerce/order';
|
|
||||||
import { Modal } from '../../../shared/ui/Modal';
|
import { Modal } from '../../../shared/ui/Modal';
|
||||||
import { Button } from '../../../shared/ui/Button';
|
import { Button } from '../../../shared/ui/Button';
|
||||||
import { formatCurrencyMinor } from '../../../shared/lib/currency';
|
import { formatCurrencyMinor } from '../../../shared/lib/currency';
|
||||||
|
|
@ -10,11 +9,7 @@ export interface DealWonOrderModalProps {
|
||||||
isOpen: boolean;
|
isOpen: boolean;
|
||||||
wonDeal: Deal | null;
|
wonDeal: Deal | null;
|
||||||
onClose: () => void;
|
onClose: () => void;
|
||||||
onConfirmOrder: (params: {
|
onConfirmOrder: (params: { orderTitle: string; orderWarehouse: string }) => Promise<void> | void;
|
||||||
orderTitle: string;
|
|
||||||
orderWarehouse: string;
|
|
||||||
items?: OrderItem[];
|
|
||||||
}) => Promise<void> | void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export const DealWonOrderModal: React.FC<DealWonOrderModalProps> = ({
|
export const DealWonOrderModal: React.FC<DealWonOrderModalProps> = ({
|
||||||
|
|
@ -47,7 +42,7 @@ export const DealWonOrderModal: React.FC<DealWonOrderModalProps> = ({
|
||||||
<div className="space-y-4 text-xs">
|
<div className="space-y-4 text-xs">
|
||||||
<div className="p-3 bg-emerald-500/10 border border-emerald-500/30 rounded-lg text-emerald-800 dark:text-emerald-300">
|
<div className="p-3 bg-emerald-500/10 border border-emerald-500/30 rounded-lg text-emerald-800 dark:text-emerald-300">
|
||||||
<strong>Поздравляем с успехом!</strong> Сделка «{wonDeal?.title}» переведена в статус «Выиграна».
|
<strong>Поздравляем с успехом!</strong> Сделка «{wonDeal?.title}» переведена в статус «Выиграна».
|
||||||
Система предлагает автоматически сформировать коммерческий Заказ с двусторонней связью.
|
Система предлагает автоматически сформировать коммерческий Заказ.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
|
@ -65,33 +60,13 @@ export const DealWonOrderModal: React.FC<DealWonOrderModalProps> = ({
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block font-semibold text-krona-primary mb-1">
|
<label className="block font-semibold text-krona-primary mb-1">
|
||||||
Сумма заказа:
|
Сумма заказа (RUB):
|
||||||
</label>
|
</label>
|
||||||
<div className="font-bold text-sm text-krona-green py-1">
|
<div className="font-bold text-sm text-krona-green py-1">
|
||||||
{wonDeal ? formatCurrencyMinor(wonDeal.amount.minor) : '0 ₽'}
|
{wonDeal ? formatCurrencyMinor(wonDeal.amount.minor) : '0 ₽'}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{wonDeal?.items && wonDeal.items.length > 0 && (
|
|
||||||
<div>
|
|
||||||
<label className="block font-semibold text-krona-primary mb-1">
|
|
||||||
Спецификация сделки ({wonDeal.items.length} поз.):
|
|
||||||
</label>
|
|
||||||
<div className="border border-krona-border rounded-lg p-2 bg-krona-workspace/50 space-y-1.5 max-h-36 overflow-y-auto">
|
|
||||||
{wonDeal.items.map((item, idx) => (
|
|
||||||
<div key={item.id || idx} className="flex justify-between items-center text-xs">
|
|
||||||
<span className="text-krona-primary">
|
|
||||||
{item.name} <span className="text-krona-secondary">× {item.quantity} шт.</span>
|
|
||||||
</span>
|
|
||||||
<span className="font-semibold text-krona-primary">
|
|
||||||
{formatCurrencyMinor(item.totalPrice?.minor ?? item.unitPrice.minor * item.quantity)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div>
|
<div>
|
||||||
<label className="block font-semibold text-krona-primary mb-1">
|
<label className="block font-semibold text-krona-primary mb-1">
|
||||||
Склад отгрузки:
|
Склад отгрузки:
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { INITIAL_EMPLOYEES, EmployeeEntity } from '../../mock/mockData';
|
import { INITIAL_EMPLOYEES, EmployeeEntity } from '../../mock/mockData';
|
||||||
import { getStoreItem, putStoreItem } from '../../services/storage/indexedDb';
|
|
||||||
import { Badge, Button, MaturityBadge, Modal, useToast } from '../../shared/ui';
|
import { Badge, Button, MaturityBadge, Modal, useToast } from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
Clock,
|
Clock,
|
||||||
|
|
@ -52,35 +51,6 @@ export const EmployeesModule: React.FC = () => {
|
||||||
const [shifts, setShifts] = useState<ShiftPlanItem[]>(INITIAL_SHIFTS);
|
const [shifts, setShifts] = useState<ShiftPlanItem[]>(INITIAL_SHIFTS);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
|
|
||||||
// Load persisted employees and shifts on mount
|
|
||||||
useEffect(() => {
|
|
||||||
async function loadData() {
|
|
||||||
try {
|
|
||||||
const savedEmp = await getStoreItem<{ key: string; employees: EmployeeEntity[] }>('settings', 'krona_persisted_employees');
|
|
||||||
if (savedEmp?.employees && Array.isArray(savedEmp.employees) && savedEmp.employees.length > 0) {
|
|
||||||
setEmployees(savedEmp.employees);
|
|
||||||
}
|
|
||||||
const savedShifts = await getStoreItem<{ key: string; shifts: ShiftPlanItem[] }>('settings', 'krona_persisted_shifts');
|
|
||||||
if (savedShifts?.shifts && Array.isArray(savedShifts.shifts) && savedShifts.shifts.length > 0) {
|
|
||||||
setShifts(savedShifts.shifts);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Keep initial
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loadData();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const persistEmployees = useCallback((updated: EmployeeEntity[]) => {
|
|
||||||
setEmployees(updated);
|
|
||||||
void putStoreItem('settings', { key: 'krona_persisted_employees', employees: updated }).catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const persistShifts = useCallback((updated: ShiftPlanItem[]) => {
|
|
||||||
setShifts(updated);
|
|
||||||
void putStoreItem('settings', { key: 'krona_persisted_shifts', shifts: updated }).catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// New Employee Modal
|
// New Employee Modal
|
||||||
const [isNewEmpModalOpen, setIsNewEmpModalOpen] = useState(false);
|
const [isNewEmpModalOpen, setIsNewEmpModalOpen] = useState(false);
|
||||||
const [newEmpName, setNewEmpName] = useState('');
|
const [newEmpName, setNewEmpName] = useState('');
|
||||||
|
|
@ -113,8 +83,7 @@ export const EmployeesModule: React.FC = () => {
|
||||||
avatarInitials: initials,
|
avatarInitials: initials,
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatedEmployees = [newEmp, ...employees];
|
setEmployees((prev) => [newEmp, ...prev]);
|
||||||
persistEmployees(updatedEmployees);
|
|
||||||
setIsNewEmpModalOpen(false);
|
setIsNewEmpModalOpen(false);
|
||||||
setNewEmpName('');
|
setNewEmpName('');
|
||||||
showToast({
|
showToast({
|
||||||
|
|
@ -130,24 +99,26 @@ export const EmployeesModule: React.FC = () => {
|
||||||
const hasConflict = editingShift.hours > 12;
|
const hasConflict = editingShift.hours > 12;
|
||||||
const conflictReason = hasConflict ? 'Смена превышает 12 ч (ТК РФ)' : undefined;
|
const conflictReason = hasConflict ? 'Смена превышает 12 ч (ТК РФ)' : undefined;
|
||||||
|
|
||||||
const filtered = shifts.filter(
|
setShifts((prev) => {
|
||||||
(s) => !(s.employeeId === editingShift.empId && s.day === editingShift.day)
|
const filtered = prev.filter(
|
||||||
);
|
(s) => !(s.employeeId === editingShift.empId && s.day === editingShift.day)
|
||||||
const updatedShifts = (editingShift.shiftText === 'Выходной' || editingShift.hours === 0)
|
);
|
||||||
? filtered
|
if (editingShift.shiftText === 'Выходной' || editingShift.hours === 0) {
|
||||||
: [
|
return filtered;
|
||||||
...filtered,
|
}
|
||||||
{
|
return [
|
||||||
employeeId: editingShift.empId,
|
...filtered,
|
||||||
day: editingShift.day,
|
{
|
||||||
shiftText: editingShift.shiftText,
|
employeeId: editingShift.empId,
|
||||||
hours: editingShift.hours,
|
day: editingShift.day,
|
||||||
hasConflict,
|
shiftText: editingShift.shiftText,
|
||||||
conflictReason,
|
hours: editingShift.hours,
|
||||||
},
|
hasConflict,
|
||||||
];
|
conflictReason,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
});
|
||||||
|
|
||||||
persistShifts(updatedShifts);
|
|
||||||
setEditingShift(null);
|
setEditingShift(null);
|
||||||
showToast({
|
showToast({
|
||||||
title: 'Смена обновлена',
|
title: 'Смена обновлена',
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,4 @@
|
||||||
import React, { useState, useMemo } from 'react';
|
import React, { useState, useMemo } from 'react';
|
||||||
import { useSearchParams } from 'react-router-dom';
|
|
||||||
import { useFinance } from '../../services/hooks/useFinance';
|
import { useFinance } from '../../services/hooks/useFinance';
|
||||||
import { TransactionCategory } from '../../services/contracts/finance';
|
import { TransactionCategory } from '../../services/contracts/finance';
|
||||||
import { Badge } from '../../shared/ui/Badge';
|
import { Badge } from '../../shared/ui/Badge';
|
||||||
|
|
@ -25,7 +24,6 @@ import {
|
||||||
|
|
||||||
export const FinanceModule: React.FC = () => {
|
export const FinanceModule: React.FC = () => {
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [searchParams, setSearchParams] = useSearchParams();
|
|
||||||
const {
|
const {
|
||||||
accounts,
|
accounts,
|
||||||
transactions,
|
transactions,
|
||||||
|
|
@ -33,19 +31,7 @@ export const FinanceModule: React.FC = () => {
|
||||||
createManualTransaction,
|
createManualTransaction,
|
||||||
} = useFinance();
|
} = useFinance();
|
||||||
|
|
||||||
const rawTab = (searchParams.get('tab') || '').toLowerCase();
|
const [activeTab, setActiveTab] = useState<'transactions' | 'pnl' | 'accounts'>('transactions');
|
||||||
const activeTab: 'transactions' | 'pnl' | 'accounts' =
|
|
||||||
rawTab === 'pl' || rawTab === 'pnl'
|
|
||||||
? 'pnl'
|
|
||||||
: rawTab === 'accounts' || rawTab === 'cashflow'
|
|
||||||
? 'accounts'
|
|
||||||
: 'transactions';
|
|
||||||
|
|
||||||
const setActiveTab = (tab: 'transactions' | 'pnl' | 'accounts') => {
|
|
||||||
const next = new URLSearchParams(searchParams);
|
|
||||||
next.set('tab', tab === 'pnl' ? 'pl' : tab === 'accounts' ? 'cashflow' : 'transactions');
|
|
||||||
setSearchParams(next, { replace: false });
|
|
||||||
};
|
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [filterType, setFilterType] = useState<'all' | 'inflow' | 'outflow'>('all');
|
const [filterType, setFilterType] = useState<'all' | 'inflow' | 'outflow'>('all');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { INITIAL_TICKETS, TicketEntity } from '../../mock/mockData';
|
import { INITIAL_TICKETS, TicketEntity } from '../../mock/mockData';
|
||||||
import { getStoreItem, putStoreItem } from '../../services/storage/indexedDb';
|
|
||||||
import { Badge, Button, MaturityBadge, Modal, useToast } from '../../shared/ui';
|
import { Badge, Button, MaturityBadge, Modal, useToast } from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
|
|
@ -34,27 +33,6 @@ const ASSIGNEES = ['Алина Соколова', 'Артем Васильев',
|
||||||
export const HelpDeskModule: React.FC = () => {
|
export const HelpDeskModule: React.FC = () => {
|
||||||
const { showToast } = useToast();
|
const { showToast } = useToast();
|
||||||
const [tickets, setTickets] = useState<ExtendedTicket[]>(INITIAL_EXTENDED_TICKETS);
|
const [tickets, setTickets] = useState<ExtendedTicket[]>(INITIAL_EXTENDED_TICKETS);
|
||||||
|
|
||||||
// Load persisted tickets on mount
|
|
||||||
useEffect(() => {
|
|
||||||
async function loadTickets() {
|
|
||||||
try {
|
|
||||||
const saved = await getStoreItem<{ key: string; tickets: ExtendedTicket[] }>('settings', 'krona_persisted_tickets');
|
|
||||||
if (saved?.tickets && Array.isArray(saved.tickets) && saved.tickets.length > 0) {
|
|
||||||
setTickets(saved.tickets);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Keep initial
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loadTickets();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const persistTickets = useCallback((updated: ExtendedTicket[]) => {
|
|
||||||
setTickets(updated);
|
|
||||||
void putStoreItem('settings', { key: 'krona_persisted_tickets', tickets: updated }).catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const [selectedTicket, setSelectedTicket] = useState<ExtendedTicket | null>(null);
|
const [selectedTicket, setSelectedTicket] = useState<ExtendedTicket | null>(null);
|
||||||
const [search, setSearch] = useState('');
|
const [search, setSearch] = useState('');
|
||||||
const [filterStatus, setFilterStatus] = useState<string>('all');
|
const [filterStatus, setFilterStatus] = useState<string>('all');
|
||||||
|
|
@ -131,8 +109,9 @@ export const HelpDeskModule: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateTicketStatus = (ticketId: string, nextStatus: ExtendedTicket['status']) => {
|
const handleUpdateTicketStatus = (ticketId: string, nextStatus: ExtendedTicket['status']) => {
|
||||||
const updated = tickets.map((t) => (t.id === ticketId ? { ...t, status: nextStatus } : t));
|
setTickets((prev) =>
|
||||||
persistTickets(updated);
|
prev.map((t) => (t.id === ticketId ? { ...t, status: nextStatus } : t))
|
||||||
|
);
|
||||||
if (selectedTicket && selectedTicket.id === ticketId) {
|
if (selectedTicket && selectedTicket.id === ticketId) {
|
||||||
setSelectedTicket({ ...selectedTicket, status: nextStatus });
|
setSelectedTicket({ ...selectedTicket, status: nextStatus });
|
||||||
}
|
}
|
||||||
|
|
@ -144,8 +123,9 @@ export const HelpDeskModule: React.FC = () => {
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleUpdateAssignee = (ticketId: string, assignee: string) => {
|
const handleUpdateAssignee = (ticketId: string, assignee: string) => {
|
||||||
const updated = tickets.map((t) => (t.id === ticketId ? { ...t, assignee } : t));
|
setTickets((prev) =>
|
||||||
persistTickets(updated);
|
prev.map((t) => (t.id === ticketId ? { ...t, assignee } : t))
|
||||||
|
);
|
||||||
if (selectedTicket && selectedTicket.id === ticketId) {
|
if (selectedTicket && selectedTicket.id === ticketId) {
|
||||||
setSelectedTicket({ ...selectedTicket, assignee });
|
setSelectedTicket({ ...selectedTicket, assignee });
|
||||||
}
|
}
|
||||||
|
|
@ -173,8 +153,7 @@ export const HelpDeskModule: React.FC = () => {
|
||||||
: { publicReplies: [...(selectedTicket.publicReplies || []), newNote] }),
|
: { publicReplies: [...(selectedTicket.publicReplies || []), newNote] }),
|
||||||
};
|
};
|
||||||
|
|
||||||
const updatedTickets = tickets.map((t) => (t.id === selectedTicket.id ? updatedTicket : t));
|
setTickets((prev) => prev.map((t) => (t.id === selectedTicket.id ? updatedTicket : t)));
|
||||||
persistTickets(updatedTickets);
|
|
||||||
setSelectedTicket(updatedTicket);
|
setSelectedTicket(updatedTicket);
|
||||||
setReplyText('');
|
setReplyText('');
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -130,9 +130,9 @@ export const MarketingModule: React.FC = () => {
|
||||||
revenue: 74800,
|
revenue: 74800,
|
||||||
});
|
});
|
||||||
showToast({
|
showToast({
|
||||||
title: 'Имитация рассылки завершена (Демо)',
|
title: 'Рассылка успешно завершена!',
|
||||||
message: '340 сообщений смоделировано. Реальная отправка ожидает интеграции внешнего провайдера.',
|
message: '340 сообщений отправлено в Telegram-бот. Оформлено 34 новых заказа.',
|
||||||
type: 'info',
|
type: 'success',
|
||||||
});
|
});
|
||||||
}, 1200);
|
}, 1200);
|
||||||
};
|
};
|
||||||
|
|
@ -241,15 +241,12 @@ export const MarketingModule: React.FC = () => {
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<Sparkles className="w-4 h-4 text-krona-gold" />
|
<Sparkles className="w-4 h-4 text-krona-gold" />
|
||||||
<h4 className="font-bold text-xs text-krona-primary flex items-center gap-1.5">
|
<h4 className="font-bold text-xs text-krona-primary">
|
||||||
Интерактивная симуляция запуска триггерной рассылки
|
Интерактивная симуляция запуска триггерной рассылки
|
||||||
<span className="text-[10px] font-normal px-1.5 py-0.5 rounded bg-amber-500/10 text-amber-700 dark:text-amber-300 border border-amber-500/20">
|
|
||||||
Демо-модель
|
|
||||||
</span>
|
|
||||||
</h4>
|
</h4>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-[11px] text-krona-secondary mt-0.5">
|
<p className="text-[11px] text-krona-secondary mt-0.5">
|
||||||
Протестируйте отправку рассылки с промокодом по сегменту «Спящие клиенты» (340 получателей в базе). Подключение внешнего провайдера ожидает интеграции бэкенда.
|
Протестируйте отправку рассылки с промокодом по сегменту «Спящие клиенты» (340 получателей в базе)
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
import React, { useState, useEffect, useMemo } from 'react';
|
import React, { useState, useEffect } from 'react';
|
||||||
import {
|
import {
|
||||||
EntityMetadata,
|
EntityMetadata,
|
||||||
FieldDefinition,
|
FieldDefinition,
|
||||||
|
|
@ -57,7 +57,7 @@ export interface DynamicCardRendererProps {
|
||||||
export const DynamicCardRenderer: React.FC<DynamicCardRendererProps> = ({
|
export const DynamicCardRenderer: React.FC<DynamicCardRendererProps> = ({
|
||||||
entity,
|
entity,
|
||||||
record,
|
record,
|
||||||
layout,
|
layout: _layout,
|
||||||
onEdit,
|
onEdit,
|
||||||
onDelete,
|
onDelete,
|
||||||
onClose,
|
onClose,
|
||||||
|
|
@ -67,16 +67,6 @@ export const DynamicCardRenderer: React.FC<DynamicCardRendererProps> = ({
|
||||||
const [auditLogs, setAuditLogs] = useState<AuditLogItem[]>([]);
|
const [auditLogs, setAuditLogs] = useState<AuditLogItem[]>([]);
|
||||||
const [copiedJson, setCopiedJson] = useState(false);
|
const [copiedJson, setCopiedJson] = useState(false);
|
||||||
|
|
||||||
// Sorted and enabled card blocks from layout config
|
|
||||||
const cardBlocks = useMemo(() => {
|
|
||||||
if (layout?.cardBlocks && layout.cardBlocks.length > 0) {
|
|
||||||
return [...layout.cardBlocks]
|
|
||||||
.filter((b) => b.enabled !== false)
|
|
||||||
.sort((a, b) => a.order - b.order);
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}, [layout]);
|
|
||||||
|
|
||||||
// Fetch audit logs for this specific record
|
// Fetch audit logs for this specific record
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function loadAudit() {
|
async function loadAudit() {
|
||||||
|
|
@ -445,177 +435,27 @@ export const DynamicCardRenderer: React.FC<DynamicCardRendererProps> = ({
|
||||||
{/* Tab: Overview */}
|
{/* Tab: Overview */}
|
||||||
{activeTab === 'overview' && (
|
{activeTab === 'overview' && (
|
||||||
<div className="space-y-4">
|
<div className="space-y-4">
|
||||||
{cardBlocks.length > 0 ? (
|
<div className="p-4 bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft space-y-3">
|
||||||
cardBlocks.map((block) => {
|
<h3 className="text-xs font-bold text-krona-primary uppercase tracking-wider">
|
||||||
if (block.type === 'header') {
|
Все атрибуты записи
|
||||||
return (
|
</h3>
|
||||||
<div
|
|
||||||
key={block.id}
|
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 text-xs">
|
||||||
data-testid={`card-block-${block.type}`}
|
{entity.fields.map((f) => (
|
||||||
className="p-4 bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft flex items-center justify-between"
|
<div
|
||||||
>
|
key={f.key}
|
||||||
<div className="flex items-center gap-3">
|
className="p-3 bg-krona-workspace/60 rounded-lg border border-krona-border/60 flex flex-col justify-between"
|
||||||
{block.settings?.showAvatar !== false && (
|
>
|
||||||
<div className="w-10 h-10 rounded-lg bg-krona-green/10 text-krona-green flex items-center justify-center font-bold text-sm border border-krona-green/20">
|
<span className="text-[11px] text-krona-secondary block mb-1">
|
||||||
{recordTitle.slice(0, 1).toUpperCase()}
|
{f.label}
|
||||||
</div>
|
</span>
|
||||||
)}
|
<div className="font-semibold text-krona-primary break-words">
|
||||||
<div>
|
{renderFieldValue(f)}
|
||||||
<h2 className="text-base font-bold text-krona-primary">{recordTitle}</h2>
|
|
||||||
<p className="text-xs font-mono text-krona-secondary">ID: {record.id}</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
}
|
))}
|
||||||
|
|
||||||
if (block.type === 'badges') {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={block.id}
|
|
||||||
data-testid={`card-block-${block.type}`}
|
|
||||||
className="p-3 bg-krona-surface border border-krona-border rounded-xl flex items-center gap-2"
|
|
||||||
>
|
|
||||||
<Badge variant="neutral" size="sm">
|
|
||||||
<code>{entity.code}</code>
|
|
||||||
</Badge>
|
|
||||||
{record.status !== undefined && record.status !== null && (
|
|
||||||
<Badge variant="green" size="sm">
|
|
||||||
{String(record.status)}
|
|
||||||
</Badge>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (block.type === 'fields_grid') {
|
|
||||||
const specifiedKeys = block.settings?.fieldKeys;
|
|
||||||
const targetFields = entity.fields
|
|
||||||
.filter((f) => !f.hidden)
|
|
||||||
.filter(
|
|
||||||
(f) =>
|
|
||||||
!specifiedKeys || specifiedKeys.length === 0 || specifiedKeys.includes(f.key)
|
|
||||||
);
|
|
||||||
|
|
||||||
const colsCount = block.settings?.columnsCount || 2;
|
|
||||||
const gridClass =
|
|
||||||
colsCount === 1
|
|
||||||
? 'grid-cols-1'
|
|
||||||
: colsCount === 3
|
|
||||||
? 'grid-cols-1 sm:grid-cols-3'
|
|
||||||
: 'grid-cols-1 sm:grid-cols-2';
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={block.id}
|
|
||||||
data-testid={`card-block-${block.type}`}
|
|
||||||
className="p-4 bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft space-y-3"
|
|
||||||
>
|
|
||||||
<h3 className="text-xs font-bold text-krona-primary uppercase tracking-wider">
|
|
||||||
{block.title || 'Атрибуты'}
|
|
||||||
</h3>
|
|
||||||
<div className={`grid ${gridClass} gap-3 text-xs`}>
|
|
||||||
{targetFields.map((f) => (
|
|
||||||
<div
|
|
||||||
key={f.key}
|
|
||||||
data-testid={`card-field-${f.key}`}
|
|
||||||
className="p-3 bg-krona-workspace/60 rounded-lg border border-krona-border/60 flex flex-col justify-between"
|
|
||||||
>
|
|
||||||
<span className="text-[11px] text-krona-secondary block mb-1">
|
|
||||||
{f.label}
|
|
||||||
</span>
|
|
||||||
<div className="font-semibold text-krona-primary break-words">
|
|
||||||
{renderFieldValue(f)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (block.type === 'timeline') {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={block.id}
|
|
||||||
data-testid={`card-block-${block.type}`}
|
|
||||||
className="p-4 bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft space-y-3"
|
|
||||||
>
|
|
||||||
<h3 className="text-xs font-bold text-krona-primary uppercase tracking-wider">
|
|
||||||
{block.title || 'Лента активности'}
|
|
||||||
</h3>
|
|
||||||
<div className="space-y-2">
|
|
||||||
{auditLogs.slice(0, block.settings?.maxItems || 5).map((log) => (
|
|
||||||
<div
|
|
||||||
key={log.id}
|
|
||||||
className="p-2.5 bg-krona-workspace rounded-lg text-xs flex justify-between items-center"
|
|
||||||
>
|
|
||||||
<div>
|
|
||||||
<span className="font-semibold text-krona-primary">{log.summary}</span>
|
|
||||||
<span className="text-[10px] text-krona-secondary block">
|
|
||||||
{log.action} • {log.userName || log.userId}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
<span className="text-[10px] text-krona-secondary font-mono">
|
|
||||||
{formatDateTime(log.timestamp)}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
{auditLogs.length === 0 && (
|
|
||||||
<div className="text-xs text-krona-secondary py-2">
|
|
||||||
Записей активности нет.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (block.type === 'tasks_notes') {
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={block.id}
|
|
||||||
data-testid={`card-block-${block.type}`}
|
|
||||||
className="p-4 bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft space-y-3"
|
|
||||||
>
|
|
||||||
<h3 className="text-xs font-bold text-krona-primary uppercase tracking-wider">
|
|
||||||
{block.title || 'Заметки и задачи'}
|
|
||||||
</h3>
|
|
||||||
<div className="p-3 bg-krona-workspace/60 rounded-lg text-xs text-krona-secondary">
|
|
||||||
Связанные задачи отсутствуют
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return null;
|
|
||||||
})
|
|
||||||
) : (
|
|
||||||
<div className="p-4 bg-krona-surface border border-krona-border rounded-xl shadow-krona-soft space-y-3">
|
|
||||||
<h3 className="text-xs font-bold text-krona-primary uppercase tracking-wider">
|
|
||||||
Все атрибуты записи
|
|
||||||
</h3>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-3 text-xs">
|
|
||||||
{entity.fields
|
|
||||||
.filter((f) => !f.hidden)
|
|
||||||
.map((f) => (
|
|
||||||
<div
|
|
||||||
key={f.key}
|
|
||||||
data-testid={`card-field-${f.key}`}
|
|
||||||
className="p-3 bg-krona-workspace/60 rounded-lg border border-krona-border/60 flex flex-col justify-between"
|
|
||||||
>
|
|
||||||
<span className="text-[11px] text-krona-secondary block mb-1">
|
|
||||||
{f.label}
|
|
||||||
</span>
|
|
||||||
<div className="font-semibold text-krona-primary break-words">
|
|
||||||
{renderFieldValue(f)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
|
||||||
{/* System Audit Information */}
|
{/* System Audit Information */}
|
||||||
<div className="p-3.5 bg-krona-surface border border-krona-border rounded-xl text-xs text-krona-secondary flex flex-wrap gap-4">
|
<div className="p-3.5 bg-krona-surface border border-krona-border rounded-xl text-xs text-krona-secondary flex flex-wrap gap-4">
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ import {
|
||||||
} from '../../shared/lib/fieldEngine';
|
} from '../../shared/lib/fieldEngine';
|
||||||
import { ulid } from '../../shared/lib/ulid';
|
import { ulid } from '../../shared/lib/ulid';
|
||||||
import { useToast } from '../../shared/ui/useToast';
|
import { useToast } from '../../shared/ui/useToast';
|
||||||
import { useSessionStore } from '../../core/session/sessionStore';
|
|
||||||
import {
|
import {
|
||||||
AlertCircle,
|
AlertCircle,
|
||||||
Star,
|
Star,
|
||||||
|
|
@ -152,74 +151,25 @@ export const DynamicFormRenderer: React.FC<DynamicFormRendererProps> = ({
|
||||||
];
|
];
|
||||||
}, [layout, entity]);
|
}, [layout, entity]);
|
||||||
|
|
||||||
const currentRoleId = useSessionStore((s) => s.currentRoleId);
|
// Check conditional visibility
|
||||||
|
const isFieldVisible = useCallback((field: FieldDefinition): boolean => {
|
||||||
|
if (field.hidden) return false;
|
||||||
|
if (!field.conditionalVisibility || !field.conditionalVisibility.fieldKey) return true;
|
||||||
|
|
||||||
// Check conditional visibility and role permissions
|
const { fieldKey, operator, value } = field.conditionalVisibility;
|
||||||
const isFieldVisible = useCallback(
|
const currentVal = formValues[fieldKey];
|
||||||
(field: FieldDefinition): boolean => {
|
|
||||||
if (field.hidden) return false;
|
|
||||||
|
|
||||||
// Role permission / visibility check
|
switch (operator) {
|
||||||
if (currentRoleId !== 'owner') {
|
case 'eq':
|
||||||
const customRoleVisibility = (field as unknown as { roleVisibility?: string[] }).roleVisibility;
|
return String(currentVal ?? '') === String(value ?? '');
|
||||||
const viewRoles = field.permissions?.viewRoles || customRoleVisibility;
|
case 'neq':
|
||||||
if (viewRoles && viewRoles.length > 0 && !viewRoles.includes(currentRoleId)) {
|
return String(currentVal ?? '') !== String(value ?? '');
|
||||||
return false;
|
case 'contains':
|
||||||
}
|
return String(currentVal ?? '').includes(String(value ?? ''));
|
||||||
}
|
default:
|
||||||
|
return true;
|
||||||
// Check conditionalRules array or single conditionalVisibility
|
}
|
||||||
const customRules = (field as unknown as { conditionalRules?: typeof field.conditionalVisibility[] }).conditionalRules;
|
}, [formValues]);
|
||||||
const rules = (customRules && customRules.length > 0)
|
|
||||||
? customRules
|
|
||||||
: (field.conditionalVisibility ? [field.conditionalVisibility] : []);
|
|
||||||
|
|
||||||
for (const rule of rules) {
|
|
||||||
if (!rule || !rule.fieldKey) continue;
|
|
||||||
const { fieldKey, operator, value } = rule;
|
|
||||||
const currentVal = formValues[fieldKey];
|
|
||||||
|
|
||||||
switch (operator) {
|
|
||||||
case 'eq':
|
|
||||||
if (String(currentVal ?? '') !== String(value ?? '')) return false;
|
|
||||||
break;
|
|
||||||
case 'neq':
|
|
||||||
if (String(currentVal ?? '') === String(value ?? '')) return false;
|
|
||||||
break;
|
|
||||||
case 'empty':
|
|
||||||
if (
|
|
||||||
currentVal !== undefined &&
|
|
||||||
currentVal !== null &&
|
|
||||||
currentVal !== '' &&
|
|
||||||
(!Array.isArray(currentVal) || currentVal.length > 0)
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'not_empty':
|
|
||||||
if (
|
|
||||||
currentVal === undefined ||
|
|
||||||
currentVal === null ||
|
|
||||||
currentVal === '' ||
|
|
||||||
(Array.isArray(currentVal) && currentVal.length === 0)
|
|
||||||
) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
case 'contains':
|
|
||||||
if (!String(currentVal ?? '').toLowerCase().includes(String(value ?? '').toLowerCase())) {
|
|
||||||
return false;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
default:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return true;
|
|
||||||
},
|
|
||||||
[formValues, currentRoleId]
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleFieldChange = (key: string, val: unknown) => {
|
const handleFieldChange = (key: string, val: unknown) => {
|
||||||
setFormValues((prev) => ({ ...prev, [key]: val }));
|
setFormValues((prev) => ({ ...prev, [key]: val }));
|
||||||
|
|
|
||||||
|
|
@ -88,40 +88,6 @@ export const DynamicTable: React.FC<DynamicTableProps> = ({
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// View saved filters
|
|
||||||
if (view?.filters && view.filters.length > 0) {
|
|
||||||
const conjunction = view.filterConjunction || 'AND';
|
|
||||||
result = result.filter((row) => {
|
|
||||||
const matches = view.filters.map((filter) => {
|
|
||||||
const val = row[filter.field];
|
|
||||||
switch (filter.operator) {
|
|
||||||
case 'eq':
|
|
||||||
return String(val ?? '') === String(filter.value ?? '');
|
|
||||||
case 'neq':
|
|
||||||
return String(val ?? '') !== String(filter.value ?? '');
|
|
||||||
case 'contains':
|
|
||||||
return String(val ?? '').toLowerCase().includes(String(filter.value ?? '').toLowerCase());
|
|
||||||
case 'gt':
|
|
||||||
return Number(val) > Number(filter.value);
|
|
||||||
case 'lt':
|
|
||||||
return Number(val) < Number(filter.value);
|
|
||||||
case 'in':
|
|
||||||
return Array.isArray(filter.value) && filter.value.includes(val);
|
|
||||||
case 'is_empty':
|
|
||||||
return val === undefined || val === null || val === '';
|
|
||||||
case 'is_not_empty':
|
|
||||||
return val !== undefined && val !== null && val !== '';
|
|
||||||
default:
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
return conjunction === 'OR'
|
|
||||||
? matches.some(Boolean)
|
|
||||||
: matches.every(Boolean);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Sort
|
// Sort
|
||||||
if (sortField) {
|
if (sortField) {
|
||||||
result.sort((a, b) => {
|
result.sort((a, b) => {
|
||||||
|
|
@ -136,7 +102,7 @@ export const DynamicTable: React.FC<DynamicTableProps> = ({
|
||||||
}
|
}
|
||||||
|
|
||||||
return result;
|
return result;
|
||||||
}, [records, searchQuery, sortField, sortOrder, view]);
|
}, [records, searchQuery, sortField, sortOrder]);
|
||||||
|
|
||||||
// Paginated records
|
// Paginated records
|
||||||
const totalPages = Math.ceil(filteredRecords.length / pageSize) || 1;
|
const totalPages = Math.ceil(filteredRecords.length / pageSize) || 1;
|
||||||
|
|
@ -271,16 +237,10 @@ export const DynamicTable: React.FC<DynamicTableProps> = ({
|
||||||
<tr>
|
<tr>
|
||||||
{columns.map((col) => {
|
{columns.map((col) => {
|
||||||
const isSorted = sortField === col.key;
|
const isSorted = sortField === col.key;
|
||||||
const customWidth = view?.columnWidths?.[col.key];
|
|
||||||
const colStyle = customWidth
|
|
||||||
? { width: `${customWidth}px`, minWidth: `${customWidth}px` }
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<th
|
<th
|
||||||
key={col.key}
|
key={col.key}
|
||||||
onClick={() => handleHeaderClick(col.key)}
|
onClick={() => handleHeaderClick(col.key)}
|
||||||
style={colStyle}
|
|
||||||
className="p-3 font-semibold text-krona-primary hover:bg-krona-surface-muted/60 cursor-pointer transition-colors whitespace-nowrap"
|
className="p-3 font-semibold text-krona-primary hover:bg-krona-surface-muted/60 cursor-pointer transition-colors whitespace-nowrap"
|
||||||
>
|
>
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
|
|
@ -312,13 +272,9 @@ export const DynamicTable: React.FC<DynamicTableProps> = ({
|
||||||
{columns.map((col) => {
|
{columns.map((col) => {
|
||||||
const val = row[col.key];
|
const val = row[col.key];
|
||||||
const display = formatFieldValue(col.type, val, col);
|
const display = formatFieldValue(col.type, val, col);
|
||||||
const customWidth = view?.columnWidths?.[col.key];
|
|
||||||
const colStyle = customWidth
|
|
||||||
? { width: `${customWidth}px`, minWidth: `${customWidth}px` }
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<td key={col.key} style={colStyle} className="p-3 text-krona-secondary truncate max-w-xs">
|
<td key={col.key} className="p-3 text-krona-secondary truncate max-w-xs">
|
||||||
{col.type === 'color' && val ? (
|
{col.type === 'color' && val ? (
|
||||||
<div className="flex items-center gap-1.5">
|
<div className="flex items-center gap-1.5">
|
||||||
<span
|
<span
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
import { useLocation, useNavigate } from 'react-router-dom';
|
||||||
import { useDemoStore } from '../../mock/store';
|
import { useDemoStore } from '../../mock/store';
|
||||||
import { PosView } from './PosView';
|
import { PosView } from './PosView';
|
||||||
import { KdsView } from './KdsView';
|
import { KdsView } from './KdsView';
|
||||||
|
|
@ -20,49 +20,47 @@ import { formatCurrency } from '../../shared/utils/formatters';
|
||||||
export const OperationsModule: React.FC = () => {
|
export const OperationsModule: React.FC = () => {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const [searchParams] = useSearchParams();
|
|
||||||
const { getActiveProfile, getActiveCapabilities, setIsQuickActionsOpen, activeLocation } = useDemoStore();
|
const { getActiveProfile, getActiveCapabilities, setIsQuickActionsOpen, activeLocation } = useDemoStore();
|
||||||
const profile = getActiveProfile();
|
const profile = getActiveProfile();
|
||||||
const capabilities = getActiveCapabilities();
|
const capabilities = getActiveCapabilities();
|
||||||
|
|
||||||
const currentPath = location.pathname;
|
const currentPath = location.pathname;
|
||||||
const tabParam = (searchParams.get('tab') || '').toLowerCase();
|
|
||||||
|
|
||||||
// Sub-routes dispatcher (supports both direct path and deep link query params)
|
// Sub-routes dispatcher
|
||||||
if (currentPath === '/operations/pos' || tabParam === 'pos') {
|
if (currentPath === '/operations/pos') {
|
||||||
return <PosView />;
|
return <PosView />;
|
||||||
}
|
}
|
||||||
if (currentPath === '/operations/kds' || tabParam === 'kds') {
|
if (currentPath === '/operations/kds') {
|
||||||
return <KdsView />;
|
return <KdsView />;
|
||||||
}
|
}
|
||||||
if (currentPath === '/operations/dining' || tabParam === 'dining') {
|
if (currentPath === '/operations/dining') {
|
||||||
return <DiningView />;
|
return <DiningView />;
|
||||||
}
|
}
|
||||||
if (currentPath === '/operations/work-orders' || tabParam === 'work-orders') {
|
if (currentPath === '/operations/work-orders') {
|
||||||
return <WorkOrdersView />;
|
return <WorkOrdersView />;
|
||||||
}
|
}
|
||||||
if (currentPath === '/operations/booking' || tabParam === 'booking') {
|
if (currentPath === '/operations/booking') {
|
||||||
return <BookingCalendarView />;
|
return <BookingCalendarView />;
|
||||||
}
|
}
|
||||||
if (currentPath === '/operations/delivery' || tabParam === 'delivery') {
|
if (currentPath === '/operations/delivery') {
|
||||||
return <DeliveryView />;
|
return <DeliveryView />;
|
||||||
}
|
}
|
||||||
if (currentPath === '/operations/courier' || tabParam === 'courier') {
|
if (currentPath === '/operations/courier') {
|
||||||
return <DeliveryView initialMobile={true} />;
|
return <DeliveryView initialMobile={true} />;
|
||||||
}
|
}
|
||||||
if (currentPath === '/operations/recipes' || tabParam === 'recipes') {
|
if (currentPath === '/operations/recipes') {
|
||||||
return <RecipesView />;
|
return <RecipesView />;
|
||||||
}
|
}
|
||||||
if (currentPath === '/operations/inventory' || tabParam === 'inventory') {
|
if (currentPath === '/operations/inventory') {
|
||||||
return <InventoryModule />;
|
return <InventoryModule />;
|
||||||
}
|
}
|
||||||
if (currentPath === '/operations/orders' || tabParam === 'orders') {
|
if (currentPath === '/operations/orders') {
|
||||||
return <SalesModule />;
|
return <SalesModule />;
|
||||||
}
|
}
|
||||||
if (currentPath === '/operations/purchasing' || tabParam === 'purchasing') {
|
if (currentPath === '/operations/purchasing') {
|
||||||
return <PurchasingView />;
|
return <PurchasingView />;
|
||||||
}
|
}
|
||||||
if (currentPath === '/operations/bays' || tabParam === 'bays') {
|
if (currentPath === '/operations/bays') {
|
||||||
return <BaysView />;
|
return <BaysView />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,284 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
|
||||||
import React from 'react';
|
|
||||||
import { render, screen } from '@testing-library/react';
|
|
||||||
import { DynamicFormRenderer } from '../../../metadata/DynamicFormRenderer';
|
|
||||||
import { DynamicCardRenderer } from '../../../metadata/DynamicCardRenderer';
|
|
||||||
import { DynamicTable } from '../../../metadata/DynamicTable';
|
|
||||||
import { useFormBuilderHistory } from '../useFormBuilderHistory';
|
|
||||||
import { useSessionStore } from '../../../../core/session/sessionStore';
|
|
||||||
import { EntityMetadata } from '../../../../entities/types';
|
|
||||||
import { LayoutDefinition, ViewDefinition } from '../../../../services/contracts/metadata';
|
|
||||||
import { renderHook, act } from '@testing-library/react';
|
|
||||||
|
|
||||||
describe('Builders Runtime Execution Test Suite (F12, F13, F14)', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
useSessionStore.getState().setCurrentRoleId('owner');
|
|
||||||
});
|
|
||||||
|
|
||||||
const mockEntity: EntityMetadata = {
|
|
||||||
id: 'ent_client_contract',
|
|
||||||
code: 'client_contract',
|
|
||||||
name: 'Договор с клиентом',
|
|
||||||
pluralName: 'Договоры с клиентами',
|
|
||||||
isSystem: false,
|
|
||||||
category: 'custom',
|
|
||||||
maturity: 'target_prototype',
|
|
||||||
fields: [
|
|
||||||
{
|
|
||||||
id: 'f_number',
|
|
||||||
key: 'contractNumber',
|
|
||||||
label: 'Номер договора',
|
|
||||||
type: 'text',
|
|
||||||
required: true,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'f_type',
|
|
||||||
key: 'contractType',
|
|
||||||
label: 'Тип договора',
|
|
||||||
type: 'select',
|
|
||||||
options: [
|
|
||||||
{ value: 'standard', label: 'Стандартный' },
|
|
||||||
{ value: 'nda', label: 'NDA' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'f_secret_clause',
|
|
||||||
key: 'secretClause',
|
|
||||||
label: 'Секретное условие NDA',
|
|
||||||
type: 'text',
|
|
||||||
conditionalVisibility: {
|
|
||||||
fieldKey: 'contractType',
|
|
||||||
operator: 'eq',
|
|
||||||
value: 'nda',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'f_owner_budget',
|
|
||||||
key: 'ownerBudget',
|
|
||||||
label: 'Бюджет сделки (Собственник)',
|
|
||||||
type: 'money',
|
|
||||||
permissions: {
|
|
||||||
viewRoles: ['owner'],
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'f_hidden_flag',
|
|
||||||
key: 'internalAuditToken',
|
|
||||||
label: 'Служебный токен аудита',
|
|
||||||
type: 'text',
|
|
||||||
hidden: true,
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
describe('Form Builder Runtime (F12)', () => {
|
|
||||||
it('1. Conditional visibility and role visibility in DynamicFormRenderer', async () => {
|
|
||||||
// Step A: Under 'owner' role, secretClause is hidden initially because contractType is empty
|
|
||||||
const { rerender } = render(
|
|
||||||
<DynamicFormRenderer
|
|
||||||
entity={mockEntity}
|
|
||||||
onSave={() => {}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('Номер договора')).toBeDefined();
|
|
||||||
expect(screen.getByText('Тип договора')).toBeDefined();
|
|
||||||
expect(screen.queryByText('Секретное условие NDA')).toBeNull(); // Condition not met
|
|
||||||
expect(screen.getByText('Бюджет сделки (Собственник)')).toBeDefined(); // Owner can see
|
|
||||||
expect(screen.queryByText('Служебный токен аудита')).toBeNull(); // Hidden field is never rendered
|
|
||||||
|
|
||||||
// Step B: Switch role to 'cashier'
|
|
||||||
act(() => {
|
|
||||||
useSessionStore.getState().setCurrentRoleId('cashier');
|
|
||||||
});
|
|
||||||
|
|
||||||
rerender(
|
|
||||||
<DynamicFormRenderer
|
|
||||||
entity={mockEntity}
|
|
||||||
onSave={() => {}}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Cashier cannot see ownerBudget
|
|
||||||
expect(screen.queryByText('Бюджет сделки (Собственник)')).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('2. useFormBuilderHistory provides robust 50-step undo/redo capacity', () => {
|
|
||||||
const { result } = renderHook(() => useFormBuilderHistory());
|
|
||||||
|
|
||||||
const initialLayout: LayoutDefinition = {
|
|
||||||
id: 'lay_test',
|
|
||||||
entityCode: 'client_contract',
|
|
||||||
name: 'Базовый макет',
|
|
||||||
isDefault: true,
|
|
||||||
sections: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
act(() => {
|
|
||||||
result.current.initHistory(initialLayout);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.current.canUndo).toBe(false);
|
|
||||||
expect(result.current.canRedo).toBe(false);
|
|
||||||
|
|
||||||
// Push 55 mutations
|
|
||||||
for (let i = 1; i <= 55; i++) {
|
|
||||||
act(() => {
|
|
||||||
result.current.pushState({
|
|
||||||
...initialLayout,
|
|
||||||
name: `Редакция ${i}`,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// History should be capped at 50
|
|
||||||
expect(result.current.history.length).toBeLessThanOrEqual(50);
|
|
||||||
expect(result.current.canUndo).toBe(true);
|
|
||||||
|
|
||||||
// Undo once
|
|
||||||
act(() => {
|
|
||||||
result.current.handleUndo();
|
|
||||||
});
|
|
||||||
expect(result.current.canRedo).toBe(true);
|
|
||||||
expect(result.current.currentLayout?.name).toBe('Редакция 54');
|
|
||||||
|
|
||||||
// Redo once
|
|
||||||
act(() => {
|
|
||||||
result.current.handleRedo();
|
|
||||||
});
|
|
||||||
expect(result.current.currentLayout?.name).toBe('Редакция 55');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Card Builder Runtime (F13)', () => {
|
|
||||||
it('3. DynamicCardRenderer strictly executes cardBlocks in defined order and excludes hidden fields', () => {
|
|
||||||
const sampleRecord = {
|
|
||||||
id: 'rec_01',
|
|
||||||
entityType: 'client_contract',
|
|
||||||
contractNumber: 'DOG-2026-001',
|
|
||||||
contractType: 'standard',
|
|
||||||
secretClause: 'Secret text',
|
|
||||||
ownerBudget: { minor: 50000000, currency: 'RUB' },
|
|
||||||
internalAuditToken: 'SECRET_TOKEN_123',
|
|
||||||
};
|
|
||||||
|
|
||||||
const cardLayout: LayoutDefinition = {
|
|
||||||
id: 'card_lay_01',
|
|
||||||
entityCode: 'client_contract',
|
|
||||||
name: 'Макет карточки',
|
|
||||||
isDefault: true,
|
|
||||||
sections: [],
|
|
||||||
cardBlocks: [
|
|
||||||
{
|
|
||||||
id: 'blk_badges',
|
|
||||||
type: 'badges',
|
|
||||||
title: 'Бейджи',
|
|
||||||
enabled: true,
|
|
||||||
columnSpan: 3,
|
|
||||||
order: 1,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'blk_header',
|
|
||||||
type: 'header',
|
|
||||||
title: 'Шапка',
|
|
||||||
enabled: true,
|
|
||||||
columnSpan: 3,
|
|
||||||
order: 2,
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'blk_fields',
|
|
||||||
type: 'fields_grid',
|
|
||||||
title: 'Атрибуты',
|
|
||||||
enabled: true,
|
|
||||||
columnSpan: 2,
|
|
||||||
order: 3,
|
|
||||||
settings: {
|
|
||||||
fieldKeys: ['contractNumber', 'internalAuditToken'],
|
|
||||||
columnsCount: 2,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
render(
|
|
||||||
<DynamicCardRenderer
|
|
||||||
entity={mockEntity}
|
|
||||||
record={sampleRecord}
|
|
||||||
layout={cardLayout}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify blocks rendered in DOM
|
|
||||||
expect(screen.getByTestId('card-block-badges')).toBeDefined();
|
|
||||||
expect(screen.getByTestId('card-block-header')).toBeDefined();
|
|
||||||
expect(screen.getByTestId('card-block-fields_grid')).toBeDefined();
|
|
||||||
|
|
||||||
// Field contractNumber is visible
|
|
||||||
expect(screen.getByTestId('card-field-contractNumber')).toBeDefined();
|
|
||||||
|
|
||||||
// internalAuditToken is marked hidden in entity metadata, must NOT be rendered even if in fieldKeys!
|
|
||||||
expect(screen.queryByTestId('card-field-internalAuditToken')).toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('View Builder Runtime (F14)', () => {
|
|
||||||
it('4. DynamicTable filters records by saved view filters and applies column widths', () => {
|
|
||||||
const records = [
|
|
||||||
{
|
|
||||||
id: 'rec_1',
|
|
||||||
entityType: 'client_contract',
|
|
||||||
contractNumber: 'DOG-001',
|
|
||||||
contractType: 'standard',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'rec_2',
|
|
||||||
entityType: 'client_contract',
|
|
||||||
contractNumber: 'DOG-002',
|
|
||||||
contractType: 'nda',
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'rec_3',
|
|
||||||
entityType: 'client_contract',
|
|
||||||
contractNumber: 'DOG-003',
|
|
||||||
contractType: 'nda',
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
const customView: ViewDefinition = {
|
|
||||||
id: 'view_nda_only',
|
|
||||||
entityCode: 'client_contract',
|
|
||||||
name: 'Только NDA',
|
|
||||||
type: 'table',
|
|
||||||
isDefault: true,
|
|
||||||
visibleFieldKeys: ['contractNumber', 'contractType'],
|
|
||||||
filters: [
|
|
||||||
{
|
|
||||||
field: 'contractType',
|
|
||||||
operator: 'eq',
|
|
||||||
value: 'nda',
|
|
||||||
},
|
|
||||||
],
|
|
||||||
columnWidths: {
|
|
||||||
contractNumber: 220,
|
|
||||||
contractType: 140,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
render(
|
|
||||||
<DynamicTable
|
|
||||||
entity={mockEntity}
|
|
||||||
records={records}
|
|
||||||
view={customView}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
// Only DOG-002 and DOG-003 should be displayed
|
|
||||||
expect(screen.queryByText('DOG-001')).toBeNull();
|
|
||||||
expect(screen.getAllByText('DOG-002').length).toBeGreaterThanOrEqual(1);
|
|
||||||
expect(screen.getAllByText('DOG-003').length).toBeGreaterThanOrEqual(1);
|
|
||||||
|
|
||||||
// Found count should be 2
|
|
||||||
expect(screen.getByText('2')).toBeDefined();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import React, { useState, useEffect, useCallback } from 'react';
|
import React, { useState } from 'react';
|
||||||
import { INITIAL_TASKS, TaskEntity } from '../../mock/mockData';
|
import { INITIAL_TASKS, TaskEntity } from '../../mock/mockData';
|
||||||
import { getStoreItem, putStoreItem } from '../../services/storage/indexedDb';
|
|
||||||
import { Badge, MaturityBadge, Button, Modal } from '../../shared/ui';
|
import { Badge, MaturityBadge, Button, Modal } from '../../shared/ui';
|
||||||
import {
|
import {
|
||||||
Clock,
|
Clock,
|
||||||
|
|
@ -28,26 +27,6 @@ export const TasksModule: React.FC = () => {
|
||||||
const [tasks, setTasks] = useState<TaskEntity[]>(INITIAL_TASKS);
|
const [tasks, setTasks] = useState<TaskEntity[]>(INITIAL_TASKS);
|
||||||
const [activeView, setActiveView] = useState<'kanban' | 'list'>('kanban');
|
const [activeView, setActiveView] = useState<'kanban' | 'list'>('kanban');
|
||||||
|
|
||||||
// Load persisted tasks on mount
|
|
||||||
useEffect(() => {
|
|
||||||
async function loadTasks() {
|
|
||||||
try {
|
|
||||||
const saved = await getStoreItem<{ key: string; tasks: TaskEntity[] }>('settings', 'krona_persisted_tasks');
|
|
||||||
if (saved?.tasks && Array.isArray(saved.tasks) && saved.tasks.length > 0) {
|
|
||||||
setTasks(saved.tasks);
|
|
||||||
}
|
|
||||||
} catch {
|
|
||||||
// Keep initial
|
|
||||||
}
|
|
||||||
}
|
|
||||||
loadTasks();
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const persistTasks = useCallback((updated: TaskEntity[]) => {
|
|
||||||
setTasks(updated);
|
|
||||||
void putStoreItem('settings', { key: 'krona_persisted_tasks', tasks: updated }).catch(() => {});
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Selected task detail state
|
// Selected task detail state
|
||||||
const [selectedTask, setSelectedTask] = useState<TaskEntity | null>(null);
|
const [selectedTask, setSelectedTask] = useState<TaskEntity | null>(null);
|
||||||
const [subtasks, setSubtasks] = useState<SubtaskItem[]>([
|
const [subtasks, setSubtasks] = useState<SubtaskItem[]>([
|
||||||
|
|
@ -62,8 +41,7 @@ export const TasksModule: React.FC = () => {
|
||||||
const [newComment, setNewComment] = useState('');
|
const [newComment, setNewComment] = useState('');
|
||||||
|
|
||||||
const handleStatusChange = (taskId: string, nextStatus: TaskEntity['status']) => {
|
const handleStatusChange = (taskId: string, nextStatus: TaskEntity['status']) => {
|
||||||
const updated = tasks.map((t) => (t.id === taskId ? { ...t, status: nextStatus } : t));
|
setTasks(tasks.map(t => t.id === taskId ? { ...t, status: nextStatus } : t));
|
||||||
persistTasks(updated);
|
|
||||||
if (selectedTask && selectedTask.id === taskId) {
|
if (selectedTask && selectedTask.id === taskId) {
|
||||||
setSelectedTask({ ...selectedTask, status: nextStatus });
|
setSelectedTask({ ...selectedTask, status: nextStatus });
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
|
import { useDemoStore as useDemoControlsStore } from '../stores/demo';
|
||||||
import { CapabilityId } from '../core/capabilities/types';
|
import { CapabilityId } from '../core/capabilities/types';
|
||||||
import { SystemRoleId } from '../core/permissions/types';
|
import { SystemRoleId } from '../core/permissions/types';
|
||||||
|
import { BUSINESS_PROFILES } from '../core/profiles/profiles';
|
||||||
import { BusinessProfile } from '../core/profiles/types';
|
import { BusinessProfile } from '../core/profiles/types';
|
||||||
|
|
||||||
export type ThemeMode = 'soft-light' | 'light' | 'dark';
|
export type ThemeMode = 'soft-light' | 'light' | 'dark';
|
||||||
|
|
@ -336,9 +338,6 @@ const INITIAL_NOTIFICATIONS: AppNotification[] = [
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
import { useSessionStore } from '../core/session/sessionStore';
|
|
||||||
export { useSessionStore };
|
|
||||||
|
|
||||||
export const useDemoStore = create<DemoStore>((set, get) => ({
|
export const useDemoStore = create<DemoStore>((set, get) => ({
|
||||||
// Theme & Viewport defaults
|
// Theme & Viewport defaults
|
||||||
theme:
|
theme:
|
||||||
|
|
@ -357,27 +356,48 @@ export const useDemoStore = create<DemoStore>((set, get) => ({
|
||||||
viewportMode: 'desktop',
|
viewportMode: 'desktop',
|
||||||
setViewportMode: (viewportMode) => set({ viewportMode }),
|
setViewportMode: (viewportMode) => set({ viewportMode }),
|
||||||
|
|
||||||
// Profile & Role defaults (Delegated to canonical useSessionStore)
|
// Profile & Role defaults
|
||||||
currentProfileId: useSessionStore.getState().currentProfileId,
|
currentProfileId:
|
||||||
|
(typeof window !== 'undefined' && localStorage.getItem('krona_profile_id')) ||
|
||||||
|
'restaurant_hall_delivery',
|
||||||
setProfileId: (currentProfileId) => {
|
setProfileId: (currentProfileId) => {
|
||||||
useSessionStore.getState().setCurrentProfileId(currentProfileId);
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('krona_profile_id', currentProfileId);
|
||||||
|
}
|
||||||
|
const profile = BUSINESS_PROFILES[currentProfileId] || BUSINESS_PROFILES.restaurant_hall_delivery;
|
||||||
|
set({
|
||||||
|
currentProfileId,
|
||||||
|
customCapabilities: { ...profile.capabilities },
|
||||||
|
activeLocation: profile.locations[0] || 'Главная точка',
|
||||||
|
});
|
||||||
|
useDemoControlsStore.getState().setCurrentProfileId(currentProfileId);
|
||||||
},
|
},
|
||||||
currentRoleId: useSessionStore.getState().currentRoleId,
|
currentRoleId:
|
||||||
|
(typeof window !== 'undefined' && (localStorage.getItem('krona_role_id') as SystemRoleId)) ||
|
||||||
|
'owner',
|
||||||
setRoleId: (currentRoleId) => {
|
setRoleId: (currentRoleId) => {
|
||||||
useSessionStore.getState().setCurrentRoleId(currentRoleId);
|
if (typeof window !== 'undefined') {
|
||||||
},
|
localStorage.setItem('krona_role_id', currentRoleId);
|
||||||
activeLocation: useSessionStore.getState().activeLocation,
|
}
|
||||||
setActiveLocation: (activeLocation) => {
|
set({ currentRoleId });
|
||||||
useSessionStore.getState().setActiveLocation(activeLocation);
|
useDemoControlsStore.getState().setCurrentRoleId(currentRoleId);
|
||||||
},
|
},
|
||||||
|
activeLocation: 'Горский 1/1',
|
||||||
|
setActiveLocation: (activeLocation) => set({ activeLocation }),
|
||||||
|
|
||||||
// Capabilities (Delegated to canonical useSessionStore)
|
// Capabilities
|
||||||
customCapabilities: useSessionStore.getState().capabilities,
|
customCapabilities: { ...BUSINESS_PROFILES.restaurant_hall_delivery.capabilities },
|
||||||
toggleCapability: (id) => {
|
toggleCapability: (id) =>
|
||||||
useSessionStore.getState().toggleCapability(id);
|
set((state) => ({
|
||||||
},
|
customCapabilities: {
|
||||||
|
...state.customCapabilities,
|
||||||
|
[id]: !state.customCapabilities[id],
|
||||||
|
},
|
||||||
|
})),
|
||||||
resetCapabilitiesToProfile: () => {
|
resetCapabilitiesToProfile: () => {
|
||||||
useSessionStore.getState().resetCapabilitiesToProfile();
|
const state = get();
|
||||||
|
const profile = BUSINESS_PROFILES[state.currentProfileId] || BUSINESS_PROFILES.restaurant_hall_delivery;
|
||||||
|
set({ customCapabilities: { ...profile.capabilities } });
|
||||||
},
|
},
|
||||||
|
|
||||||
// Panels
|
// Panels
|
||||||
|
|
@ -554,18 +574,10 @@ export const useDemoStore = create<DemoStore>((set, get) => ({
|
||||||
|
|
||||||
// Getters
|
// Getters
|
||||||
getActiveProfile: () => {
|
getActiveProfile: () => {
|
||||||
return useSessionStore.getState().getActiveProfile();
|
const s = get();
|
||||||
|
return BUSINESS_PROFILES[s.currentProfileId] || BUSINESS_PROFILES.restaurant_hall_delivery;
|
||||||
},
|
},
|
||||||
getActiveCapabilities: () => {
|
getActiveCapabilities: () => {
|
||||||
return useSessionStore.getState().getActiveCapabilities();
|
return get().customCapabilities;
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
useSessionStore.subscribe((session) => {
|
|
||||||
useDemoStore.setState({
|
|
||||||
currentRoleId: session.currentRoleId,
|
|
||||||
currentProfileId: session.currentProfileId,
|
|
||||||
activeLocation: session.activeLocation,
|
|
||||||
customCapabilities: session.capabilities,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
|
|
@ -2,7 +2,8 @@ import React from 'react';
|
||||||
import { ShieldAlert, Home, RefreshCw } from 'lucide-react';
|
import { ShieldAlert, Home, RefreshCw } from 'lucide-react';
|
||||||
import { useNavigate } from 'react-router-dom';
|
import { useNavigate } from 'react-router-dom';
|
||||||
import { Button } from '../../shared/ui/Button';
|
import { Button } from '../../shared/ui/Button';
|
||||||
import { useSessionStore } from '../../core/session/sessionStore';
|
import { useDemoStore } from '../../stores/demo';
|
||||||
|
import { useDemoStore as useMockStore } from '../../mock/store';
|
||||||
|
|
||||||
export interface ForbiddenPageProps {
|
export interface ForbiddenPageProps {
|
||||||
requiredPermission?: string;
|
requiredPermission?: string;
|
||||||
|
|
@ -14,10 +15,11 @@ export const ForbiddenPage: React.FC<ForbiddenPageProps> = ({
|
||||||
moduleName,
|
moduleName,
|
||||||
}) => {
|
}) => {
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { currentRoleId, setCurrentRoleId } = useSessionStore();
|
const { currentRoleId, setCurrentRoleId } = useDemoStore();
|
||||||
|
|
||||||
const handleSwitchToOwner = () => {
|
const handleSwitchToOwner = () => {
|
||||||
setCurrentRoleId('owner');
|
setCurrentRoleId('owner');
|
||||||
|
useMockStore.getState().setRoleId('owner');
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
|
|
||||||
|
|
@ -1,91 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
|
||||||
import {
|
|
||||||
getServiceContainer,
|
|
||||||
setServiceContainer,
|
|
||||||
resetServiceContainer,
|
|
||||||
useCommerceService,
|
|
||||||
useInventoryService,
|
|
||||||
useFinanceService,
|
|
||||||
useCrmService,
|
|
||||||
useMetadataService,
|
|
||||||
useAuditService,
|
|
||||||
ServiceContainer,
|
|
||||||
} from '../container';
|
|
||||||
|
|
||||||
describe('Composition Root & Service Container Contract (F22)', () => {
|
|
||||||
beforeEach(() => {
|
|
||||||
resetServiceContainer();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('1. Initializes default container with all required domain services conforming to contracts', () => {
|
|
||||||
const container = getServiceContainer();
|
|
||||||
|
|
||||||
expect(container).toBeDefined();
|
|
||||||
expect(container.commerce).toBeDefined();
|
|
||||||
expect(typeof container.commerce.confirmOrder).toBe('function');
|
|
||||||
expect(typeof container.commerce.cancelOrder).toBe('function');
|
|
||||||
expect(typeof container.commerce.processReturn).toBe('function');
|
|
||||||
|
|
||||||
expect(container.inventory).toBeDefined();
|
|
||||||
expect(typeof container.inventory.reserveStock).toBe('function');
|
|
||||||
expect(typeof container.inventory.cancelOrderReservations).toBe('function');
|
|
||||||
expect(typeof container.inventory.fulfillOrderReservations).toBe('function');
|
|
||||||
|
|
||||||
expect(container.finance).toBeDefined();
|
|
||||||
expect(typeof container.finance.getPnLMetrics).toBe('function');
|
|
||||||
expect(typeof container.finance.createTransactionFromPayment).toBe('function');
|
|
||||||
|
|
||||||
expect(container.crm).toBeDefined();
|
|
||||||
expect(typeof container.crm.convertLeadToDeal).toBe('function');
|
|
||||||
|
|
||||||
expect(container.metadata).toBeDefined();
|
|
||||||
expect(typeof container.metadata.listEntities).toBe('function');
|
|
||||||
expect(typeof container.metadata.getEntity).toBe('function');
|
|
||||||
|
|
||||||
expect(container.audit).toBeDefined();
|
|
||||||
expect(typeof container.audit.log).toBe('function');
|
|
||||||
expect(typeof container.audit.getHistory).toBe('function');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('2. Supports Dependency Injection via setServiceContainer for testing and API swapping', () => {
|
|
||||||
const mockCommerce = {
|
|
||||||
products: {} as any,
|
|
||||||
categories: {} as any,
|
|
||||||
priceLists: {} as any,
|
|
||||||
orders: {} as any,
|
|
||||||
payments: {} as any,
|
|
||||||
shipments: {} as any,
|
|
||||||
returns: {} as any,
|
|
||||||
confirmOrder: async () => ({} as any),
|
|
||||||
cancelOrder: async () => ({} as any),
|
|
||||||
updateOrderStatus: async () => ({} as any),
|
|
||||||
createShipment: async () => ({} as any),
|
|
||||||
createReturn: async () => ({} as any),
|
|
||||||
processReturn: async () => ({} as any),
|
|
||||||
createPayment: async () => ({} as any),
|
|
||||||
};
|
|
||||||
|
|
||||||
const mockContainer: ServiceContainer = {
|
|
||||||
...getServiceContainer(),
|
|
||||||
commerce: mockCommerce,
|
|
||||||
};
|
|
||||||
|
|
||||||
setServiceContainer(mockContainer);
|
|
||||||
|
|
||||||
expect(getServiceContainer().commerce).toBe(mockCommerce);
|
|
||||||
expect(useCommerceService()).toBe(mockCommerce);
|
|
||||||
|
|
||||||
// Reset restores default
|
|
||||||
resetServiceContainer();
|
|
||||||
expect(getServiceContainer().commerce).not.toBe(mockCommerce);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('3. Typed service hook accessors provide direct access to corresponding contracts', () => {
|
|
||||||
expect(useCommerceService()).toBe(getServiceContainer().commerce);
|
|
||||||
expect(useInventoryService()).toBe(getServiceContainer().inventory);
|
|
||||||
expect(useFinanceService()).toBe(getServiceContainer().finance);
|
|
||||||
expect(useCrmService()).toBe(getServiceContainer().crm);
|
|
||||||
expect(useMetadataService()).toBe(getServiceContainer().metadata);
|
|
||||||
expect(useAuditService()).toBe(getServiceContainer().audit);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,328 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
|
||||||
import { resetDatabase } from '../storage/indexedDb';
|
|
||||||
import { getServices } from '../mock';
|
|
||||||
import { InsufficientStockError } from '../contracts/inventory';
|
|
||||||
import { Order } from '../../entities/commerce/order';
|
|
||||||
|
|
||||||
describe('Order & Inventory Reservation Integrity Suite (F02, F06)', () => {
|
|
||||||
const { commerce, inventory } = getServices();
|
|
||||||
const WAREHOUSE_ID = 'wh_test_main';
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await resetDatabase();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('1. Available 2, requested 3 -> rejects with InsufficientStockError and creates NO reservations', async () => {
|
|
||||||
const productId = 'prod_espresso_beans';
|
|
||||||
|
|
||||||
// Seed stock: onHand = 2, reserved = 0, available = 2
|
|
||||||
await inventory.adjustStock(WAREHOUSE_ID, productId, 2, 'Начальный остаток');
|
|
||||||
|
|
||||||
const draftOrder = await commerce.orders.create({
|
|
||||||
orderNumber: 'ORD-TEST-001',
|
|
||||||
customerType: 'contact',
|
|
||||||
customerId: 'cust_1',
|
|
||||||
customerName: 'Тестовый клиент',
|
|
||||||
status: 'draft',
|
|
||||||
paymentStatus: 'pending',
|
|
||||||
fulfillmentStatus: 'unfulfilled',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 'item_1',
|
|
||||||
productId,
|
|
||||||
sku: 'SKU-BEANS',
|
|
||||||
name: 'Кофе в зёрнах',
|
|
||||||
quantity: 3,
|
|
||||||
unitPrice: { minor: 100000, currency: 'RUB' },
|
|
||||||
discountMinor: 0,
|
|
||||||
totalPrice: { minor: 300000, currency: 'RUB' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
subtotal: { minor: 300000, currency: 'RUB' },
|
|
||||||
discountMinor: 0,
|
|
||||||
taxMinor: 0,
|
|
||||||
shippingMinor: 0,
|
|
||||||
total: { minor: 300000, currency: 'RUB' },
|
|
||||||
channel: 'manual',
|
|
||||||
} as Omit<Order, 'id' | 'createdAt' | 'updatedAt'>);
|
|
||||||
|
|
||||||
// Attempting to confirm order with quantity 3 when available is 2 must throw InsufficientStockError
|
|
||||||
await expect(commerce.confirmOrder(draftOrder.id, WAREHOUSE_ID)).rejects.toThrow(
|
|
||||||
InsufficientStockError
|
|
||||||
);
|
|
||||||
|
|
||||||
// Verify invariant: zero reservations created
|
|
||||||
const reservations = await inventory.reservations.list({ filter: { orderId: draftOrder.id } });
|
|
||||||
expect(reservations.items).toHaveLength(0);
|
|
||||||
|
|
||||||
// Verify stock remains untouched: available = 2, reserved = 0
|
|
||||||
const stockList = await inventory.stockItems.list({ filter: { warehouseId: WAREHOUSE_ID, productId } });
|
|
||||||
expect(stockList.items[0].onHand).toBe(2);
|
|
||||||
expect(stockList.items[0].reserved).toBe(0);
|
|
||||||
expect(stockList.items[0].available).toBe(2);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('2. Idempotent confirmation: repeating confirmOrder does not duplicate reservations or decrease available stock', async () => {
|
|
||||||
const productId = 'prod_arabica_special';
|
|
||||||
|
|
||||||
// Seed stock: onHand = 10, available = 10
|
|
||||||
await inventory.adjustStock(WAREHOUSE_ID, productId, 10, 'Поступление зерна');
|
|
||||||
|
|
||||||
const draftOrder = await commerce.orders.create({
|
|
||||||
orderNumber: 'ORD-TEST-002',
|
|
||||||
customerType: 'contact',
|
|
||||||
customerId: 'cust_2',
|
|
||||||
customerName: 'Иван Иванов',
|
|
||||||
status: 'draft',
|
|
||||||
paymentStatus: 'pending',
|
|
||||||
fulfillmentStatus: 'unfulfilled',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 'item_2',
|
|
||||||
productId,
|
|
||||||
sku: 'SKU-ARABICA',
|
|
||||||
name: 'Арабика Премиум',
|
|
||||||
quantity: 4,
|
|
||||||
unitPrice: { minor: 50000, currency: 'RUB' },
|
|
||||||
discountMinor: 0,
|
|
||||||
totalPrice: { minor: 200000, currency: 'RUB' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
subtotal: { minor: 200000, currency: 'RUB' },
|
|
||||||
discountMinor: 0,
|
|
||||||
taxMinor: 0,
|
|
||||||
shippingMinor: 0,
|
|
||||||
total: { minor: 200000, currency: 'RUB' },
|
|
||||||
channel: 'manual',
|
|
||||||
} as Omit<Order, 'id' | 'createdAt' | 'updatedAt'>);
|
|
||||||
|
|
||||||
// First confirmation
|
|
||||||
const confirmed = await commerce.confirmOrder(draftOrder.id, WAREHOUSE_ID);
|
|
||||||
expect(confirmed.status).toBe('confirmed');
|
|
||||||
|
|
||||||
const stockListAfter1 = await inventory.stockItems.list({ filter: { warehouseId: WAREHOUSE_ID, productId } });
|
|
||||||
expect(stockListAfter1.items[0].onHand).toBe(10);
|
|
||||||
expect(stockListAfter1.items[0].reserved).toBe(4);
|
|
||||||
expect(stockListAfter1.items[0].available).toBe(6);
|
|
||||||
|
|
||||||
// Second confirmation (idempotent call)
|
|
||||||
const confirmedAgain = await commerce.confirmOrder(draftOrder.id, WAREHOUSE_ID);
|
|
||||||
expect(confirmedAgain.status).toBe('confirmed');
|
|
||||||
|
|
||||||
// Stock must be exactly the same, NOT double-reserved (must remain reserved: 4, available: 6)
|
|
||||||
const stockListAfter2 = await inventory.stockItems.list({ filter: { warehouseId: WAREHOUSE_ID, productId } });
|
|
||||||
expect(stockListAfter2.items[0].onHand).toBe(10);
|
|
||||||
expect(stockListAfter2.items[0].reserved).toBe(4);
|
|
||||||
expect(stockListAfter2.items[0].available).toBe(6);
|
|
||||||
|
|
||||||
// Exact count of active reservations for order must be 1
|
|
||||||
const reservations = await inventory.reservations.list({ filter: { orderId: draftOrder.id, status: 'active' } });
|
|
||||||
expect(reservations.items).toHaveLength(1);
|
|
||||||
expect(reservations.items[0].quantity).toBe(4);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('3. All-or-Nothing: deficit on second line prevents reservation of first line', async () => {
|
|
||||||
const productA = 'prod_tea_earl_grey';
|
|
||||||
const productB = 'prod_tea_jasmine';
|
|
||||||
|
|
||||||
// Seed: Product A has 10 available, Product B has only 1 available
|
|
||||||
await inventory.adjustStock(WAREHOUSE_ID, productA, 10, 'Приход Эрл Грей');
|
|
||||||
await inventory.adjustStock(WAREHOUSE_ID, productB, 1, 'Приход Жасмин');
|
|
||||||
|
|
||||||
const draftOrder = await commerce.orders.create({
|
|
||||||
orderNumber: 'ORD-TEST-003',
|
|
||||||
customerType: 'company',
|
|
||||||
customerId: 'comp_1',
|
|
||||||
customerName: 'ООО "Чайный Дом"',
|
|
||||||
status: 'draft',
|
|
||||||
paymentStatus: 'pending',
|
|
||||||
fulfillmentStatus: 'unfulfilled',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 'line_1',
|
|
||||||
productId: productA,
|
|
||||||
sku: 'TEA-EG',
|
|
||||||
name: 'Чай Эрл Грей',
|
|
||||||
quantity: 5, // Available is 10 (sufficient)
|
|
||||||
unitPrice: { minor: 40000, currency: 'RUB' },
|
|
||||||
discountMinor: 0,
|
|
||||||
totalPrice: { minor: 200000, currency: 'RUB' },
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: 'line_2',
|
|
||||||
productId: productB,
|
|
||||||
sku: 'TEA-JASMINE',
|
|
||||||
name: 'Чай Жасмин',
|
|
||||||
quantity: 3, // Available is 1 (DEFICIT!)
|
|
||||||
unitPrice: { minor: 60000, currency: 'RUB' },
|
|
||||||
discountMinor: 0,
|
|
||||||
totalPrice: { minor: 180000, currency: 'RUB' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
subtotal: { minor: 380000, currency: 'RUB' },
|
|
||||||
discountMinor: 0,
|
|
||||||
taxMinor: 0,
|
|
||||||
shippingMinor: 0,
|
|
||||||
total: { minor: 380000, currency: 'RUB' },
|
|
||||||
channel: 'b2b',
|
|
||||||
} as Omit<Order, 'id' | 'createdAt' | 'updatedAt'>);
|
|
||||||
|
|
||||||
// Two-phase execution must reject because line 2 has deficit
|
|
||||||
await expect(commerce.confirmOrder(draftOrder.id, WAREHOUSE_ID)).rejects.toThrow(
|
|
||||||
InsufficientStockError
|
|
||||||
);
|
|
||||||
|
|
||||||
// Crucial check: Product A must NOT have been reserved! All-or-nothing atomicity!
|
|
||||||
const stockA = await inventory.stockItems.list({ filter: { warehouseId: WAREHOUSE_ID, productId: productA } });
|
|
||||||
expect(stockA.items[0].reserved).toBe(0);
|
|
||||||
expect(stockA.items[0].available).toBe(10);
|
|
||||||
|
|
||||||
const stockB = await inventory.stockItems.list({ filter: { warehouseId: WAREHOUSE_ID, productId: productB } });
|
|
||||||
expect(stockB.items[0].reserved).toBe(0);
|
|
||||||
expect(stockB.items[0].available).toBe(1);
|
|
||||||
|
|
||||||
// Zero reservations registered
|
|
||||||
const reservations = await inventory.reservations.list({ filter: { orderId: draftOrder.id } });
|
|
||||||
expect(reservations.items).toHaveLength(0);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('4. Cancel order releases active reservations and restores available stock', async () => {
|
|
||||||
const productId = 'prod_matcha_latte';
|
|
||||||
await inventory.adjustStock(WAREHOUSE_ID, productId, 8, 'Приход матча');
|
|
||||||
|
|
||||||
const draftOrder = await commerce.orders.create({
|
|
||||||
orderNumber: 'ORD-TEST-004',
|
|
||||||
customerType: 'contact',
|
|
||||||
customerId: 'cust_4',
|
|
||||||
customerName: 'Анна Соколова',
|
|
||||||
status: 'draft',
|
|
||||||
paymentStatus: 'pending',
|
|
||||||
fulfillmentStatus: 'unfulfilled',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 'line_matcha',
|
|
||||||
productId,
|
|
||||||
sku: 'MATCHA',
|
|
||||||
name: 'Матча Латте',
|
|
||||||
quantity: 3,
|
|
||||||
unitPrice: { minor: 35000, currency: 'RUB' },
|
|
||||||
discountMinor: 0,
|
|
||||||
totalPrice: { minor: 105000, currency: 'RUB' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
subtotal: { minor: 105000, currency: 'RUB' },
|
|
||||||
discountMinor: 0,
|
|
||||||
taxMinor: 0,
|
|
||||||
shippingMinor: 0,
|
|
||||||
total: { minor: 105000, currency: 'RUB' },
|
|
||||||
channel: 'web',
|
|
||||||
} as Omit<Order, 'id' | 'createdAt' | 'updatedAt'>);
|
|
||||||
|
|
||||||
// 1. Confirm order
|
|
||||||
await commerce.confirmOrder(draftOrder.id, WAREHOUSE_ID);
|
|
||||||
let stock = (await inventory.stockItems.list({ filter: { warehouseId: WAREHOUSE_ID, productId } })).items[0];
|
|
||||||
expect(stock.reserved).toBe(3);
|
|
||||||
expect(stock.available).toBe(5);
|
|
||||||
|
|
||||||
// 2. Cancel order
|
|
||||||
const cancelled = await commerce.cancelOrder(draftOrder.id);
|
|
||||||
expect(cancelled.status).toBe('cancelled');
|
|
||||||
|
|
||||||
// 3. Verify stock restored
|
|
||||||
stock = (await inventory.stockItems.list({ filter: { warehouseId: WAREHOUSE_ID, productId } })).items[0];
|
|
||||||
expect(stock.onHand).toBe(8);
|
|
||||||
expect(stock.reserved).toBe(0);
|
|
||||||
expect(stock.available).toBe(8);
|
|
||||||
|
|
||||||
// 4. Verify reservation marked as cancelled
|
|
||||||
const resList = await inventory.reservations.list({ filter: { orderId: draftOrder.id } });
|
|
||||||
expect(resList.items[0].status).toBe('cancelled');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('5. Stock reconciliation matches movements: initial + receipts - shipments + adjustments = onHand', async () => {
|
|
||||||
const productId = 'prod_syrup_vanilla';
|
|
||||||
|
|
||||||
// 1. Receipt: +20
|
|
||||||
await inventory.createAndReceiveGoods(WAREHOUSE_ID, 'REC-001', 'ООО "Сиропы"', [
|
|
||||||
{
|
|
||||||
productId,
|
|
||||||
sku: 'SYRUP-VANILLA',
|
|
||||||
name: 'Сироп Ваниль 1л',
|
|
||||||
quantity: 20,
|
|
||||||
costPrice: { minor: 45000, currency: 'RUB' },
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 2. Additional Receipt: +10
|
|
||||||
await inventory.createAndReceiveGoods(WAREHOUSE_ID, 'REC-002', 'ООО "Сиропы"', [
|
|
||||||
{
|
|
||||||
productId,
|
|
||||||
sku: 'SYRUP-VANILLA',
|
|
||||||
name: 'Сироп Ваниль 1л',
|
|
||||||
quantity: 10,
|
|
||||||
costPrice: { minor: 45000, currency: 'RUB' },
|
|
||||||
},
|
|
||||||
]);
|
|
||||||
|
|
||||||
// 3. Create order, confirm and ship: -7
|
|
||||||
const order = await commerce.orders.create({
|
|
||||||
orderNumber: 'ORD-TEST-005',
|
|
||||||
customerType: 'contact',
|
|
||||||
customerId: 'cust_5',
|
|
||||||
customerName: 'Бариста Тест',
|
|
||||||
status: 'draft',
|
|
||||||
paymentStatus: 'paid',
|
|
||||||
fulfillmentStatus: 'unfulfilled',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 'item_syrup',
|
|
||||||
productId,
|
|
||||||
sku: 'SYRUP-VANILLA',
|
|
||||||
name: 'Сироп Ваниль 1л',
|
|
||||||
quantity: 7,
|
|
||||||
unitPrice: { minor: 70000, currency: 'RUB' },
|
|
||||||
discountMinor: 0,
|
|
||||||
totalPrice: { minor: 490000, currency: 'RUB' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
subtotal: { minor: 490000, currency: 'RUB' },
|
|
||||||
discountMinor: 0,
|
|
||||||
taxMinor: 0,
|
|
||||||
shippingMinor: 0,
|
|
||||||
total: { minor: 490000, currency: 'RUB' },
|
|
||||||
channel: 'pos',
|
|
||||||
} as Omit<Order, 'id' | 'createdAt' | 'updatedAt'>);
|
|
||||||
|
|
||||||
await commerce.confirmOrder(order.id, WAREHOUSE_ID);
|
|
||||||
await commerce.createShipment(order.id, WAREHOUSE_ID);
|
|
||||||
|
|
||||||
// 4. Inventory adjustment (audit discrepancy correction): +2
|
|
||||||
await inventory.adjustStock(WAREHOUSE_ID, productId, 2, 'Инвентаризация: найден излишек');
|
|
||||||
|
|
||||||
// 5. Query Stock Ledger movements
|
|
||||||
const movements = await inventory.movements.list({ filter: { productId } });
|
|
||||||
|
|
||||||
let calculatedOnHand = 0;
|
|
||||||
for (const m of movements.items) {
|
|
||||||
if (m.type === 'receipt') {
|
|
||||||
calculatedOnHand += m.quantity;
|
|
||||||
} else if (m.type === 'shipment') {
|
|
||||||
calculatedOnHand -= m.quantity;
|
|
||||||
} else if (m.type === 'adjustment') {
|
|
||||||
calculatedOnHand += m.quantity;
|
|
||||||
} else if (m.type === 'loss') {
|
|
||||||
calculatedOnHand -= m.quantity;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Expected: 20 + 10 - 7 + 2 = 25
|
|
||||||
expect(calculatedOnHand).toBe(25);
|
|
||||||
|
|
||||||
// Stock item must exactly match calculated balance
|
|
||||||
const stockItem = (await inventory.stockItems.list({ filter: { warehouseId: WAREHOUSE_ID, productId } })).items[0];
|
|
||||||
expect(stockItem.onHand).toBe(25);
|
|
||||||
expect(stockItem.reserved).toBe(0);
|
|
||||||
expect(stockItem.available).toBe(25);
|
|
||||||
expect(stockItem.onHand).toBe(calculatedOnHand);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,280 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
|
||||||
import { resetDatabase } from '../storage/indexedDb';
|
|
||||||
import { getServices } from '../mock';
|
|
||||||
import { Order } from '../../entities/commerce/order';
|
|
||||||
|
|
||||||
describe('Order State Machine & Shipment/Return Lifecycle Suite (F04, F05)', () => {
|
|
||||||
const { commerce, inventory, finance } = getServices();
|
|
||||||
const WAREHOUSE_ID = 'wh_sm_test';
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await resetDatabase();
|
|
||||||
// Ensure default bank account exists in finance
|
|
||||||
await finance.accounts.create({
|
|
||||||
name: 'Расчётный счёт компании',
|
|
||||||
type: 'bank',
|
|
||||||
balance: { minor: 10000000, currency: 'RUB' },
|
|
||||||
currency: 'RUB',
|
|
||||||
isDefault: true,
|
|
||||||
status: 'active',
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const createDraftOrder = async (productId: string, qty = 5, channel: Order['channel'] = 'web') => {
|
|
||||||
// Seed initial stock
|
|
||||||
await inventory.adjustStock(WAREHOUSE_ID, productId, 20, 'Начальный остаток для SM');
|
|
||||||
|
|
||||||
return commerce.orders.create({
|
|
||||||
orderNumber: `ORD-SM-${Date.now().toString().slice(-4)}`,
|
|
||||||
customerType: 'contact',
|
|
||||||
customerId: 'cust_sm',
|
|
||||||
customerName: 'Клиент SM',
|
|
||||||
status: 'draft',
|
|
||||||
paymentStatus: 'unpaid',
|
|
||||||
fulfillmentStatus: 'unfulfilled',
|
|
||||||
warehouseId: WAREHOUSE_ID,
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 'item_sm_1',
|
|
||||||
productId,
|
|
||||||
sku: 'SKU-SM',
|
|
||||||
name: 'Товар SM',
|
|
||||||
quantity: qty,
|
|
||||||
unitPrice: { minor: 100000, currency: 'RUB' },
|
|
||||||
discountMinor: 0,
|
|
||||||
totalPrice: { minor: qty * 100000, currency: 'RUB' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
subtotal: { minor: qty * 100000, currency: 'RUB' },
|
|
||||||
discountMinor: 0,
|
|
||||||
taxMinor: 0,
|
|
||||||
shippingMinor: 0,
|
|
||||||
total: { minor: qty * 100000, currency: 'RUB' },
|
|
||||||
channel,
|
|
||||||
} as Omit<Order, 'id' | 'createdAt' | 'updatedAt'>);
|
|
||||||
};
|
|
||||||
|
|
||||||
it('1. Executes valid state machine progression: draft -> confirmed -> preparing -> ready_for_shipment -> shipped -> delivered -> completed', async () => {
|
|
||||||
const order = await createDraftOrder('prod_sm_1', 3, 'web');
|
|
||||||
expect(order.status).toBe('draft');
|
|
||||||
|
|
||||||
// 1. draft -> confirmed (via confirmOrder)
|
|
||||||
const confirmed = await commerce.confirmOrder(order.id, WAREHOUSE_ID);
|
|
||||||
expect(confirmed.status).toBe('confirmed');
|
|
||||||
|
|
||||||
// 2. confirmed -> preparing
|
|
||||||
const preparing = await commerce.updateOrderStatus(order.id, 'preparing');
|
|
||||||
expect(preparing.status).toBe('preparing');
|
|
||||||
|
|
||||||
// 3. preparing -> ready_for_shipment
|
|
||||||
const ready = await commerce.updateOrderStatus(order.id, 'ready_for_shipment');
|
|
||||||
expect(ready.status).toBe('ready_for_shipment');
|
|
||||||
|
|
||||||
// Pay order before shipping (for B2C/web channel)
|
|
||||||
await commerce.createPayment(order.id, ready.total, 'card', true);
|
|
||||||
|
|
||||||
// 4. ready_for_shipment -> shipped
|
|
||||||
const shipped = await commerce.updateOrderStatus(order.id, 'shipped');
|
|
||||||
expect(shipped.status).toBe('shipped');
|
|
||||||
|
|
||||||
// 5. shipped -> delivered
|
|
||||||
const delivered = await commerce.updateOrderStatus(order.id, 'delivered');
|
|
||||||
expect(delivered.status).toBe('delivered');
|
|
||||||
|
|
||||||
// 6. delivered -> completed
|
|
||||||
const completed = await commerce.updateOrderStatus(order.id, 'completed');
|
|
||||||
expect(completed.status).toBe('completed');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('2. Rejects invalid state transitions (draft -> shipped, completed -> cancelled, shipped -> cancelled)', async () => {
|
|
||||||
const order = await createDraftOrder('prod_sm_2', 2, 'web');
|
|
||||||
|
|
||||||
// Direct illegal transition from draft to shipped
|
|
||||||
await expect(commerce.updateOrderStatus(order.id, 'shipped')).rejects.toThrow(
|
|
||||||
/Недопустимый переход статуса/
|
|
||||||
);
|
|
||||||
|
|
||||||
// Direct illegal transition from draft to ready_for_shipment
|
|
||||||
await expect(commerce.updateOrderStatus(order.id, 'ready_for_shipment')).rejects.toThrow(
|
|
||||||
/Недопустимый переход статуса/
|
|
||||||
);
|
|
||||||
|
|
||||||
// Confirm order
|
|
||||||
await commerce.confirmOrder(order.id, WAREHOUSE_ID);
|
|
||||||
await commerce.createPayment(order.id, order.total, 'card', false);
|
|
||||||
await commerce.createShipment(order.id, WAREHOUSE_ID);
|
|
||||||
|
|
||||||
// After shipping, cannot cancel!
|
|
||||||
await expect(commerce.updateOrderStatus(order.id, 'cancelled')).rejects.toThrow(
|
|
||||||
/Недопустимый переход статуса/
|
|
||||||
);
|
|
||||||
await expect(commerce.cancelOrder(order.id)).rejects.toThrow(
|
|
||||||
/Отменить можно только черновик или подтверждённый заказ/
|
|
||||||
);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('3. Shipment precondition: unpaid retail orders cannot be shipped; B2B orders allow postpaid shipment', async () => {
|
|
||||||
// A) Retail order unpaid -> createShipment must be blocked
|
|
||||||
const retailOrder = await createDraftOrder('prod_sm_retail', 2, 'web');
|
|
||||||
await commerce.confirmOrder(retailOrder.id, WAREHOUSE_ID);
|
|
||||||
await commerce.updateOrderStatus(retailOrder.id, 'preparing');
|
|
||||||
await commerce.updateOrderStatus(retailOrder.id, 'ready_for_shipment');
|
|
||||||
|
|
||||||
await expect(commerce.createShipment(retailOrder.id, WAREHOUSE_ID)).rejects.toThrow(
|
|
||||||
/Отгрузка невозможна: заказ не оплачен/
|
|
||||||
);
|
|
||||||
|
|
||||||
// Pay retail order -> now createShipment succeeds!
|
|
||||||
await commerce.createPayment(retailOrder.id, retailOrder.total, 'card', false);
|
|
||||||
const retailShipment = await commerce.createShipment(retailOrder.id, WAREHOUSE_ID);
|
|
||||||
expect(retailShipment.status).toBe('ready');
|
|
||||||
const updatedRetailOrder = await commerce.orders.getById(retailOrder.id);
|
|
||||||
expect(updatedRetailOrder?.status).toBe('shipped');
|
|
||||||
|
|
||||||
// B) B2B order unpaid -> postpaid shipment allowed
|
|
||||||
const b2bOrder = await createDraftOrder('prod_sm_b2b', 4, 'b2b');
|
|
||||||
await commerce.confirmOrder(b2bOrder.id, WAREHOUSE_ID);
|
|
||||||
await commerce.updateOrderStatus(b2bOrder.id, 'preparing');
|
|
||||||
await commerce.updateOrderStatus(b2bOrder.id, 'ready_for_shipment');
|
|
||||||
|
|
||||||
const b2bShipment = await commerce.createShipment(b2bOrder.id, WAREHOUSE_ID);
|
|
||||||
expect(b2bShipment.status).toBe('ready');
|
|
||||||
const updatedB2BOrder = await commerce.orders.getById(b2bOrder.id);
|
|
||||||
expect(updatedB2BOrder?.status).toBe('shipped');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('4. createShipment fulfills active reservation and deducts physical stock onHand', async () => {
|
|
||||||
const productId = 'prod_sm_stock';
|
|
||||||
const order = await createDraftOrder(productId, 5, 'b2b');
|
|
||||||
|
|
||||||
// Before confirmation: onHand = 20, reserved = 0, available = 20
|
|
||||||
let stock = (await inventory.stockItems.list({ filter: { warehouseId: WAREHOUSE_ID, productId } })).items[0];
|
|
||||||
expect(stock.onHand).toBe(20);
|
|
||||||
expect(stock.reserved).toBe(0);
|
|
||||||
|
|
||||||
// Confirm: onHand = 20, reserved = 5, available = 15
|
|
||||||
await commerce.confirmOrder(order.id, WAREHOUSE_ID);
|
|
||||||
stock = (await inventory.stockItems.list({ filter: { warehouseId: WAREHOUSE_ID, productId } })).items[0];
|
|
||||||
expect(stock.onHand).toBe(20);
|
|
||||||
expect(stock.reserved).toBe(5);
|
|
||||||
expect(stock.available).toBe(15);
|
|
||||||
|
|
||||||
// Create Shipment: physical onHand reduces to 15, reserved cleared to 0, available = 15
|
|
||||||
await commerce.createShipment(order.id, WAREHOUSE_ID);
|
|
||||||
stock = (await inventory.stockItems.list({ filter: { warehouseId: WAREHOUSE_ID, productId } })).items[0];
|
|
||||||
expect(stock.onHand).toBe(15);
|
|
||||||
expect(stock.reserved).toBe(0);
|
|
||||||
expect(stock.available).toBe(15);
|
|
||||||
|
|
||||||
// Reservation is marked 'fulfilled'
|
|
||||||
const resList = await inventory.reservations.list({ filter: { orderId: order.id } });
|
|
||||||
expect(resList.items[0].status).toBe('fulfilled');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('5. createReturn: allows return only on shipped/delivered orders, checks max returnable qty, restores onHand, and records outflow in finance', async () => {
|
|
||||||
const productId = 'prod_sm_return';
|
|
||||||
const order = await createDraftOrder(productId, 6, 'b2b');
|
|
||||||
|
|
||||||
// 1. Attempting return on draft order must be rejected
|
|
||||||
await expect(
|
|
||||||
commerce.createReturn(order.id, 'Брак', [
|
|
||||||
{
|
|
||||||
productId,
|
|
||||||
sku: 'SKU-SM',
|
|
||||||
name: 'Товар SM',
|
|
||||||
quantity: 2,
|
|
||||||
refundAmount: { minor: 200000, currency: 'RUB' },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
).rejects.toThrow(/Возврат разрешён только по отгруженным или доставленным/);
|
|
||||||
|
|
||||||
// Confirm and Ship order (qty = 6)
|
|
||||||
await commerce.confirmOrder(order.id, WAREHOUSE_ID);
|
|
||||||
await commerce.createShipment(order.id, WAREHOUSE_ID);
|
|
||||||
|
|
||||||
// Stock onHand after shipment: 20 - 6 = 14
|
|
||||||
let stock = (await inventory.stockItems.list({ filter: { warehouseId: WAREHOUSE_ID, productId } })).items[0];
|
|
||||||
expect(stock.onHand).toBe(14);
|
|
||||||
|
|
||||||
// 2. Attempting return of 7 when only 6 were shipped must be rejected
|
|
||||||
await expect(
|
|
||||||
commerce.createReturn(order.id, 'Отказ', [
|
|
||||||
{
|
|
||||||
productId,
|
|
||||||
sku: 'SKU-SM',
|
|
||||||
name: 'Товар SM',
|
|
||||||
quantity: 7,
|
|
||||||
refundAmount: { minor: 700000, currency: 'RUB' },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
).rejects.toThrow(/превышает отгруженное доступное/);
|
|
||||||
|
|
||||||
// 3. Valid return of 2 items
|
|
||||||
const ret1 = await commerce.createReturn(
|
|
||||||
order.id,
|
|
||||||
'Частичный отказ клиента',
|
|
||||||
[
|
|
||||||
{
|
|
||||||
productId,
|
|
||||||
sku: 'SKU-SM',
|
|
||||||
name: 'Товар SM',
|
|
||||||
quantity: 2,
|
|
||||||
refundAmount: { minor: 200000, currency: 'RUB' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
WAREHOUSE_ID,
|
|
||||||
true // auto-record finance transaction
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(ret1.status).toBe('refunded');
|
|
||||||
expect(ret1.totalRefund.minor).toBe(200000);
|
|
||||||
|
|
||||||
// Verify physical stock onHand restored from 14 -> 16
|
|
||||||
stock = (await inventory.stockItems.list({ filter: { warehouseId: WAREHOUSE_ID, productId } })).items[0];
|
|
||||||
expect(stock.onHand).toBe(16);
|
|
||||||
expect(stock.available).toBe(16);
|
|
||||||
|
|
||||||
// Verify finance outflow transaction recorded
|
|
||||||
const txList = await finance.transactions.list({ filter: { referenceId: ret1.id } });
|
|
||||||
expect(txList.items).toHaveLength(1);
|
|
||||||
expect(txList.items[0].type).toBe('outflow');
|
|
||||||
expect(txList.items[0].category).toBe('refund');
|
|
||||||
expect(txList.items[0].amount.minor).toBe(200000);
|
|
||||||
|
|
||||||
// 4. Second return: can return up to 4 more. Trying to return 5 must be rejected!
|
|
||||||
await expect(
|
|
||||||
commerce.createReturn(order.id, 'Лишний возврат', [
|
|
||||||
{
|
|
||||||
productId,
|
|
||||||
sku: 'SKU-SM',
|
|
||||||
name: 'Товар SM',
|
|
||||||
quantity: 5,
|
|
||||||
refundAmount: { minor: 500000, currency: 'RUB' },
|
|
||||||
},
|
|
||||||
])
|
|
||||||
).rejects.toThrow(/превышает отгруженное доступное/);
|
|
||||||
|
|
||||||
// Returning exactly the remaining 4 items succeeds
|
|
||||||
const ret2 = await commerce.createReturn(
|
|
||||||
order.id,
|
|
||||||
'Возврат остатка партии',
|
|
||||||
[
|
|
||||||
{
|
|
||||||
productId,
|
|
||||||
sku: 'SKU-SM',
|
|
||||||
name: 'Товар SM',
|
|
||||||
quantity: 4,
|
|
||||||
refundAmount: { minor: 400000, currency: 'RUB' },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
WAREHOUSE_ID,
|
|
||||||
true
|
|
||||||
);
|
|
||||||
expect(ret2.status).toBe('refunded');
|
|
||||||
|
|
||||||
// Stock onHand restored from 16 -> 20 (original initial balance)
|
|
||||||
stock = (await inventory.stockItems.list({ filter: { warehouseId: WAREHOUSE_ID, productId } })).items[0];
|
|
||||||
expect(stock.onHand).toBe(20);
|
|
||||||
expect(stock.available).toBe(20);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,192 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
|
||||||
import {
|
|
||||||
resetDatabase,
|
|
||||||
generateMonotonicNumber,
|
|
||||||
getRecordsByEntity,
|
|
||||||
putRecord,
|
|
||||||
} from '../storage/indexedDb';
|
|
||||||
import { getServices } from '../mock';
|
|
||||||
import { createRequestContext } from '../contracts/context';
|
|
||||||
|
|
||||||
describe('Pagination & Monotonic Numbering Integrity (F16)', () => {
|
|
||||||
const { commerce } = getServices();
|
|
||||||
const TENANT_A = 'tenant_num_a';
|
|
||||||
const TENANT_B = 'tenant_num_b';
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await resetDatabase();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Monotonic Sequence Generator', () => {
|
|
||||||
it('1. Generates strictly incremental sequence (0001, 0002, 0003)', async () => {
|
|
||||||
const n1 = await generateMonotonicNumber('ORD', 4, TENANT_A);
|
|
||||||
const n2 = await generateMonotonicNumber('ORD', 4, TENANT_A);
|
|
||||||
const n3 = await generateMonotonicNumber('ORD', 4, TENANT_A);
|
|
||||||
|
|
||||||
expect(n1).toBe('ORD-0001');
|
|
||||||
expect(n2).toBe('ORD-0002');
|
|
||||||
expect(n3).toBe('ORD-0003');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('2. Sequence never rewinds or collides even after records are deleted', async () => {
|
|
||||||
const n1 = await generateMonotonicNumber('ORD', 4, TENANT_A);
|
|
||||||
const n2 = await generateMonotonicNumber('ORD', 4, TENANT_A);
|
|
||||||
const n3 = await generateMonotonicNumber('ORD', 4, TENANT_A);
|
|
||||||
expect(n1).toBe('ORD-0001');
|
|
||||||
expect(n2).toBe('ORD-0002');
|
|
||||||
expect(n3).toBe('ORD-0003');
|
|
||||||
|
|
||||||
// Even if items 1-3 were deleted, next item is strictly ORD-0004, NOT ORD-0001 or ORD-records.length
|
|
||||||
const n4 = await generateMonotonicNumber('ORD', 4, TENANT_A);
|
|
||||||
expect(n4).toBe('ORD-0004');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('3. Independent counters for different prefixes', async () => {
|
|
||||||
const ord1 = await generateMonotonicNumber('ORD', 4, TENANT_A);
|
|
||||||
const inv1 = await generateMonotonicNumber('INV', 4, TENANT_A);
|
|
||||||
const lead1 = await generateMonotonicNumber('LEAD', 4, TENANT_A);
|
|
||||||
const ord2 = await generateMonotonicNumber('ORD', 4, TENANT_A);
|
|
||||||
|
|
||||||
expect(ord1).toBe('ORD-0001');
|
|
||||||
expect(inv1).toBe('INV-0001');
|
|
||||||
expect(lead1).toBe('LEAD-0001');
|
|
||||||
expect(ord2).toBe('ORD-0002');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('4. Multi-tenant counter isolation', async () => {
|
|
||||||
const a1 = await generateMonotonicNumber('ORD', 4, TENANT_A);
|
|
||||||
const b1 = await generateMonotonicNumber('ORD', 4, TENANT_B);
|
|
||||||
const a2 = await generateMonotonicNumber('ORD', 4, TENANT_A);
|
|
||||||
|
|
||||||
expect(a1).toBe('ORD-0001');
|
|
||||||
expect(b1).toBe('ORD-0001');
|
|
||||||
expect(a2).toBe('ORD-0002');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Repository Auto-Numbering on Create', () => {
|
|
||||||
it('5. BaseMockRepository automatically assigns monotonic orderNumber when omitted', async () => {
|
|
||||||
const o1 = await commerce.orders.create({
|
|
||||||
orderNumber: '',
|
|
||||||
customerType: 'contact',
|
|
||||||
customerId: 'c1',
|
|
||||||
customerName: 'Customer One',
|
|
||||||
status: 'draft',
|
|
||||||
paymentStatus: 'unpaid',
|
|
||||||
fulfillmentStatus: 'unfulfilled',
|
|
||||||
items: [],
|
|
||||||
subtotal: { minor: 10000, currency: 'RUB' },
|
|
||||||
discountMinor: 0,
|
|
||||||
taxMinor: 0,
|
|
||||||
shippingMinor: 0,
|
|
||||||
total: { minor: 10000, currency: 'RUB' },
|
|
||||||
} as any, createRequestContext({ tenantId: TENANT_A, correlationId: 'corr_1' }));
|
|
||||||
|
|
||||||
const o2 = await commerce.orders.create({
|
|
||||||
orderNumber: '',
|
|
||||||
customerType: 'contact',
|
|
||||||
customerId: 'c2',
|
|
||||||
customerName: 'Customer Two',
|
|
||||||
status: 'draft',
|
|
||||||
paymentStatus: 'unpaid',
|
|
||||||
fulfillmentStatus: 'unfulfilled',
|
|
||||||
items: [],
|
|
||||||
subtotal: { minor: 20000, currency: 'RUB' },
|
|
||||||
discountMinor: 0,
|
|
||||||
taxMinor: 0,
|
|
||||||
shippingMinor: 0,
|
|
||||||
total: { minor: 20000, currency: 'RUB' },
|
|
||||||
} as any, createRequestContext({ tenantId: TENANT_A, correlationId: 'corr_2' }));
|
|
||||||
|
|
||||||
expect(o1.orderNumber).toBe('ORD-0001');
|
|
||||||
expect(o2.orderNumber).toBe('ORD-0002');
|
|
||||||
|
|
||||||
// Delete o1
|
|
||||||
await commerce.orders.delete(o1.id, createRequestContext({ tenantId: TENANT_A, correlationId: 'corr_3' }));
|
|
||||||
|
|
||||||
// Create o3 -> Must be ORD-0003, avoiding collision with o2
|
|
||||||
const o3 = await commerce.orders.create({
|
|
||||||
orderNumber: '',
|
|
||||||
customerType: 'contact',
|
|
||||||
customerId: 'c3',
|
|
||||||
customerName: 'Customer Three',
|
|
||||||
status: 'draft',
|
|
||||||
paymentStatus: 'unpaid',
|
|
||||||
fulfillmentStatus: 'unfulfilled',
|
|
||||||
items: [],
|
|
||||||
subtotal: { minor: 30000, currency: 'RUB' },
|
|
||||||
discountMinor: 0,
|
|
||||||
taxMinor: 0,
|
|
||||||
shippingMinor: 0,
|
|
||||||
total: { minor: 30000, currency: 'RUB' },
|
|
||||||
} as any, createRequestContext({ tenantId: TENANT_A, correlationId: 'corr_4' }));
|
|
||||||
|
|
||||||
expect(o3.orderNumber).toBe('ORD-0003');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Full Dataset Search & Invariant Pagination (F16)', () => {
|
|
||||||
it('6. Searches across whole collection before slicing pages, maintaining total and totalPages', async () => {
|
|
||||||
// Seed 12 items: 8 "Premium Arabica Coffee" and 4 "Green Tea"
|
|
||||||
for (let i = 1; i <= 8; i++) {
|
|
||||||
await putRecord({
|
|
||||||
id: `coffee_${i}`,
|
|
||||||
entityType: 'test_product',
|
|
||||||
name: `Premium Arabica Coffee Batch #${i}`,
|
|
||||||
category: 'coffee',
|
|
||||||
price: 500 + i * 10,
|
|
||||||
tenant_id: TENANT_A,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
for (let i = 1; i <= 4; i++) {
|
|
||||||
await putRecord({
|
|
||||||
id: `tea_${i}`,
|
|
||||||
entityType: 'test_product',
|
|
||||||
name: `Green Tea Leaf #${i}`,
|
|
||||||
category: 'tea',
|
|
||||||
price: 200 + i * 10,
|
|
||||||
tenant_id: TENANT_A,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Page 1 of search "Arabica" with pageSize = 3
|
|
||||||
const page1 = await getRecordsByEntity('test_product', {
|
|
||||||
search: 'Arabica',
|
|
||||||
page: 1,
|
|
||||||
pageSize: 3,
|
|
||||||
tenantId: TENANT_A,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(page1.total).toBe(8); // Full count of matching records across collection
|
|
||||||
expect(page1.totalPages).toBe(3); // Math.ceil(8 / 3) = 3
|
|
||||||
expect(page1.page).toBe(1);
|
|
||||||
expect(page1.pageSize).toBe(3);
|
|
||||||
expect(page1.items).toHaveLength(3);
|
|
||||||
expect((page1.items[0] as any).name).toContain('Coffee Batch #1');
|
|
||||||
|
|
||||||
// Page 2 of search "Arabica"
|
|
||||||
const page2 = await getRecordsByEntity('test_product', {
|
|
||||||
search: 'Arabica',
|
|
||||||
page: 2,
|
|
||||||
pageSize: 3,
|
|
||||||
tenantId: TENANT_A,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(page2.total).toBe(8);
|
|
||||||
expect(page2.items).toHaveLength(3);
|
|
||||||
expect((page2.items[0] as any).name).toContain('Coffee Batch #4');
|
|
||||||
|
|
||||||
// Page 3 of search "Arabica" (last page with remaining 2 items)
|
|
||||||
const page3 = await getRecordsByEntity('test_product', {
|
|
||||||
search: 'Arabica',
|
|
||||||
page: 3,
|
|
||||||
pageSize: 3,
|
|
||||||
tenantId: TENANT_A,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(page3.total).toBe(8);
|
|
||||||
expect(page3.items).toHaveLength(2);
|
|
||||||
expect((page3.items[1] as any).name).toContain('Coffee Batch #8');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,197 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
|
||||||
import { resetDatabase } from '../storage/indexedDb';
|
|
||||||
import { getServices } from '../mock';
|
|
||||||
import { Order } from '../../entities/commerce/order';
|
|
||||||
|
|
||||||
describe('Payment Model & Finance Invariants Suite (F03, F07)', () => {
|
|
||||||
const { commerce, finance } = getServices();
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await resetDatabase();
|
|
||||||
});
|
|
||||||
|
|
||||||
const createTestOrder = async (totalMinor = 100000, currency = 'RUB') => {
|
|
||||||
return commerce.orders.create({
|
|
||||||
orderNumber: `ORD-${Date.now()}`,
|
|
||||||
customerType: 'contact',
|
|
||||||
customerId: 'cust_pay',
|
|
||||||
customerName: 'Платёжный клиент',
|
|
||||||
status: 'confirmed',
|
|
||||||
paymentStatus: 'unpaid',
|
|
||||||
fulfillmentStatus: 'unfulfilled',
|
|
||||||
items: [
|
|
||||||
{
|
|
||||||
id: 'item_p1',
|
|
||||||
productId: 'prod_coffee',
|
|
||||||
sku: 'COFFEE-1',
|
|
||||||
name: 'Кофе',
|
|
||||||
quantity: 1,
|
|
||||||
unitPrice: { minor: totalMinor, currency },
|
|
||||||
discountMinor: 0,
|
|
||||||
totalPrice: { minor: totalMinor, currency },
|
|
||||||
},
|
|
||||||
],
|
|
||||||
subtotal: { minor: totalMinor, currency },
|
|
||||||
discountMinor: 0,
|
|
||||||
taxMinor: 0,
|
|
||||||
shippingMinor: 0,
|
|
||||||
total: { minor: totalMinor, currency },
|
|
||||||
channel: 'web',
|
|
||||||
} as Omit<Order, 'id' | 'createdAt' | 'updatedAt'>);
|
|
||||||
};
|
|
||||||
|
|
||||||
it('1. Rejects non-positive payments and currency mismatches', async () => {
|
|
||||||
const order = await createTestOrder(100000, 'RUB');
|
|
||||||
|
|
||||||
// 0 minor units rejected
|
|
||||||
await expect(
|
|
||||||
commerce.createPayment(order.id, { minor: 0, currency: 'RUB' }, 'card')
|
|
||||||
).rejects.toThrow(/больше нуля/);
|
|
||||||
|
|
||||||
// Negative minor units rejected
|
|
||||||
await expect(
|
|
||||||
commerce.createPayment(order.id, { minor: -5000, currency: 'RUB' }, 'card')
|
|
||||||
).rejects.toThrow(/больше нуля/);
|
|
||||||
|
|
||||||
// Currency mismatch (USD vs RUB) rejected
|
|
||||||
await expect(
|
|
||||||
commerce.createPayment(order.id, { minor: 50000, currency: 'USD' }, 'card')
|
|
||||||
).rejects.toThrow(/не совпадает/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('2. Computes cumulative payment progression: unpaid -> partially_paid -> paid -> overpaid', async () => {
|
|
||||||
const order = await createTestOrder(100000, 'RUB'); // Total 1000.00 RUB
|
|
||||||
expect(order.paymentStatus).toBe('unpaid');
|
|
||||||
|
|
||||||
// Step 1: Partial payment of 400.00 RUB (40000 minor)
|
|
||||||
await commerce.createPayment(order.id, { minor: 40000, currency: 'RUB' }, 'card');
|
|
||||||
const orderStep1 = await commerce.orders.getById(order.id);
|
|
||||||
expect(orderStep1?.paymentStatus).toBe('partially_paid');
|
|
||||||
|
|
||||||
// Step 2: Second payment of 600.00 RUB (cumulative 1000.00 RUB)
|
|
||||||
await commerce.createPayment(order.id, { minor: 60000, currency: 'RUB' }, 'cash');
|
|
||||||
const orderStep2 = await commerce.orders.getById(order.id);
|
|
||||||
expect(orderStep2?.paymentStatus).toBe('paid');
|
|
||||||
|
|
||||||
// Step 3: Additional payment of 100.00 RUB (cumulative 1100.00 RUB -> overpaid)
|
|
||||||
await commerce.createPayment(order.id, { minor: 10000, currency: 'RUB' }, 'online');
|
|
||||||
const orderStep3 = await commerce.orders.getById(order.id);
|
|
||||||
expect(orderStep3?.paymentStatus).toBe('overpaid');
|
|
||||||
});
|
|
||||||
|
|
||||||
it('3. Idempotency by idempotencyKey prevents duplicate payment records', async () => {
|
|
||||||
const order = await createTestOrder(50000, 'RUB');
|
|
||||||
const idempotencyKey = 'unique-idemp-key-12345';
|
|
||||||
|
|
||||||
// First attempt
|
|
||||||
const payment1 = await commerce.createPayment(
|
|
||||||
order.id,
|
|
||||||
{ minor: 50000, currency: 'RUB' },
|
|
||||||
'card',
|
|
||||||
false,
|
|
||||||
idempotencyKey
|
|
||||||
);
|
|
||||||
expect(payment1.id).toBeTruthy();
|
|
||||||
|
|
||||||
// Second attempt with same idempotency key
|
|
||||||
const payment2 = await commerce.createPayment(
|
|
||||||
order.id,
|
|
||||||
{ minor: 50000, currency: 'RUB' },
|
|
||||||
'card',
|
|
||||||
false,
|
|
||||||
idempotencyKey
|
|
||||||
);
|
|
||||||
|
|
||||||
// Must return the exact same payment object without creating a duplicate
|
|
||||||
expect(payment2.id).toBe(payment1.id);
|
|
||||||
|
|
||||||
const allPayments = await commerce.payments.list({ filter: { orderId: order.id } });
|
|
||||||
expect(allPayments.items).toHaveLength(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('4. Atomic rollback: if finance recording fails, payment is not persisted', async () => {
|
|
||||||
const order = await createTestOrder(50000, 'RUB');
|
|
||||||
|
|
||||||
// Create an invalid account setup or pass invalid parameter to trigger finance failure
|
|
||||||
// If financeService throws during createTransactionFromPayment, commerce payment must rollback
|
|
||||||
const origCreateTx = finance.createTransactionFromPayment.bind(finance);
|
|
||||||
finance.createTransactionFromPayment = async () => {
|
|
||||||
throw new Error('Банковский шлюз недоступен');
|
|
||||||
};
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
commerce.createPayment(order.id, { minor: 50000, currency: 'RUB' }, 'card', true)
|
|
||||||
).rejects.toThrow('Банковский шлюз недоступен');
|
|
||||||
|
|
||||||
// Verify payment was rolled back and NOT saved
|
|
||||||
const payments = await commerce.payments.list({ filter: { orderId: order.id } });
|
|
||||||
expect(payments.items).toHaveLength(0);
|
|
||||||
|
|
||||||
// Verify order status was NOT changed to paid
|
|
||||||
const updatedOrder = await commerce.orders.getById(order.id);
|
|
||||||
expect(updatedOrder?.paymentStatus).toBe('unpaid');
|
|
||||||
|
|
||||||
// Restore original method
|
|
||||||
finance.createTransactionFromPayment = origCreateTx;
|
|
||||||
});
|
|
||||||
|
|
||||||
it('5. MockFinanceService requires valid referenceType/Id and tags PnL with disclaimer and date filters', async () => {
|
|
||||||
// 1. Mandatory reference basis
|
|
||||||
await expect(
|
|
||||||
finance.createTransactionFromPayment('', 'order_123', { minor: 10000, currency: 'RUB' })
|
|
||||||
).rejects.toThrow(/Обязательное основание/);
|
|
||||||
|
|
||||||
await expect(
|
|
||||||
finance.createTransactionFromReturn('ret_1', '', { minor: 10000, currency: 'RUB' })
|
|
||||||
).rejects.toThrow(/Обязательное основание/);
|
|
||||||
|
|
||||||
// 2. Add sample transactions in different dates
|
|
||||||
const accounts = await finance.accounts.list();
|
|
||||||
const defaultAcc =
|
|
||||||
accounts.items[0] ||
|
|
||||||
(await finance.accounts.create({
|
|
||||||
name: 'Основной расчётный счёт',
|
|
||||||
type: 'bank',
|
|
||||||
balance: { minor: 1000000, currency: 'RUB' },
|
|
||||||
currency: 'RUB',
|
|
||||||
isDefault: true,
|
|
||||||
status: 'active',
|
|
||||||
}));
|
|
||||||
|
|
||||||
await finance.transactions.create({
|
|
||||||
transactionNumber: 'CFT-DATE-1',
|
|
||||||
type: 'inflow',
|
|
||||||
category: 'sales_revenue',
|
|
||||||
amount: { minor: 150000, currency: 'RUB' },
|
|
||||||
accountId: defaultAcc.id,
|
|
||||||
accountName: defaultAcc.name,
|
|
||||||
referenceType: 'order_payment',
|
|
||||||
referenceId: 'pay_date_1',
|
|
||||||
description: 'Сентябрьский чек',
|
|
||||||
timestamp: '2026-09-05T12:00:00.000Z',
|
|
||||||
});
|
|
||||||
|
|
||||||
await finance.transactions.create({
|
|
||||||
transactionNumber: 'CFT-DATE-2',
|
|
||||||
type: 'inflow',
|
|
||||||
category: 'sales_revenue',
|
|
||||||
amount: { minor: 80000, currency: 'RUB' },
|
|
||||||
accountId: defaultAcc.id,
|
|
||||||
accountName: defaultAcc.name,
|
|
||||||
referenceType: 'order_payment',
|
|
||||||
referenceId: 'pay_date_2',
|
|
||||||
description: 'Августовский чек',
|
|
||||||
timestamp: '2026-08-15T12:00:00.000Z',
|
|
||||||
});
|
|
||||||
|
|
||||||
// Filter by date range: only September 2026
|
|
||||||
const pnlSept = await finance.getPnLMetrics('2026-09', '2026-09-01', '2026-09-30');
|
|
||||||
expect(pnlSept.revenue.minor).toBe(150000);
|
|
||||||
expect(pnlSept.reportDisclaimer).toBe('Демонстрационная модель расчёта');
|
|
||||||
|
|
||||||
// Filter all: both August and September
|
|
||||||
const pnlAll = await finance.getPnLMetrics('all');
|
|
||||||
expect(pnlAll.revenue.minor).toBeGreaterThanOrEqual(230000);
|
|
||||||
expect(pnlAll.reportDisclaimer).toBe('Демонстрационная модель расчёта');
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -1,87 +0,0 @@
|
||||||
import { ICommerceService } from './contracts/commerce';
|
|
||||||
import { IInventoryService } from './contracts/inventory';
|
|
||||||
import { IFinanceService } from './contracts/finance';
|
|
||||||
import { ICrmService } from './contracts/crm';
|
|
||||||
import { IMetadataService } from './contracts/metadata';
|
|
||||||
import { IAuditService } from './contracts/audit';
|
|
||||||
import {
|
|
||||||
MockCommerceService,
|
|
||||||
MockInventoryService,
|
|
||||||
MockFinanceService,
|
|
||||||
MockCrmService,
|
|
||||||
MockMetadataService,
|
|
||||||
MockAuditService,
|
|
||||||
} from './mock';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* ServiceContainer interface defining all system services by their typed contracts.
|
|
||||||
* This is the architectural boundary (Composition Root, F22).
|
|
||||||
* UI layers and hooks consume only interfaces from this container.
|
|
||||||
* When migrating to a real REST/GraphQL/gRPC backend, only the factory in this container
|
|
||||||
* needs to be swapped for the API adapter implementation without changing UI components.
|
|
||||||
*/
|
|
||||||
export interface ServiceContainer {
|
|
||||||
commerce: ICommerceService;
|
|
||||||
inventory: IInventoryService;
|
|
||||||
finance: IFinanceService;
|
|
||||||
crm: ICrmService;
|
|
||||||
metadata: IMetadataService;
|
|
||||||
audit: IAuditService;
|
|
||||||
}
|
|
||||||
|
|
||||||
let currentContainer: ServiceContainer | null = null;
|
|
||||||
|
|
||||||
export function createDefaultContainer(): ServiceContainer {
|
|
||||||
return {
|
|
||||||
commerce: new MockCommerceService(),
|
|
||||||
inventory: new MockInventoryService(),
|
|
||||||
finance: new MockFinanceService(),
|
|
||||||
crm: new MockCrmService(),
|
|
||||||
metadata: new MockMetadataService(),
|
|
||||||
audit: new MockAuditService(),
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getServiceContainer(): ServiceContainer {
|
|
||||||
if (!currentContainer) {
|
|
||||||
currentContainer = createDefaultContainer();
|
|
||||||
}
|
|
||||||
return currentContainer;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function setServiceContainer(container: ServiceContainer): void {
|
|
||||||
currentContainer = container;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function resetServiceContainer(): void {
|
|
||||||
currentContainer = createDefaultContainer();
|
|
||||||
}
|
|
||||||
|
|
||||||
// Typed Service Hooks (Composition Root Accessors)
|
|
||||||
export function useServiceContainer(): ServiceContainer {
|
|
||||||
return getServiceContainer();
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCommerceService(): ICommerceService {
|
|
||||||
return getServiceContainer().commerce;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useInventoryService(): IInventoryService {
|
|
||||||
return getServiceContainer().inventory;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useFinanceService(): IFinanceService {
|
|
||||||
return getServiceContainer().finance;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useCrmService(): ICrmService {
|
|
||||||
return getServiceContainer().crm;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useMetadataService(): IMetadataService {
|
|
||||||
return getServiceContainer().metadata;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useAuditService(): IAuditService {
|
|
||||||
return getServiceContainer().audit;
|
|
||||||
}
|
|
||||||
|
|
@ -19,7 +19,6 @@ export interface Payment extends BaseEntity {
|
||||||
method: 'card' | 'cash' | 'online' | 'bank_transfer';
|
method: 'card' | 'cash' | 'online' | 'bank_transfer';
|
||||||
status: 'completed' | 'pending' | 'failed' | 'refunded';
|
status: 'completed' | 'pending' | 'failed' | 'refunded';
|
||||||
transactionId?: string;
|
transactionId?: string;
|
||||||
idempotencyKey?: string;
|
|
||||||
paidAt: string;
|
paidAt: string;
|
||||||
recipientAccount?: string;
|
recipientAccount?: string;
|
||||||
}
|
}
|
||||||
|
|
@ -71,26 +70,7 @@ export interface ICommerceService {
|
||||||
updateOrderStatus(orderId: string, status: Order['status']): Promise<Order>;
|
updateOrderStatus(orderId: string, status: Order['status']): Promise<Order>;
|
||||||
confirmOrder(orderId: string, warehouseId?: string): Promise<Order>;
|
confirmOrder(orderId: string, warehouseId?: string): Promise<Order>;
|
||||||
cancelOrder(orderId: string): Promise<Order>;
|
cancelOrder(orderId: string): Promise<Order>;
|
||||||
createPayment(
|
createPayment(orderId: string, amount: MoneyAmount, method: Payment['method'], autoRecordFinance?: boolean): Promise<Payment>;
|
||||||
orderId: string,
|
|
||||||
amount: MoneyAmount,
|
|
||||||
method: Payment['method'],
|
|
||||||
autoRecordFinance?: boolean,
|
|
||||||
idempotencyKey?: string
|
|
||||||
): Promise<Payment>;
|
|
||||||
createShipment(orderId: string, warehouseId: string, items?: ShipmentItem[]): Promise<Shipment>;
|
createShipment(orderId: string, warehouseId: string, items?: ShipmentItem[]): Promise<Shipment>;
|
||||||
createReturn(
|
processReturn(orderId: string, reason: string, items: ReturnItem[], warehouseId?: string, autoRecordFinance?: boolean): Promise<Return>;
|
||||||
orderId: string,
|
|
||||||
reason: string,
|
|
||||||
items: ReturnItem[],
|
|
||||||
warehouseId?: string,
|
|
||||||
autoRecordFinance?: boolean
|
|
||||||
): Promise<Return>;
|
|
||||||
processReturn(
|
|
||||||
orderId: string,
|
|
||||||
reason: string,
|
|
||||||
items: ReturnItem[],
|
|
||||||
warehouseId?: string,
|
|
||||||
autoRecordFinance?: boolean
|
|
||||||
): Promise<Return>;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -41,5 +41,5 @@ export interface ICrmService {
|
||||||
activities: IRepository<Activity>;
|
activities: IRepository<Activity>;
|
||||||
notes: IRepository<Note>;
|
notes: IRepository<Note>;
|
||||||
getTimeline(entityType: string, entityId: string): Promise<TimelineItem[]>;
|
getTimeline(entityType: string, entityId: string): Promise<TimelineItem[]>;
|
||||||
convertLeadToDeal(leadId: string, dealTitle?: string): Promise<{ deal: Deal; contact?: Contact }>;
|
convertLeadToDeal(leadId: string, dealTitle: string): Promise<{ deal: Deal; contact?: Contact }>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -29,8 +29,8 @@ export interface CashFlowTransaction extends BaseEntity {
|
||||||
amount: MoneyAmount;
|
amount: MoneyAmount;
|
||||||
accountId: string;
|
accountId: string;
|
||||||
accountName: string;
|
accountName: string;
|
||||||
referenceType: 'order_payment' | 'order_return' | 'manual';
|
referenceType?: 'order_payment' | 'order_return' | 'manual';
|
||||||
referenceId: string;
|
referenceId?: string;
|
||||||
description: string;
|
description: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
@ -43,23 +43,12 @@ export interface PnLMetrics {
|
||||||
operatingExpenses: MoneyAmount;
|
operatingExpenses: MoneyAmount;
|
||||||
netProfit: MoneyAmount;
|
netProfit: MoneyAmount;
|
||||||
operatingMarginPercent: number;
|
operatingMarginPercent: number;
|
||||||
reportDisclaimer: 'Демонстрационная модель расчёта';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IFinanceService {
|
export interface IFinanceService {
|
||||||
accounts: IRepository<Account>;
|
accounts: IRepository<Account>;
|
||||||
transactions: IRepository<CashFlowTransaction>;
|
transactions: IRepository<CashFlowTransaction>;
|
||||||
getPnLMetrics(period?: string, startDate?: string, endDate?: string): Promise<PnLMetrics>;
|
getPnLMetrics(period?: string): Promise<PnLMetrics>;
|
||||||
createTransactionFromPayment(
|
createTransactionFromPayment(paymentId: string, orderId: string, amount: MoneyAmount): Promise<CashFlowTransaction>;
|
||||||
paymentId: string,
|
createTransactionFromReturn(returnId: string, orderId: string, amount: MoneyAmount): Promise<CashFlowTransaction>;
|
||||||
orderId: string,
|
|
||||||
amount: MoneyAmount,
|
|
||||||
accountId?: string
|
|
||||||
): Promise<CashFlowTransaction>;
|
|
||||||
createTransactionFromReturn(
|
|
||||||
returnId: string,
|
|
||||||
orderId: string,
|
|
||||||
amount: MoneyAmount,
|
|
||||||
accountId?: string
|
|
||||||
): Promise<CashFlowTransaction>;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,21 +2,6 @@ import { Warehouse } from '../../entities/inventory/warehouse';
|
||||||
import { BaseEntity, MoneyAmount } from '../../entities/types';
|
import { BaseEntity, MoneyAmount } from '../../entities/types';
|
||||||
import { IRepository } from './repository';
|
import { IRepository } from './repository';
|
||||||
|
|
||||||
export class InsufficientStockError extends Error {
|
|
||||||
constructor(
|
|
||||||
public readonly productId: string,
|
|
||||||
public readonly requested: number,
|
|
||||||
public readonly available: number,
|
|
||||||
message?: string
|
|
||||||
) {
|
|
||||||
super(
|
|
||||||
message ||
|
|
||||||
`Недостаточно товара на складе (ID: ${productId}): запрошено ${requested}, доступно ${available}`
|
|
||||||
);
|
|
||||||
this.name = 'InsufficientStockError';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface StockItem extends BaseEntity {
|
export interface StockItem extends BaseEntity {
|
||||||
warehouseId: string;
|
warehouseId: string;
|
||||||
warehouseName: string;
|
warehouseName: string;
|
||||||
|
|
@ -30,21 +15,15 @@ export interface StockItem extends BaseEntity {
|
||||||
minThreshold?: number;
|
minThreshold?: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
export type StockMovementType = 'receipt' | 'shipment' | 'adjustment' | 'return' | 'transfer' | 'loss';
|
|
||||||
|
|
||||||
export interface StockMovement extends BaseEntity {
|
export interface StockMovement extends BaseEntity {
|
||||||
type: StockMovementType;
|
type: 'receipt' | 'shipment' | 'transfer' | 'adjustment' | 'loss';
|
||||||
productId: string;
|
productId: string;
|
||||||
productName: string;
|
productName: string;
|
||||||
sku: string;
|
sku: string;
|
||||||
sourceWarehouseId?: string;
|
sourceWarehouseId?: string;
|
||||||
targetWarehouseId?: string;
|
targetWarehouseId?: string;
|
||||||
warehouseId?: string;
|
|
||||||
quantity: number;
|
quantity: number;
|
||||||
referenceNumber?: string;
|
referenceNumber?: string;
|
||||||
orderId?: string;
|
|
||||||
orderLineId?: string;
|
|
||||||
reason?: string;
|
|
||||||
notes?: string;
|
notes?: string;
|
||||||
timestamp: string;
|
timestamp: string;
|
||||||
}
|
}
|
||||||
|
|
@ -70,12 +49,10 @@ export interface GoodsReceipt extends BaseEntity {
|
||||||
|
|
||||||
export interface StockReservation extends BaseEntity {
|
export interface StockReservation extends BaseEntity {
|
||||||
orderId: string;
|
orderId: string;
|
||||||
orderLineId?: string;
|
|
||||||
warehouseId: string;
|
warehouseId: string;
|
||||||
productId: string;
|
productId: string;
|
||||||
quantity: number;
|
quantity: number;
|
||||||
status: 'active' | 'fulfilled' | 'cancelled';
|
status: 'active' | 'fulfilled' | 'cancelled';
|
||||||
reason?: string;
|
|
||||||
expiresAt?: string;
|
expiresAt?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -86,25 +63,9 @@ export interface IInventoryService {
|
||||||
receipts: IRepository<GoodsReceipt>;
|
receipts: IRepository<GoodsReceipt>;
|
||||||
reservations: IRepository<StockReservation>;
|
reservations: IRepository<StockReservation>;
|
||||||
adjustStock(warehouseId: string, productId: string, delta: number, reason: string): Promise<StockItem>;
|
adjustStock(warehouseId: string, productId: string, delta: number, reason: string): Promise<StockItem>;
|
||||||
reserveStock(
|
reserveStock(orderId: string, warehouseId: string, productId: string, quantity: number): Promise<StockReservation>;
|
||||||
orderId: string,
|
|
||||||
warehouseId: string,
|
|
||||||
productId: string,
|
|
||||||
quantity: number,
|
|
||||||
orderLineId?: string,
|
|
||||||
reason?: string
|
|
||||||
): Promise<StockReservation>;
|
|
||||||
cancelOrderReservations(orderId: string): Promise<void>;
|
cancelOrderReservations(orderId: string): Promise<void>;
|
||||||
fulfillOrderReservations(
|
fulfillOrderReservations(orderId: string, warehouseId: string, items?: { productId: string; name: string; sku: string; quantity: number }[]): Promise<void>;
|
||||||
orderId: string,
|
|
||||||
warehouseId: string,
|
|
||||||
items?: { productId: string; name: string; sku: string; quantity: number }[]
|
|
||||||
): Promise<void>;
|
|
||||||
receiveGoods(receiptId: string): Promise<GoodsReceipt>;
|
receiveGoods(receiptId: string): Promise<GoodsReceipt>;
|
||||||
createAndReceiveGoods(
|
createAndReceiveGoods(warehouseId: string, receiptNumber: string, supplierName: string, items: GoodsReceiptItem[]): Promise<GoodsReceipt>;
|
||||||
warehouseId: string,
|
|
||||||
receiptNumber: string,
|
|
||||||
supplierName: string,
|
|
||||||
items: GoodsReceiptItem[]
|
|
||||||
): Promise<GoodsReceipt>;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -84,8 +84,6 @@ export function useFinance() {
|
||||||
amount,
|
amount,
|
||||||
accountId,
|
accountId,
|
||||||
accountName: accName,
|
accountName: accName,
|
||||||
referenceType: 'manual',
|
|
||||||
referenceId: `manual_${Date.now()}`,
|
|
||||||
description,
|
description,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,4 +0,0 @@
|
||||||
export * from './container';
|
|
||||||
export * from './contracts';
|
|
||||||
export * from './hooks';
|
|
||||||
export * from './storage/indexedDb';
|
|
||||||
|
|
@ -12,7 +12,6 @@ import {
|
||||||
getRecordsByEntity,
|
getRecordsByEntity,
|
||||||
putStoreItem,
|
putStoreItem,
|
||||||
RecordEntity,
|
RecordEntity,
|
||||||
generateMonotonicNumber,
|
|
||||||
} from '../storage/indexedDb';
|
} from '../storage/indexedDb';
|
||||||
import { ulid } from '../../shared/lib/ulid';
|
import { ulid } from '../../shared/lib/ulid';
|
||||||
import { useDemoStore } from '../../stores/demo';
|
import { useDemoStore } from '../../stores/demo';
|
||||||
|
|
@ -99,18 +98,8 @@ export class BaseMockRepository<T extends BaseEntity> implements IRepository<T>
|
||||||
const userId = ctx.actorId || useDemoStore.getState().currentRoleId || 'demo_user';
|
const userId = ctx.actorId || useDemoStore.getState().currentRoleId || 'demo_user';
|
||||||
|
|
||||||
const id = ulid();
|
const id = ulid();
|
||||||
const entityData = { ...data } as Record<string, unknown>;
|
|
||||||
|
|
||||||
if (this.entityName === 'order' && !entityData.orderNumber) {
|
|
||||||
entityData.orderNumber = await generateMonotonicNumber('ORD', 4, ctx.tenantId);
|
|
||||||
} else if (this.entityName === 'invoice' && !entityData.invoiceNumber) {
|
|
||||||
entityData.invoiceNumber = await generateMonotonicNumber('INV', 4, ctx.tenantId);
|
|
||||||
} else if (this.entityName === 'lead' && !entityData.leadNumber) {
|
|
||||||
entityData.leadNumber = await generateMonotonicNumber('LEAD', 4, ctx.tenantId);
|
|
||||||
}
|
|
||||||
|
|
||||||
const entity = {
|
const entity = {
|
||||||
...entityData,
|
...data,
|
||||||
id,
|
id,
|
||||||
entityType: this.entityName,
|
entityType: this.entityName,
|
||||||
tenant_id: ctx.tenantId,
|
tenant_id: ctx.tenantId,
|
||||||
|
|
|
||||||
|
|
@ -8,26 +8,12 @@ import {
|
||||||
ReturnItem,
|
ReturnItem,
|
||||||
} from '../contracts/commerce';
|
} from '../contracts/commerce';
|
||||||
import { IRepository } from '../contracts/repository';
|
import { IRepository } from '../contracts/repository';
|
||||||
import { Order, OrderStatus } from '../../entities/commerce/order';
|
import { Order } from '../../entities/commerce/order';
|
||||||
import { Product, ProductCategory } from '../../entities/commerce/product';
|
import { Product, ProductCategory } from '../../entities/commerce/product';
|
||||||
import { MoneyAmount } from '../../entities/types';
|
import { MoneyAmount } from '../../entities/types';
|
||||||
import { BaseMockRepository } from './baseRepository';
|
import { BaseMockRepository } from './baseRepository';
|
||||||
import { ulid } from '../../shared/lib/ulid';
|
import { ulid } from '../../shared/lib/ulid';
|
||||||
import { getServices } from './index';
|
import { getServices } from './index';
|
||||||
import { InsufficientStockError } from '../contracts/inventory';
|
|
||||||
|
|
||||||
const ALLOWED_ORDER_TRANSITIONS: Record<OrderStatus, OrderStatus[]> = {
|
|
||||||
draft: ['confirmed', 'cancelled'],
|
|
||||||
confirmed: ['preparing', 'processing', 'cancelled'],
|
|
||||||
preparing: ['ready_for_shipment', 'ready'],
|
|
||||||
processing: ['ready_for_shipment', 'ready'],
|
|
||||||
ready_for_shipment: ['shipped'],
|
|
||||||
ready: ['shipped'],
|
|
||||||
shipped: ['delivered'],
|
|
||||||
delivered: ['completed'],
|
|
||||||
completed: [],
|
|
||||||
cancelled: [],
|
|
||||||
};
|
|
||||||
|
|
||||||
export class MockCommerceService implements ICommerceService {
|
export class MockCommerceService implements ICommerceService {
|
||||||
products: IRepository<Product>;
|
products: IRepository<Product>;
|
||||||
|
|
@ -48,41 +34,8 @@ export class MockCommerceService implements ICommerceService {
|
||||||
this.returns = new BaseMockRepository<Return>('return');
|
this.returns = new BaseMockRepository<Return>('return');
|
||||||
}
|
}
|
||||||
|
|
||||||
async updateOrderStatus(orderId: string, nextStatus: OrderStatus): Promise<Order> {
|
async updateOrderStatus(orderId: string, status: Order['status']): Promise<Order> {
|
||||||
const order = await this.orders.getById(orderId);
|
return this.orders.update(orderId, { status });
|
||||||
if (!order) {
|
|
||||||
throw new Error(`Заказ с ID ${orderId} не найден.`);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (order.status === nextStatus) {
|
|
||||||
return order;
|
|
||||||
}
|
|
||||||
|
|
||||||
const allowed = ALLOWED_ORDER_TRANSITIONS[order.status] || [];
|
|
||||||
if (!allowed.includes(nextStatus)) {
|
|
||||||
throw new Error(
|
|
||||||
`Недопустимый переход статуса заказа: «${order.status}» -> «${nextStatus}». Запрещено нарушать State Machine заказа.`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Direct routing to full operations for confirmed and cancelled
|
|
||||||
if (nextStatus === 'confirmed') {
|
|
||||||
return this.confirmOrder(orderId);
|
|
||||||
}
|
|
||||||
if (nextStatus === 'cancelled') {
|
|
||||||
return this.cancelOrder(orderId);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pre-condition for shipped status: must be paid or B2B credit
|
|
||||||
if (nextStatus === 'shipped') {
|
|
||||||
const isPaid = order.paymentStatus === 'paid' || order.paymentStatus === 'overpaid';
|
|
||||||
const isB2B = order.channel === 'b2b';
|
|
||||||
if (!isPaid && !isB2B) {
|
|
||||||
throw new Error('Отгрузка невозможна: заказ не оплачен (для розницы требуется 100% предоплата)');
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return this.orders.update(orderId, { status: nextStatus });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
async confirmOrder(orderId: string, warehouseId = 'wh_main'): Promise<Order> {
|
async confirmOrder(orderId: string, warehouseId = 'wh_main'): Promise<Order> {
|
||||||
|
|
@ -91,47 +44,11 @@ export class MockCommerceService implements ICommerceService {
|
||||||
throw new Error(`Заказ с ID ${orderId} не найден.`);
|
throw new Error(`Заказ с ID ${orderId} не найден.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Idempotency: if already confirmed, do not duplicate reservations
|
// Reserve stock in inventory
|
||||||
if (order.status === 'confirmed') {
|
|
||||||
return order;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validation: only draft orders can be confirmed
|
|
||||||
if (order.status !== 'draft') {
|
|
||||||
throw new Error(
|
|
||||||
`Подтверждать можно только заказ в статусе черновика (draft). Текущий статус: «${order.status}»`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const inventory = getServices().inventory;
|
const inventory = getServices().inventory;
|
||||||
if (inventory && order.items && order.items.length > 0) {
|
if (inventory && order.items) {
|
||||||
// Phase 1: Pre-flight availability check for ALL items (All-or-Nothing)
|
|
||||||
for (const item of order.items) {
|
for (const item of order.items) {
|
||||||
const stockList = await inventory.stockItems.list({
|
await inventory.reserveStock(orderId, warehouseId, item.productId, item.quantity);
|
||||||
filter: { warehouseId, productId: item.productId },
|
|
||||||
});
|
|
||||||
const stockItem = stockList.items[0];
|
|
||||||
const available = stockItem ? stockItem.available : 0;
|
|
||||||
if (available < item.quantity) {
|
|
||||||
throw new InsufficientStockError(
|
|
||||||
item.productId,
|
|
||||||
item.quantity,
|
|
||||||
available,
|
|
||||||
`Недостаточно товара «${item.name || item.productId}» на складе: требуется ${item.quantity}, доступно ${available}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Phase 2: All items are available, perform atomic reservation
|
|
||||||
for (const item of order.items) {
|
|
||||||
await inventory.reserveStock(
|
|
||||||
orderId,
|
|
||||||
warehouseId,
|
|
||||||
item.productId,
|
|
||||||
item.quantity,
|
|
||||||
item.id,
|
|
||||||
`Резервирование по заказу #${order.orderNumber}`
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -144,17 +61,7 @@ export class MockCommerceService implements ICommerceService {
|
||||||
throw new Error(`Заказ с ID ${orderId} не найден.`);
|
throw new Error(`Заказ с ID ${orderId} не найден.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (order.status === 'cancelled') {
|
// Release any active warehouse reservations
|
||||||
return order;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (order.status !== 'draft' && order.status !== 'confirmed') {
|
|
||||||
throw new Error(
|
|
||||||
`Отменить можно только черновик или подтверждённый заказ (draft, confirmed). Текущий статус: «${order.status}»`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Release only active warehouse reservations specifically for this order
|
|
||||||
const inventory = getServices().inventory;
|
const inventory = getServices().inventory;
|
||||||
if (inventory) {
|
if (inventory) {
|
||||||
await inventory.cancelOrderReservations(orderId);
|
await inventory.cancelOrderReservations(orderId);
|
||||||
|
|
@ -167,33 +74,13 @@ export class MockCommerceService implements ICommerceService {
|
||||||
orderId: string,
|
orderId: string,
|
||||||
amount: MoneyAmount,
|
amount: MoneyAmount,
|
||||||
method: Payment['method'],
|
method: Payment['method'],
|
||||||
autoRecordFinance = false,
|
autoRecordFinance = false
|
||||||
idempotencyKey?: string
|
|
||||||
): Promise<Payment> {
|
): Promise<Payment> {
|
||||||
const order = await this.orders.getById(orderId);
|
const order = await this.orders.getById(orderId);
|
||||||
if (!order) {
|
if (!order) {
|
||||||
throw new Error(`Заказ с ID ${orderId} не найден.`);
|
throw new Error(`Заказ с ID ${orderId} не найден.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Validation: amount.minor > 0 and currency match
|
|
||||||
if (amount.minor <= 0) {
|
|
||||||
throw new Error(`Сумма платежа должна быть больше нуля (получено: ${amount.minor})`);
|
|
||||||
}
|
|
||||||
if (amount.currency !== order.total.currency) {
|
|
||||||
throw new Error(
|
|
||||||
`Валюта платежа (${amount.currency}) не совпадает с валютой заказа (${order.total.currency})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Idempotency check by idempotencyKey
|
|
||||||
if (idempotencyKey) {
|
|
||||||
const existingPayments = await this.payments.list({ filter: { orderId } });
|
|
||||||
const found = existingPayments.items.find((p) => p.idempotencyKey === idempotencyKey);
|
|
||||||
if (found) {
|
|
||||||
return found;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
const payment = await this.payments.create({
|
const payment = await this.payments.create({
|
||||||
orderId,
|
orderId,
|
||||||
|
|
@ -201,49 +88,18 @@ export class MockCommerceService implements ICommerceService {
|
||||||
amount,
|
amount,
|
||||||
method,
|
method,
|
||||||
status: 'completed',
|
status: 'completed',
|
||||||
idempotencyKey,
|
|
||||||
paidAt: now,
|
paidAt: now,
|
||||||
transactionId: `TXN-${ulid().slice(0, 10)}`,
|
transactionId: `TXN-${ulid().slice(0, 10)}`,
|
||||||
});
|
});
|
||||||
|
|
||||||
// 3. Compute cumulative paid amount to update paymentStatus
|
// Update order payment status
|
||||||
const orderPayments = await this.payments.list({ filter: { orderId, status: 'completed' } });
|
await this.orders.update(orderId, { paymentStatus: 'paid' });
|
||||||
const cumulativePaidMinor = orderPayments.items.reduce((sum, p) => sum + p.amount.minor, 0);
|
|
||||||
|
|
||||||
let nextPaymentStatus: Order['paymentStatus'];
|
// Automatically record finance transaction if requested
|
||||||
if (cumulativePaidMinor <= 0) {
|
|
||||||
nextPaymentStatus = 'unpaid';
|
|
||||||
} else if (cumulativePaidMinor < order.total.minor) {
|
|
||||||
nextPaymentStatus = 'partially_paid';
|
|
||||||
} else if (cumulativePaidMinor === order.total.minor) {
|
|
||||||
nextPaymentStatus = 'paid';
|
|
||||||
} else {
|
|
||||||
nextPaymentStatus = 'overpaid';
|
|
||||||
}
|
|
||||||
|
|
||||||
await this.orders.update(orderId, { paymentStatus: nextPaymentStatus });
|
|
||||||
|
|
||||||
// 4. Atomic finance recording: if finance fails, rollback payment creation
|
|
||||||
if (autoRecordFinance) {
|
if (autoRecordFinance) {
|
||||||
const finance = getServices().finance;
|
const finance = getServices().finance;
|
||||||
if (finance) {
|
if (finance) {
|
||||||
try {
|
await finance.createTransactionFromPayment(payment.id, orderId, amount);
|
||||||
await finance.createTransactionFromPayment(payment.id, orderId, amount);
|
|
||||||
} catch (financeErr) {
|
|
||||||
await this.payments.delete(payment.id);
|
|
||||||
const remainingPayments = await this.payments.list({ filter: { orderId, status: 'completed' } });
|
|
||||||
const remainingSum = remainingPayments.items.reduce((sum, p) => sum + p.amount.minor, 0);
|
|
||||||
const restoredStatus: Order['paymentStatus'] =
|
|
||||||
remainingSum <= 0
|
|
||||||
? 'unpaid'
|
|
||||||
: remainingSum < order.total.minor
|
|
||||||
? 'partially_paid'
|
|
||||||
: remainingSum === order.total.minor
|
|
||||||
? 'paid'
|
|
||||||
: 'overpaid';
|
|
||||||
await this.orders.update(orderId, { paymentStatus: restoredStatus });
|
|
||||||
throw financeErr;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -260,22 +116,6 @@ export class MockCommerceService implements ICommerceService {
|
||||||
throw new Error(`Заказ с ID ${orderId} не найден.`);
|
throw new Error(`Заказ с ID ${orderId} не найден.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (order.status === 'draft') {
|
|
||||||
throw new Error('Нельзя отгрузить черновик заказа (draft). Сначала подтвердите заказ.');
|
|
||||||
}
|
|
||||||
if (order.status === 'cancelled') {
|
|
||||||
throw new Error('Нельзя отгрузить отмененный заказ (cancelled).');
|
|
||||||
}
|
|
||||||
if (order.status === 'completed') {
|
|
||||||
throw new Error('Заказ уже полностью выполнен (completed).');
|
|
||||||
}
|
|
||||||
|
|
||||||
const isPaid = order.paymentStatus === 'paid' || order.paymentStatus === 'overpaid';
|
|
||||||
const isB2B = order.channel === 'b2b';
|
|
||||||
if (!isPaid && !isB2B) {
|
|
||||||
throw new Error('Отгрузка невозможна: заказ не оплачен (для розницы требуется 100% предоплата)');
|
|
||||||
}
|
|
||||||
|
|
||||||
const shipmentItems: ShipmentItem[] =
|
const shipmentItems: ShipmentItem[] =
|
||||||
items ||
|
items ||
|
||||||
order.items.map((i) => ({
|
order.items.map((i) => ({
|
||||||
|
|
@ -298,7 +138,7 @@ export class MockCommerceService implements ICommerceService {
|
||||||
|
|
||||||
await this.orders.update(orderId, { fulfillmentStatus: 'fulfilled', status: 'shipped' });
|
await this.orders.update(orderId, { fulfillmentStatus: 'fulfilled', status: 'shipped' });
|
||||||
|
|
||||||
// Deduct physical stock (onHand) and fulfill active reservations in inventory
|
// Deduct reserved stock and physical stock in inventory
|
||||||
const inventory = getServices().inventory;
|
const inventory = getServices().inventory;
|
||||||
if (inventory) {
|
if (inventory) {
|
||||||
await inventory.fulfillOrderReservations(orderId, warehouseId, shipmentItems);
|
await inventory.fulfillOrderReservations(orderId, warehouseId, shipmentItems);
|
||||||
|
|
@ -307,56 +147,18 @@ export class MockCommerceService implements ICommerceService {
|
||||||
return shipment;
|
return shipment;
|
||||||
}
|
}
|
||||||
|
|
||||||
async createReturn(
|
async processReturn(
|
||||||
orderId: string,
|
orderId: string,
|
||||||
reason: string,
|
reason: string,
|
||||||
items: ReturnItem[],
|
items: ReturnItem[],
|
||||||
warehouseId = 'wh_main',
|
warehouseId = 'wh_main',
|
||||||
autoRecordFinance = true
|
autoRecordFinance = false
|
||||||
): Promise<Return> {
|
): Promise<Return> {
|
||||||
const order = await this.orders.getById(orderId);
|
const order = await this.orders.getById(orderId);
|
||||||
if (!order) {
|
if (!order) {
|
||||||
throw new Error(`Заказ с ID ${orderId} не найден.`);
|
throw new Error(`Заказ с ID ${orderId} не найден.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Return is strictly allowed only for shipped, delivered, or completed orders
|
|
||||||
const isEligibleForReturn =
|
|
||||||
order.status === 'shipped' ||
|
|
||||||
order.status === 'delivered' ||
|
|
||||||
order.status === 'completed' ||
|
|
||||||
order.fulfillmentStatus === 'fulfilled';
|
|
||||||
|
|
||||||
if (!isEligibleForReturn) {
|
|
||||||
throw new Error(
|
|
||||||
`Возврат разрешён только по отгруженным или доставленным заказам (shipped, delivered, completed). Текущий статус: «${order.status}»`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Validate quantities against fulfilled quantities minus already returned quantities
|
|
||||||
const previousReturns = await this.returns.list({ filter: { orderId } });
|
|
||||||
|
|
||||||
for (const item of items) {
|
|
||||||
const orderItem = order.items.find((i) => i.productId === item.productId);
|
|
||||||
if (!orderItem) {
|
|
||||||
throw new Error(`Товар с ID «${item.productId}» не найден в позициях заказа`);
|
|
||||||
}
|
|
||||||
|
|
||||||
let alreadyReturnedQty = 0;
|
|
||||||
for (const pr of previousReturns.items) {
|
|
||||||
const prevItem = pr.items.find((pi) => pi.productId === item.productId);
|
|
||||||
if (prevItem) {
|
|
||||||
alreadyReturnedQty += prevItem.quantity;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const maxAllowedReturn = orderItem.quantity - alreadyReturnedQty;
|
|
||||||
if (item.quantity > maxAllowedReturn) {
|
|
||||||
throw new Error(
|
|
||||||
`Количество возврата (${item.quantity}) превышает отгруженное доступное для возврата количество (${maxAllowedReturn}) для товара «${item.name || item.productId}»`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalMinor = items.reduce((sum, item) => sum + item.refundAmount.minor, 0);
|
const totalMinor = items.reduce((sum, item) => sum + item.refundAmount.minor, 0);
|
||||||
|
|
||||||
const ret = await this.returns.create({
|
const ret = await this.returns.create({
|
||||||
|
|
@ -373,7 +175,7 @@ export class MockCommerceService implements ICommerceService {
|
||||||
paymentStatus: 'refunded',
|
paymentStatus: 'refunded',
|
||||||
});
|
});
|
||||||
|
|
||||||
// Restore physical stock onHand in inventory
|
// Restore physical stock in inventory
|
||||||
const inventory = getServices().inventory;
|
const inventory = getServices().inventory;
|
||||||
if (inventory) {
|
if (inventory) {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
|
|
@ -386,7 +188,7 @@ export class MockCommerceService implements ICommerceService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Automatically record refund transaction in finance
|
// Automatically record refund transaction in finance if requested
|
||||||
if (autoRecordFinance) {
|
if (autoRecordFinance) {
|
||||||
const finance = getServices().finance;
|
const finance = getServices().finance;
|
||||||
if (finance) {
|
if (finance) {
|
||||||
|
|
@ -396,14 +198,4 @@ export class MockCommerceService implements ICommerceService {
|
||||||
|
|
||||||
return ret;
|
return ret;
|
||||||
}
|
}
|
||||||
|
|
||||||
async processReturn(
|
|
||||||
orderId: string,
|
|
||||||
reason: string,
|
|
||||||
items: ReturnItem[],
|
|
||||||
warehouseId = 'wh_main',
|
|
||||||
autoRecordFinance = true
|
|
||||||
): Promise<Return> {
|
|
||||||
return this.createReturn(orderId, reason, items, warehouseId, autoRecordFinance);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ export class MockCrmService implements ICrmService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// 3. If contact, company, or deal, fetch linked deals and orders
|
// 3. If contact or company, fetch linked deals
|
||||||
if (entityType === 'contact' || entityType === 'company') {
|
if (entityType === 'contact' || entityType === 'company') {
|
||||||
const dealsRes = await this.deals.list({
|
const dealsRes = await this.deals.list({
|
||||||
filter: entityType === 'contact' ? { contactId: entityId } : { companyId: entityId },
|
filter: entityType === 'contact' ? { contactId: entityId } : { companyId: entityId },
|
||||||
|
|
@ -68,29 +68,7 @@ export class MockCrmService implements ICrmService {
|
||||||
type: 'deal_created',
|
type: 'deal_created',
|
||||||
title: `Сделка: ${d.title}`,
|
title: `Сделка: ${d.title}`,
|
||||||
description: `Этап: ${d.stage}, сумма: ${(d.amount.minor / 100).toLocaleString('ru-RU')} ₽`,
|
description: `Этап: ${d.stage}, сумма: ${(d.amount.minor / 100).toLocaleString('ru-RU')} ₽`,
|
||||||
actor: d.assignedTo || 'Отдел продаж',
|
actor: 'Отдел продаж',
|
||||||
});
|
|
||||||
if (d.orderId) {
|
|
||||||
timeline.push({
|
|
||||||
id: `ord_${d.orderId}`,
|
|
||||||
timestamp: d.audit?.updatedAt || d.audit?.createdAt || new Date().toISOString(),
|
|
||||||
type: 'order_created',
|
|
||||||
title: `Заказ по сделке: ${d.title}`,
|
|
||||||
description: `Связанный заказ ID: ${d.orderId}`,
|
|
||||||
actor: 'Коммерция',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (entityType === 'deal') {
|
|
||||||
const deal = await this.deals.getById(entityId);
|
|
||||||
if (deal && deal.orderId) {
|
|
||||||
timeline.push({
|
|
||||||
id: `ord_${deal.orderId}`,
|
|
||||||
timestamp: deal.audit?.updatedAt || deal.audit?.createdAt || new Date().toISOString(),
|
|
||||||
type: 'order_created',
|
|
||||||
title: `Создан связанный заказ`,
|
|
||||||
description: `Заказ ID: ${deal.orderId} успешно сформирован в Коммерции`,
|
|
||||||
actor: 'Коммерция',
|
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -99,108 +77,44 @@ export class MockCrmService implements ICrmService {
|
||||||
return timeline.sort((a, b) => (b.timestamp > a.timestamp ? 1 : -1));
|
return timeline.sort((a, b) => (b.timestamp > a.timestamp ? 1 : -1));
|
||||||
}
|
}
|
||||||
|
|
||||||
async convertLeadToDeal(
|
async convertLeadToDeal(leadId: string, dealTitle: string): Promise<{ deal: Deal; contact?: Contact }> {
|
||||||
leadId: string,
|
|
||||||
dealTitle?: string
|
|
||||||
): Promise<{ deal: Deal; contact?: Contact }> {
|
|
||||||
const lead = await this.leads.getById(leadId);
|
const lead = await this.leads.getById(leadId);
|
||||||
if (!lead) {
|
if (!lead) {
|
||||||
throw new Error(`Лид с ID ${leadId} не найден.`);
|
throw new Error(`Лид с ID ${leadId} не найден.`);
|
||||||
}
|
}
|
||||||
|
|
||||||
// 1. Idempotency: if already converted, return existing records without duplicating
|
// 1. Create or match contact
|
||||||
if (lead.status === 'converted' && lead.convertedDealId) {
|
const parts = (lead.contactName || 'Клиент').split(' ');
|
||||||
const existingDeal = await this.deals.getById(lead.convertedDealId);
|
const firstName = parts[0];
|
||||||
const existingContact = lead.convertedContactId
|
const lastName = parts.slice(1).join(' ') || 'Клиент';
|
||||||
? await this.contacts.getById(lead.convertedContactId)
|
|
||||||
: undefined;
|
|
||||||
|
|
||||||
if (existingDeal) {
|
const contact = await this.contacts.create({
|
||||||
return { deal: existingDeal, contact: existingContact || undefined };
|
firstName,
|
||||||
}
|
lastName,
|
||||||
}
|
phone: lead.phone,
|
||||||
|
email: lead.email,
|
||||||
|
roles: ['client'],
|
||||||
|
status: 'active',
|
||||||
|
});
|
||||||
|
|
||||||
// 2. Resolve company: match existing by name or create
|
// 2. Create deal
|
||||||
let companyId = lead.companyId;
|
|
||||||
if (!companyId && lead.companyName && lead.companyName.trim()) {
|
|
||||||
const companiesList = await this.companies.list({ pageSize: 1000 });
|
|
||||||
const foundCompany = companiesList.items.find(
|
|
||||||
(c) => c.name.trim().toLowerCase() === lead.companyName!.trim().toLowerCase()
|
|
||||||
);
|
|
||||||
if (foundCompany) {
|
|
||||||
companyId = foundCompany.id;
|
|
||||||
} else {
|
|
||||||
const newCompany = await this.companies.create({
|
|
||||||
name: lead.companyName.trim(),
|
|
||||||
status: 'active',
|
|
||||||
roles: ['client'],
|
|
||||||
});
|
|
||||||
companyId = newCompany.id;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Resolve contact: match by clean email or clean phone first
|
|
||||||
let contact: Contact | null = null;
|
|
||||||
const cleanEmail = lead.email ? lead.email.trim().toLowerCase() : '';
|
|
||||||
const cleanPhone = lead.phone ? lead.phone.replace(/\D/g, '') : '';
|
|
||||||
|
|
||||||
if (cleanEmail || (cleanPhone && cleanPhone.length >= 6)) {
|
|
||||||
const contactsList = await this.contacts.list({ pageSize: 1000 });
|
|
||||||
contact =
|
|
||||||
contactsList.items.find((c) => {
|
|
||||||
if (cleanEmail && c.email && c.email.trim().toLowerCase() === cleanEmail) {
|
|
||||||
return true;
|
|
||||||
}
|
|
||||||
if (cleanPhone && c.phone) {
|
|
||||||
const cp = c.phone.replace(/\D/g, '');
|
|
||||||
if (cp && cp === cleanPhone) return true;
|
|
||||||
}
|
|
||||||
return false;
|
|
||||||
}) || null;
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!contact) {
|
|
||||||
const parts = (lead.contactName || 'Клиент').trim().split(/\s+/);
|
|
||||||
const firstName = parts[0] || 'Клиент';
|
|
||||||
const lastName = parts.slice(1).join(' ') || 'Клиент';
|
|
||||||
|
|
||||||
contact = await this.contacts.create({
|
|
||||||
firstName,
|
|
||||||
lastName,
|
|
||||||
phone: lead.phone,
|
|
||||||
email: lead.email,
|
|
||||||
companyId,
|
|
||||||
roles: ['client', 'lead'],
|
|
||||||
assignedTo: lead.assignedTo || lead.assignedUserId,
|
|
||||||
status: 'active',
|
|
||||||
});
|
|
||||||
} else if (companyId && !contact.companyId) {
|
|
||||||
await this.contacts.update(contact.id, { companyId });
|
|
||||||
contact = (await this.contacts.getById(contact.id)) || contact;
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Create deal
|
|
||||||
const deal = await this.deals.create({
|
const deal = await this.deals.create({
|
||||||
title: dealTitle?.trim() || lead.title || `Сделка: ${lead.contactName}`,
|
title: dealTitle || lead.title,
|
||||||
pipelineId: 'pipe_default',
|
pipelineId: 'pipe_default',
|
||||||
stage: 'proposal',
|
stage: 'proposal',
|
||||||
amount: lead.estimatedValue || { minor: 1000000, currency: 'RUB' },
|
amount: lead.estimatedValue || { minor: 1000000, currency: 'RUB' },
|
||||||
probability: 60,
|
probability: 60,
|
||||||
contactId: contact.id,
|
contactId: contact.id,
|
||||||
companyId: companyId || contact.companyId,
|
|
||||||
assignedUserId: lead.assignedUserId,
|
|
||||||
assignedTo: lead.assignedTo || lead.assignedUserId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 5. Mark lead as converted
|
// 3. Mark lead as converted
|
||||||
await this.leads.update(leadId, {
|
await this.leads.update(leadId, {
|
||||||
status: 'converted',
|
status: 'converted',
|
||||||
convertedContactId: contact.id,
|
convertedContactId: contact.id,
|
||||||
convertedDealId: deal.id,
|
convertedDealId: deal.id,
|
||||||
companyId: companyId || lead.companyId,
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// 6. Record note in deal timeline
|
// 4. Add initial note to timeline
|
||||||
await this.notes.create({
|
await this.notes.create({
|
||||||
targetEntityType: 'deal',
|
targetEntityType: 'deal',
|
||||||
targetEntityId: deal.id,
|
targetEntityId: deal.id,
|
||||||
|
|
|
||||||
|
|
@ -18,34 +18,12 @@ export class MockFinanceService implements IFinanceService {
|
||||||
this.transactions = new BaseMockRepository<CashFlowTransaction>('cash_flow_transaction');
|
this.transactions = new BaseMockRepository<CashFlowTransaction>('cash_flow_transaction');
|
||||||
}
|
}
|
||||||
|
|
||||||
async getPnLMetrics(
|
async getPnLMetrics(period = 'current_month'): Promise<PnLMetrics> {
|
||||||
period = 'current_month',
|
|
||||||
startDate?: string,
|
|
||||||
endDate?: string
|
|
||||||
): Promise<PnLMetrics> {
|
|
||||||
const txs = await this.transactions.list({ pageSize: 500 });
|
const txs = await this.transactions.list({ pageSize: 500 });
|
||||||
let filteredTxs = txs.items;
|
|
||||||
|
|
||||||
if (startDate || endDate) {
|
|
||||||
filteredTxs = filteredTxs.filter((tx) => {
|
|
||||||
const txDate = tx.timestamp.slice(0, 10);
|
|
||||||
if (startDate && txDate < startDate.slice(0, 10)) return false;
|
|
||||||
if (endDate && txDate > endDate.slice(0, 10)) return false;
|
|
||||||
return true;
|
|
||||||
});
|
|
||||||
} else if (period && period !== 'all') {
|
|
||||||
if (period === 'current_month') {
|
|
||||||
const currentMonth = new Date().toISOString().slice(0, 7);
|
|
||||||
filteredTxs = filteredTxs.filter((tx) => tx.timestamp.startsWith(currentMonth));
|
|
||||||
} else if (/^\d{4}-\d{2}$/.test(period)) {
|
|
||||||
filteredTxs = filteredTxs.filter((tx) => tx.timestamp.startsWith(period));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
let revenueMinor = 0;
|
let revenueMinor = 0;
|
||||||
let expenseMinor = 0;
|
let expenseMinor = 0;
|
||||||
|
|
||||||
for (const tx of filteredTxs) {
|
for (const tx of txs.items) {
|
||||||
if (tx.type === 'inflow') {
|
if (tx.type === 'inflow') {
|
||||||
revenueMinor += tx.amount.minor;
|
revenueMinor += tx.amount.minor;
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -53,7 +31,7 @@ export class MockFinanceService implements IFinanceService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const cogsMinor = Math.round(revenueMinor * 0.42); // 42% cost of goods sold model
|
const cogsMinor = Math.round(revenueMinor * 0.42); // 42% cost of goods sold
|
||||||
const grossProfitMinor = revenueMinor - cogsMinor;
|
const grossProfitMinor = revenueMinor - cogsMinor;
|
||||||
const operatingExpensesMinor = expenseMinor;
|
const operatingExpensesMinor = expenseMinor;
|
||||||
const netProfitMinor = grossProfitMinor - operatingExpensesMinor;
|
const netProfitMinor = grossProfitMinor - operatingExpensesMinor;
|
||||||
|
|
@ -68,45 +46,24 @@ export class MockFinanceService implements IFinanceService {
|
||||||
operatingExpenses: { minor: operatingExpensesMinor, currency: 'RUB' },
|
operatingExpenses: { minor: operatingExpensesMinor, currency: 'RUB' },
|
||||||
netProfit: { minor: netProfitMinor, currency: 'RUB' },
|
netProfit: { minor: netProfitMinor, currency: 'RUB' },
|
||||||
operatingMarginPercent,
|
operatingMarginPercent,
|
||||||
reportDisclaimer: 'Демонстрационная модель расчёта',
|
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
async createTransactionFromPayment(
|
async createTransactionFromPayment(
|
||||||
paymentId: string,
|
paymentId: string,
|
||||||
orderId: string,
|
orderId: string,
|
||||||
amount: MoneyAmount,
|
amount: MoneyAmount
|
||||||
accountId?: string
|
|
||||||
): Promise<CashFlowTransaction> {
|
): Promise<CashFlowTransaction> {
|
||||||
if (!paymentId || !orderId) {
|
|
||||||
throw new Error('Обязательное основание операции: referenceType="order_payment", paymentId и orderId');
|
|
||||||
}
|
|
||||||
if (amount.minor <= 0) {
|
|
||||||
throw new Error(`Сумма входящей проводки должна быть больше 0 (получено: ${amount.minor})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const accounts = await this.accounts.list();
|
const accounts = await this.accounts.list();
|
||||||
const targetAcc = accountId
|
const defaultAcc = accounts.items.find((a) => a.isDefault) || accounts.items[0];
|
||||||
? accounts.items.find((a) => a.id === accountId)
|
|
||||||
: accounts.items.find((a) => a.isDefault) || accounts.items[0];
|
|
||||||
|
|
||||||
if (!targetAcc) {
|
|
||||||
throw new Error('Расчётный счёт для проведения оплаты не найден.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetAcc.currency !== amount.currency) {
|
|
||||||
throw new Error(
|
|
||||||
`Несовпадение валюты счёта (${targetAcc.currency}) и валюты платежа (${amount.currency})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tx = await this.transactions.create({
|
const tx = await this.transactions.create({
|
||||||
transactionNumber: `CFT-${ulid().slice(0, 8)}`,
|
transactionNumber: `CFT-${ulid().slice(0, 8)}`,
|
||||||
type: 'inflow',
|
type: 'inflow',
|
||||||
category: 'sales_revenue',
|
category: 'sales_revenue',
|
||||||
amount,
|
amount,
|
||||||
accountId: targetAcc.id,
|
accountId: defaultAcc?.id || 'acc_default',
|
||||||
accountName: targetAcc.name,
|
accountName: defaultAcc?.name || 'Основной расчетный счет',
|
||||||
referenceType: 'order_payment',
|
referenceType: 'order_payment',
|
||||||
referenceId: paymentId,
|
referenceId: paymentId,
|
||||||
description: `Оплата по заказу (${orderId.slice(0, 8)}...)`,
|
description: `Оплата по заказу (${orderId.slice(0, 8)}...)`,
|
||||||
|
|
@ -114,12 +71,14 @@ export class MockFinanceService implements IFinanceService {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update account balance
|
// Update account balance
|
||||||
await this.accounts.update(targetAcc.id, {
|
if (defaultAcc) {
|
||||||
balance: {
|
await this.accounts.update(defaultAcc.id, {
|
||||||
minor: targetAcc.balance.minor + amount.minor,
|
balance: {
|
||||||
currency: targetAcc.balance.currency,
|
minor: defaultAcc.balance.minor + amount.minor,
|
||||||
},
|
currency: defaultAcc.balance.currency,
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return tx;
|
return tx;
|
||||||
}
|
}
|
||||||
|
|
@ -127,38 +86,18 @@ export class MockFinanceService implements IFinanceService {
|
||||||
async createTransactionFromReturn(
|
async createTransactionFromReturn(
|
||||||
returnId: string,
|
returnId: string,
|
||||||
orderId: string,
|
orderId: string,
|
||||||
amount: MoneyAmount,
|
amount: MoneyAmount
|
||||||
accountId?: string
|
|
||||||
): Promise<CashFlowTransaction> {
|
): Promise<CashFlowTransaction> {
|
||||||
if (!returnId || !orderId) {
|
|
||||||
throw new Error('Обязательное основание операции: referenceType="order_return", returnId и orderId');
|
|
||||||
}
|
|
||||||
if (amount.minor <= 0) {
|
|
||||||
throw new Error(`Сумма исходящей проводки возврата должна быть больше 0 (получено: ${amount.minor})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const accounts = await this.accounts.list();
|
const accounts = await this.accounts.list();
|
||||||
const targetAcc = accountId
|
const defaultAcc = accounts.items.find((a) => a.isDefault) || accounts.items[0];
|
||||||
? accounts.items.find((a) => a.id === accountId)
|
|
||||||
: accounts.items.find((a) => a.isDefault) || accounts.items[0];
|
|
||||||
|
|
||||||
if (!targetAcc) {
|
|
||||||
throw new Error('Расчётный счёт для возврата средств не найден.');
|
|
||||||
}
|
|
||||||
|
|
||||||
if (targetAcc.currency !== amount.currency) {
|
|
||||||
throw new Error(
|
|
||||||
`Несовпадение валюты счёта (${targetAcc.currency}) и валюты возврата (${amount.currency})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const tx = await this.transactions.create({
|
const tx = await this.transactions.create({
|
||||||
transactionNumber: `CFT-${ulid().slice(0, 8)}`,
|
transactionNumber: `CFT-${ulid().slice(0, 8)}`,
|
||||||
type: 'outflow',
|
type: 'outflow',
|
||||||
category: 'refund',
|
category: 'refund',
|
||||||
amount,
|
amount,
|
||||||
accountId: targetAcc.id,
|
accountId: defaultAcc?.id || 'acc_default',
|
||||||
accountName: targetAcc.name,
|
accountName: defaultAcc?.name || 'Основной расчетный счет',
|
||||||
referenceType: 'order_return',
|
referenceType: 'order_return',
|
||||||
referenceId: returnId,
|
referenceId: returnId,
|
||||||
description: `Возврат средств по заказу (${orderId.slice(0, 8)}...)`,
|
description: `Возврат средств по заказу (${orderId.slice(0, 8)}...)`,
|
||||||
|
|
@ -166,12 +105,14 @@ export class MockFinanceService implements IFinanceService {
|
||||||
});
|
});
|
||||||
|
|
||||||
// Update account balance
|
// Update account balance
|
||||||
await this.accounts.update(targetAcc.id, {
|
if (defaultAcc) {
|
||||||
balance: {
|
await this.accounts.update(defaultAcc.id, {
|
||||||
minor: targetAcc.balance.minor - amount.minor,
|
balance: {
|
||||||
currency: targetAcc.balance.currency,
|
minor: Math.max(0, defaultAcc.balance.minor - amount.minor),
|
||||||
},
|
currency: defaultAcc.balance.currency,
|
||||||
});
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
return tx;
|
return tx;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import {
|
||||||
GoodsReceipt,
|
GoodsReceipt,
|
||||||
GoodsReceiptItem,
|
GoodsReceiptItem,
|
||||||
StockReservation,
|
StockReservation,
|
||||||
InsufficientStockError,
|
|
||||||
} from '../contracts/inventory';
|
} from '../contracts/inventory';
|
||||||
import { IRepository } from '../contracts/repository';
|
import { IRepository } from '../contracts/repository';
|
||||||
import { Warehouse } from '../../entities/inventory/warehouse';
|
import { Warehouse } from '../../entities/inventory/warehouse';
|
||||||
|
|
@ -40,64 +39,34 @@ export class MockInventoryService implements IInventoryService {
|
||||||
const now = new Date().toISOString();
|
const now = new Date().toISOString();
|
||||||
|
|
||||||
if (!stockItem) {
|
if (!stockItem) {
|
||||||
if (delta < 0) {
|
|
||||||
throw new InsufficientStockError(
|
|
||||||
productId,
|
|
||||||
Math.abs(delta),
|
|
||||||
0,
|
|
||||||
`Невозможно списать товар (ID: ${productId}): товар отсутствует на складе`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
stockItem = await this.stockItems.create({
|
stockItem = await this.stockItems.create({
|
||||||
warehouseId,
|
warehouseId,
|
||||||
warehouseName: 'Склад',
|
warehouseName: 'Склад',
|
||||||
productId,
|
productId,
|
||||||
sku: 'SKU',
|
sku: 'SKU',
|
||||||
productName: 'Товар',
|
productName: 'Товар',
|
||||||
onHand: delta,
|
onHand: Math.max(0, delta),
|
||||||
reserved: 0,
|
reserved: 0,
|
||||||
available: delta,
|
available: Math.max(0, delta),
|
||||||
unit: 'шт',
|
unit: 'шт',
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
if (stockItem.onHand + delta < 0) {
|
const newOnHand = Math.max(0, stockItem.onHand + delta);
|
||||||
throw new InsufficientStockError(
|
const newAvailable = Math.max(0, newOnHand - stockItem.reserved);
|
||||||
productId,
|
|
||||||
Math.abs(delta),
|
|
||||||
stockItem.onHand,
|
|
||||||
`Невозможно списать товар (ID: ${productId}): запрошено ${Math.abs(delta)}, фактически в наличии ${stockItem.onHand}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newOnHand = stockItem.onHand + delta;
|
|
||||||
const newAvailable = newOnHand - stockItem.reserved;
|
|
||||||
|
|
||||||
if (newAvailable < 0) {
|
|
||||||
throw new InsufficientStockError(
|
|
||||||
productId,
|
|
||||||
Math.abs(delta),
|
|
||||||
stockItem.available,
|
|
||||||
`Списание ${Math.abs(delta)} шт. превышает свободный остаток (${stockItem.available} шт.) с учётом активных резервов (${stockItem.reserved} шт.)`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
stockItem = await this.stockItems.update(stockItem.id, {
|
stockItem = await this.stockItems.update(stockItem.id, {
|
||||||
onHand: newOnHand,
|
onHand: newOnHand,
|
||||||
available: newAvailable,
|
available: newAvailable,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record strictly physical movement in Stock Ledger
|
// Record movement
|
||||||
await this.movements.create({
|
await this.movements.create({
|
||||||
type: delta >= 0 ? 'adjustment' : 'loss',
|
type: delta >= 0 ? 'adjustment' : 'loss',
|
||||||
productId,
|
productId,
|
||||||
productName: stockItem.productName,
|
productName: stockItem.productName,
|
||||||
sku: stockItem.sku,
|
sku: stockItem.sku,
|
||||||
warehouseId,
|
|
||||||
targetWarehouseId: warehouseId,
|
targetWarehouseId: warehouseId,
|
||||||
quantity: Math.abs(delta),
|
quantity: Math.abs(delta),
|
||||||
reason,
|
|
||||||
notes: reason,
|
notes: reason,
|
||||||
timestamp: now,
|
timestamp: now,
|
||||||
});
|
});
|
||||||
|
|
@ -109,50 +78,43 @@ export class MockInventoryService implements IInventoryService {
|
||||||
orderId: string,
|
orderId: string,
|
||||||
warehouseId: string,
|
warehouseId: string,
|
||||||
productId: string,
|
productId: string,
|
||||||
quantity: number,
|
quantity: number
|
||||||
orderLineId?: string,
|
|
||||||
reason?: string
|
|
||||||
): Promise<StockReservation> {
|
): Promise<StockReservation> {
|
||||||
if (quantity <= 0) {
|
|
||||||
throw new Error(`Количество для резервирования должно быть больше 0 (получено: ${quantity})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const list = await this.stockItems.list({
|
|
||||||
filter: { warehouseId, productId },
|
|
||||||
});
|
|
||||||
const item = list.items[0];
|
|
||||||
|
|
||||||
if (!item || item.available < quantity) {
|
|
||||||
const currentAvailable = item ? item.available : 0;
|
|
||||||
throw new InsufficientStockError(
|
|
||||||
productId,
|
|
||||||
quantity,
|
|
||||||
currentAvailable,
|
|
||||||
`Недостаточно свободного остатка для резервирования товара (ID: ${productId}): запрошено ${quantity}, доступно ${currentAvailable}`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Invariant update: available = onHand - reserved
|
|
||||||
const newReserved = item.reserved + quantity;
|
|
||||||
const newAvailable = item.onHand - newReserved;
|
|
||||||
|
|
||||||
await this.stockItems.update(item.id, {
|
|
||||||
reserved: newReserved,
|
|
||||||
available: newAvailable,
|
|
||||||
});
|
|
||||||
|
|
||||||
// Record in Reservation Ledger (append-only, isolated from physical stock movements)
|
|
||||||
const reservation = await this.reservations.create({
|
const reservation = await this.reservations.create({
|
||||||
orderId,
|
orderId,
|
||||||
orderLineId,
|
|
||||||
warehouseId,
|
warehouseId,
|
||||||
productId,
|
productId,
|
||||||
quantity,
|
quantity,
|
||||||
status: 'active',
|
status: 'active',
|
||||||
reason: reason || `Резервирование под заказ #${orderId.slice(0, 8)}`,
|
|
||||||
expiresAt: new Date(Date.now() + 24 * 3600 * 1000).toISOString(),
|
expiresAt: new Date(Date.now() + 24 * 3600 * 1000).toISOString(),
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Update stock item reserved
|
||||||
|
const list = await this.stockItems.list({
|
||||||
|
filter: { warehouseId, productId },
|
||||||
|
});
|
||||||
|
if (list.items.length > 0) {
|
||||||
|
const item = list.items[0];
|
||||||
|
const newReserved = item.reserved + quantity;
|
||||||
|
const newAvailable = Math.max(0, item.onHand - newReserved);
|
||||||
|
await this.stockItems.update(item.id, {
|
||||||
|
reserved: newReserved,
|
||||||
|
available: newAvailable,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Record movement as reservation note
|
||||||
|
await this.movements.create({
|
||||||
|
type: 'transfer',
|
||||||
|
productId,
|
||||||
|
productName: list.items[0]?.productName || 'Товар',
|
||||||
|
sku: list.items[0]?.sku || 'SKU',
|
||||||
|
sourceWarehouseId: warehouseId,
|
||||||
|
quantity,
|
||||||
|
notes: `Резервирование под заказ #${orderId.slice(0, 8)}`,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
|
|
||||||
return reservation;
|
return reservation;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -160,19 +122,29 @@ export class MockInventoryService implements IInventoryService {
|
||||||
const list = await this.reservations.list({ filter: { orderId, status: 'active' } });
|
const list = await this.reservations.list({ filter: { orderId, status: 'active' } });
|
||||||
for (const r of list.items) {
|
for (const r of list.items) {
|
||||||
await this.reservations.update(r.id, { status: 'cancelled' });
|
await this.reservations.update(r.id, { status: 'cancelled' });
|
||||||
|
|
||||||
const stockList = await this.stockItems.list({
|
const stockList = await this.stockItems.list({
|
||||||
filter: { warehouseId: r.warehouseId, productId: r.productId },
|
filter: { warehouseId: r.warehouseId, productId: r.productId },
|
||||||
});
|
});
|
||||||
if (stockList.items.length > 0) {
|
if (stockList.items.length > 0) {
|
||||||
const si = stockList.items[0];
|
const si = stockList.items[0];
|
||||||
const newReserved = si.reserved - r.quantity;
|
const newReserved = Math.max(0, si.reserved - r.quantity);
|
||||||
const newAvailable = si.onHand - newReserved;
|
const newAvailable = Math.max(0, si.onHand - newReserved);
|
||||||
await this.stockItems.update(si.id, {
|
await this.stockItems.update(si.id, {
|
||||||
reserved: newReserved,
|
reserved: newReserved,
|
||||||
available: newAvailable,
|
available: newAvailable,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await this.movements.create({
|
||||||
|
type: 'adjustment',
|
||||||
|
productId: r.productId,
|
||||||
|
productName: stockList.items[0]?.productName || 'Товар',
|
||||||
|
sku: stockList.items[0]?.sku || 'SKU',
|
||||||
|
targetWarehouseId: r.warehouseId,
|
||||||
|
quantity: r.quantity,
|
||||||
|
notes: `Снятие резерва (отмена заказа #${orderId.slice(0, 8)})`,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -181,7 +153,7 @@ export class MockInventoryService implements IInventoryService {
|
||||||
warehouseId: string,
|
warehouseId: string,
|
||||||
items?: { productId: string; name: string; sku: string; quantity: number }[]
|
items?: { productId: string; name: string; sku: string; quantity: number }[]
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
// 1. Mark active reservations as fulfilled
|
// 1. Mark reservations as fulfilled
|
||||||
const resList = await this.reservations.list({
|
const resList = await this.reservations.list({
|
||||||
filter: { orderId, status: 'active' },
|
filter: { orderId, status: 'active' },
|
||||||
});
|
});
|
||||||
|
|
@ -189,7 +161,7 @@ export class MockInventoryService implements IInventoryService {
|
||||||
await this.reservations.update(r.id, { status: 'fulfilled' });
|
await this.reservations.update(r.id, { status: 'fulfilled' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 2. Deduct physical stock (onHand) and reserved quantity in Stock Ledger
|
// 2. Deduct physical stock (onHand) and reserved quantity
|
||||||
if (items && items.length > 0) {
|
if (items && items.length > 0) {
|
||||||
for (const item of items) {
|
for (const item of items) {
|
||||||
const stockList = await this.stockItems.list({
|
const stockList = await this.stockItems.list({
|
||||||
|
|
@ -197,19 +169,9 @@ export class MockInventoryService implements IInventoryService {
|
||||||
});
|
});
|
||||||
if (stockList.items.length > 0) {
|
if (stockList.items.length > 0) {
|
||||||
const si = stockList.items[0];
|
const si = stockList.items[0];
|
||||||
if (si.onHand < item.quantity) {
|
const newOnHand = Math.max(0, si.onHand - item.quantity);
|
||||||
throw new InsufficientStockError(
|
|
||||||
item.productId,
|
|
||||||
item.quantity,
|
|
||||||
si.onHand,
|
|
||||||
`Невозможно отгрузить: физический остаток (${si.onHand}) меньше списываемого (${item.quantity})`
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
const newOnHand = si.onHand - item.quantity;
|
|
||||||
const newReserved = Math.max(0, si.reserved - item.quantity);
|
const newReserved = Math.max(0, si.reserved - item.quantity);
|
||||||
const newAvailable = newOnHand - newReserved;
|
const newAvailable = Math.max(0, newOnHand - newReserved);
|
||||||
|
|
||||||
await this.stockItems.update(si.id, {
|
await this.stockItems.update(si.id, {
|
||||||
onHand: newOnHand,
|
onHand: newOnHand,
|
||||||
reserved: newReserved,
|
reserved: newReserved,
|
||||||
|
|
@ -217,17 +179,13 @@ export class MockInventoryService implements IInventoryService {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// Record physical shipment in Stock Ledger
|
|
||||||
await this.movements.create({
|
await this.movements.create({
|
||||||
type: 'shipment',
|
type: 'shipment',
|
||||||
productId: item.productId,
|
productId: item.productId,
|
||||||
productName: item.name,
|
productName: item.name,
|
||||||
sku: item.sku,
|
sku: item.sku,
|
||||||
warehouseId,
|
|
||||||
sourceWarehouseId: warehouseId,
|
sourceWarehouseId: warehouseId,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
orderId,
|
|
||||||
reason: `Отгрузка по заказу #${orderId.slice(0, 8)}`,
|
|
||||||
notes: `Отгрузка по заказу #${orderId.slice(0, 8)}`,
|
notes: `Отгрузка по заказу #${orderId.slice(0, 8)}`,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
@ -279,6 +237,7 @@ export class MockInventoryService implements IInventoryService {
|
||||||
items,
|
items,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Find warehouse name
|
||||||
const wh = await this.warehouses.getById(warehouseId);
|
const wh = await this.warehouses.getById(warehouseId);
|
||||||
const whName = wh?.name || 'Склад';
|
const whName = wh?.name || 'Склад';
|
||||||
|
|
||||||
|
|
@ -289,7 +248,7 @@ export class MockInventoryService implements IInventoryService {
|
||||||
if (list.items.length > 0) {
|
if (list.items.length > 0) {
|
||||||
const si = list.items[0];
|
const si = list.items[0];
|
||||||
const newOnHand = si.onHand + item.quantity;
|
const newOnHand = si.onHand + item.quantity;
|
||||||
const newAvailable = newOnHand - si.reserved;
|
const newAvailable = Math.max(0, newOnHand - si.reserved);
|
||||||
await this.stockItems.update(si.id, {
|
await this.stockItems.update(si.id, {
|
||||||
onHand: newOnHand,
|
onHand: newOnHand,
|
||||||
available: newAvailable,
|
available: newAvailable,
|
||||||
|
|
@ -313,11 +272,8 @@ export class MockInventoryService implements IInventoryService {
|
||||||
productId: item.productId,
|
productId: item.productId,
|
||||||
productName: item.name,
|
productName: item.name,
|
||||||
sku: item.sku,
|
sku: item.sku,
|
||||||
warehouseId,
|
|
||||||
targetWarehouseId: warehouseId,
|
targetWarehouseId: warehouseId,
|
||||||
quantity: item.quantity,
|
quantity: item.quantity,
|
||||||
referenceNumber: receiptNumber,
|
|
||||||
reason: `Приходная накладная №${receiptNumber} (${supplierName})`,
|
|
||||||
notes: `Приходная накладная №${receiptNumber} (${supplierName})`,
|
notes: `Приходная накладная №${receiptNumber} (${supplierName})`,
|
||||||
timestamp: new Date().toISOString(),
|
timestamp: new Date().toISOString(),
|
||||||
});
|
});
|
||||||
|
|
@ -326,3 +282,4 @@ export class MockInventoryService implements IInventoryService {
|
||||||
return receipt;
|
return receipt;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,218 +0,0 @@
|
||||||
import { describe, it, expect, beforeEach } from 'vitest';
|
|
||||||
import {
|
|
||||||
resetDatabase,
|
|
||||||
putRecord,
|
|
||||||
getRecord,
|
|
||||||
getRecordsByEntity,
|
|
||||||
importDatabaseSnapshot,
|
|
||||||
validateSnapshotSchema,
|
|
||||||
calculateSnapshotDiff,
|
|
||||||
getStoreItem,
|
|
||||||
DatabaseSnapshot,
|
|
||||||
} from '../indexedDb';
|
|
||||||
|
|
||||||
describe('Safe Snapshot Import & Export Suite (F24)', () => {
|
|
||||||
const TENANT_ID = 'tenant_import_test';
|
|
||||||
|
|
||||||
beforeEach(async () => {
|
|
||||||
await resetDatabase();
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Schema Validation', () => {
|
|
||||||
it('1. Rejects non-object or null input', () => {
|
|
||||||
expect(() => validateSnapshotSchema(null)).toThrow(/Некорректный формат снимка/);
|
|
||||||
expect(() => validateSnapshotSchema('invalid string')).toThrow(/Некорректный формат снимка/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('2. Rejects snapshot with missing or invalid version', () => {
|
|
||||||
expect(() => validateSnapshotSchema({ stores: {} })).toThrow(/поле version должно быть числом >= 1/);
|
|
||||||
expect(() => validateSnapshotSchema({ version: 0, stores: {} })).toThrow(/поле version должно быть числом >= 1/);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('3. Rejects snapshot with missing or malformed stores', () => {
|
|
||||||
expect(() => validateSnapshotSchema({ version: 1 })).toThrow(/отсутствует объект таблиц/);
|
|
||||||
expect(() => validateSnapshotSchema({ version: 1, stores: 'not an object' })).toThrow(/отсутствует объект таблиц/);
|
|
||||||
expect(() =>
|
|
||||||
validateSnapshotSchema({
|
|
||||||
version: 1,
|
|
||||||
stores: {
|
|
||||||
records_data: 'invalid non-array',
|
|
||||||
},
|
|
||||||
})
|
|
||||||
).toThrow(/ожидался массив записей/);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Diff Calculation & Pre-Import Backup', () => {
|
|
||||||
it('4. Correctly computes diff (toCreate, toUpdate, toDelete) before applying changes', async () => {
|
|
||||||
// Seed 2 existing records
|
|
||||||
await putRecord({ id: 'rec_1', entityType: 'deal', title: 'Deal 1', tenant_id: TENANT_ID });
|
|
||||||
await putRecord({ id: 'rec_2', entityType: 'deal', title: 'Deal 2', tenant_id: TENANT_ID });
|
|
||||||
|
|
||||||
// Incoming snapshot with 1 update (rec_1) and 1 create (rec_3). rec_2 is missing (so it will be deleted in overwrite mode)
|
|
||||||
const incomingSnapshot: DatabaseSnapshot = {
|
|
||||||
version: 1,
|
|
||||||
schemaVersion: 3,
|
|
||||||
exportedAt: new Date().toISOString(),
|
|
||||||
system: 'Krona Universal Business OS',
|
|
||||||
tenantId: TENANT_ID,
|
|
||||||
stores: {
|
|
||||||
entities_meta: [],
|
|
||||||
fields_meta: [],
|
|
||||||
layouts_meta: [],
|
|
||||||
views_meta: [],
|
|
||||||
records_data: [
|
|
||||||
{ id: 'rec_1', entityType: 'deal', title: 'Deal 1 Updated', tenant_id: TENANT_ID },
|
|
||||||
{ id: 'rec_3', entityType: 'deal', title: 'Deal 3 New', tenant_id: TENANT_ID },
|
|
||||||
],
|
|
||||||
audit_log: [],
|
|
||||||
settings: [],
|
|
||||||
counters: [],
|
|
||||||
},
|
|
||||||
metadata: {
|
|
||||||
totalRecords: 2,
|
|
||||||
entitiesCount: 0,
|
|
||||||
auditLogsCount: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const diffOverwrite = await calculateSnapshotDiff(incomingSnapshot, {
|
|
||||||
targetTenantId: TENANT_ID,
|
|
||||||
mode: 'overwrite',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(diffOverwrite.toCreate).toBe(1); // rec_3
|
|
||||||
expect(diffOverwrite.toUpdate).toBe(1); // rec_1
|
|
||||||
expect(diffOverwrite.toDelete).toBe(1); // rec_2
|
|
||||||
|
|
||||||
const diffMerge = await calculateSnapshotDiff(incomingSnapshot, {
|
|
||||||
targetTenantId: TENANT_ID,
|
|
||||||
mode: 'merge',
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(diffMerge.toCreate).toBe(1);
|
|
||||||
expect(diffMerge.toUpdate).toBe(1);
|
|
||||||
expect(diffMerge.toDelete).toBe(0); // in merge mode nothing is deleted
|
|
||||||
});
|
|
||||||
|
|
||||||
it('5. Creates automatic backup in settings before mutating data', async () => {
|
|
||||||
// Seed record
|
|
||||||
await putRecord({ id: 'rec_orig', entityType: 'lead', name: 'Original Lead', tenant_id: TENANT_ID });
|
|
||||||
|
|
||||||
const incomingSnapshot: DatabaseSnapshot = {
|
|
||||||
version: 1,
|
|
||||||
schemaVersion: 3,
|
|
||||||
exportedAt: new Date().toISOString(),
|
|
||||||
system: 'Krona Universal Business OS',
|
|
||||||
tenantId: TENANT_ID,
|
|
||||||
stores: {
|
|
||||||
entities_meta: [],
|
|
||||||
fields_meta: [],
|
|
||||||
layouts_meta: [],
|
|
||||||
views_meta: [],
|
|
||||||
records_data: [
|
|
||||||
{ id: 'rec_new', entityType: 'lead', name: 'Imported Lead', tenant_id: TENANT_ID },
|
|
||||||
],
|
|
||||||
audit_log: [],
|
|
||||||
settings: [],
|
|
||||||
counters: [],
|
|
||||||
},
|
|
||||||
metadata: {
|
|
||||||
totalRecords: 1,
|
|
||||||
entitiesCount: 0,
|
|
||||||
auditLogsCount: 0,
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
const result = await importDatabaseSnapshot(incomingSnapshot, {
|
|
||||||
targetTenantId: TENANT_ID,
|
|
||||||
mode: 'overwrite',
|
|
||||||
createBackup: true,
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(result.backupCreated).toBe(true);
|
|
||||||
|
|
||||||
// Verify backup exists in settings
|
|
||||||
const backupEntry = await getStoreItem<{ key: string; backup: DatabaseSnapshot }>(
|
|
||||||
'settings',
|
|
||||||
'krona_last_backup_before_import'
|
|
||||||
);
|
|
||||||
expect(backupEntry).toBeDefined();
|
|
||||||
expect(backupEntry?.backup.stores.records_data).toEqual(
|
|
||||||
expect.arrayContaining([expect.objectContaining({ id: 'rec_orig' })])
|
|
||||||
);
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('Modes: Overwrite vs Merge', () => {
|
|
||||||
it('6. Overwrite mode deletes unmentioned existing records for the tenant', async () => {
|
|
||||||
await putRecord({ id: 'old_1', entityType: 'item', tenant_id: TENANT_ID });
|
|
||||||
await putRecord({ id: 'old_2', entityType: 'item', tenant_id: TENANT_ID });
|
|
||||||
|
|
||||||
const incoming: DatabaseSnapshot = {
|
|
||||||
version: 1,
|
|
||||||
schemaVersion: 3,
|
|
||||||
exportedAt: new Date().toISOString(),
|
|
||||||
system: 'Krona Universal Business OS',
|
|
||||||
tenantId: TENANT_ID,
|
|
||||||
stores: {
|
|
||||||
entities_meta: [],
|
|
||||||
fields_meta: [],
|
|
||||||
layouts_meta: [],
|
|
||||||
views_meta: [],
|
|
||||||
records_data: [
|
|
||||||
{ id: 'fresh_1', entityType: 'item', tenant_id: TENANT_ID },
|
|
||||||
],
|
|
||||||
audit_log: [],
|
|
||||||
settings: [],
|
|
||||||
counters: [],
|
|
||||||
},
|
|
||||||
metadata: { totalRecords: 1, entitiesCount: 0, auditLogsCount: 0 },
|
|
||||||
};
|
|
||||||
|
|
||||||
await importDatabaseSnapshot(incoming, { targetTenantId: TENANT_ID, mode: 'overwrite' });
|
|
||||||
|
|
||||||
const records = await getRecordsByEntity('item', { tenantId: TENANT_ID });
|
|
||||||
expect(records.total).toBe(1);
|
|
||||||
expect(records.items[0].id).toBe('fresh_1');
|
|
||||||
expect(await getRecord('old_1', TENANT_ID)).toBeNull();
|
|
||||||
expect(await getRecord('old_2', TENANT_ID)).toBeNull();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('7. Merge mode preserves existing records and upserts incoming records', async () => {
|
|
||||||
await putRecord({ id: 'kept_1', entityType: 'item', name: 'Keep Me', tenant_id: TENANT_ID });
|
|
||||||
await putRecord({ id: 'updated_1', entityType: 'item', name: 'Before Update', tenant_id: TENANT_ID });
|
|
||||||
|
|
||||||
const incoming: DatabaseSnapshot = {
|
|
||||||
version: 1,
|
|
||||||
schemaVersion: 3,
|
|
||||||
exportedAt: new Date().toISOString(),
|
|
||||||
system: 'Krona Universal Business OS',
|
|
||||||
tenantId: TENANT_ID,
|
|
||||||
stores: {
|
|
||||||
entities_meta: [],
|
|
||||||
fields_meta: [],
|
|
||||||
layouts_meta: [],
|
|
||||||
views_meta: [],
|
|
||||||
records_data: [
|
|
||||||
{ id: 'updated_1', entityType: 'item', name: 'After Update', tenant_id: TENANT_ID },
|
|
||||||
{ id: 'new_1', entityType: 'item', name: 'Brand New', tenant_id: TENANT_ID },
|
|
||||||
],
|
|
||||||
audit_log: [],
|
|
||||||
settings: [],
|
|
||||||
counters: [],
|
|
||||||
},
|
|
||||||
metadata: { totalRecords: 2, entitiesCount: 0, auditLogsCount: 0 },
|
|
||||||
};
|
|
||||||
|
|
||||||
await importDatabaseSnapshot(incoming, { targetTenantId: TENANT_ID, mode: 'merge' });
|
|
||||||
|
|
||||||
const records = await getRecordsByEntity('item', { tenantId: TENANT_ID });
|
|
||||||
expect(records.total).toBe(3); // kept_1, updated_1, new_1
|
|
||||||
expect(await getRecord('kept_1', TENANT_ID)).not.toBeNull();
|
|
||||||
const updated = await getRecord<any>('updated_1', TENANT_ID);
|
|
||||||
expect(updated?.name).toBe('After Update');
|
|
||||||
expect(await getRecord('new_1', TENANT_ID)).not.toBeNull();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -8,7 +8,7 @@ import {
|
||||||
import { ulid } from '../../shared/lib/ulid';
|
import { ulid } from '../../shared/lib/ulid';
|
||||||
|
|
||||||
export const DB_NAME = 'krona_business_os_db';
|
export const DB_NAME = 'krona_business_os_db';
|
||||||
export const DB_VERSION = 3;
|
export const DB_VERSION = 2;
|
||||||
|
|
||||||
export const DB_OBJECT_STORES = [
|
export const DB_OBJECT_STORES = [
|
||||||
'entities_meta',
|
'entities_meta',
|
||||||
|
|
@ -18,7 +18,6 @@ export const DB_OBJECT_STORES = [
|
||||||
'records_data',
|
'records_data',
|
||||||
'audit_log',
|
'audit_log',
|
||||||
'settings',
|
'settings',
|
||||||
'counters',
|
|
||||||
] as const;
|
] as const;
|
||||||
|
|
||||||
export type DbStoreName = (typeof DB_OBJECT_STORES)[number];
|
export type DbStoreName = (typeof DB_OBJECT_STORES)[number];
|
||||||
|
|
@ -54,7 +53,6 @@ export interface DatabaseSnapshot {
|
||||||
records_data: unknown[];
|
records_data: unknown[];
|
||||||
audit_log: unknown[];
|
audit_log: unknown[];
|
||||||
settings: unknown[];
|
settings: unknown[];
|
||||||
counters?: unknown[];
|
|
||||||
};
|
};
|
||||||
metadata: {
|
metadata: {
|
||||||
totalRecords: number;
|
totalRecords: number;
|
||||||
|
|
@ -129,44 +127,12 @@ export function getDb(): Promise<IDBPDatabase> {
|
||||||
if (!db.objectStoreNames.contains('settings')) {
|
if (!db.objectStoreNames.contains('settings')) {
|
||||||
db.createObjectStore('settings', { keyPath: 'key' });
|
db.createObjectStore('settings', { keyPath: 'key' });
|
||||||
}
|
}
|
||||||
|
|
||||||
// 8. counters
|
|
||||||
if (!db.objectStoreNames.contains('counters')) {
|
|
||||||
db.createObjectStore('counters', { keyPath: 'key' });
|
|
||||||
}
|
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return dbInstancePromise;
|
return dbInstancePromise;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface CounterItem {
|
|
||||||
key: string;
|
|
||||||
currentValue: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function getNextMonotonicSequence(prefix: string, tenantId?: string): Promise<number> {
|
|
||||||
const db = await getDb();
|
|
||||||
const effectiveTenant = tenantId || getActiveGlobalTenantId() || DEFAULT_TENANT_ID;
|
|
||||||
const key = `${effectiveTenant}:${prefix}`;
|
|
||||||
const tx = db.transaction('counters', 'readwrite');
|
|
||||||
const store = tx.objectStore('counters');
|
|
||||||
const existing = (await store.get(key)) as CounterItem | undefined;
|
|
||||||
const nextVal = (existing ? existing.currentValue : 0) + 1;
|
|
||||||
await store.put({ key, currentValue: nextVal });
|
|
||||||
await tx.done;
|
|
||||||
return nextVal;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function generateMonotonicNumber(
|
|
||||||
prefix: string,
|
|
||||||
padLength = 4,
|
|
||||||
tenantId?: string
|
|
||||||
): Promise<string> {
|
|
||||||
const nextSeq = await getNextMonotonicSequence(prefix, tenantId);
|
|
||||||
return `${prefix}-${String(nextSeq).padStart(padLength, '0')}`;
|
|
||||||
}
|
|
||||||
|
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
// CRUD on records_data with Tenant Isolation
|
// CRUD on records_data with Tenant Isolation
|
||||||
// ----------------------------------------------------
|
// ----------------------------------------------------
|
||||||
|
|
@ -405,7 +371,6 @@ export async function exportDatabaseSnapshot(tenantId?: string): Promise<Databas
|
||||||
let records_data = (await db.getAll('records_data')) as RecordEntity[];
|
let records_data = (await db.getAll('records_data')) as RecordEntity[];
|
||||||
let audit_log = (await db.getAll('audit_log')) as Record<string, unknown>[];
|
let audit_log = (await db.getAll('audit_log')) as Record<string, unknown>[];
|
||||||
let settings = (await db.getAll('settings')) as Record<string, unknown>[];
|
let settings = (await db.getAll('settings')) as Record<string, unknown>[];
|
||||||
let counters = (await db.getAll('counters')) as CounterItem[];
|
|
||||||
|
|
||||||
if (tenantId) {
|
if (tenantId) {
|
||||||
records_data = records_data.filter((r) => (r.tenant_id || DEFAULT_TENANT_ID) === tenantId);
|
records_data = records_data.filter((r) => (r.tenant_id || DEFAULT_TENANT_ID) === tenantId);
|
||||||
|
|
@ -420,7 +385,6 @@ export async function exportDatabaseSnapshot(tenantId?: string): Promise<Databas
|
||||||
(v) => !v.tenant_id || v.tenant_id === 'system' || v.tenant_id === tenantId
|
(v) => !v.tenant_id || v.tenant_id === 'system' || v.tenant_id === tenantId
|
||||||
);
|
);
|
||||||
settings = settings.filter((s) => !s.tenant_id || s.tenant_id === tenantId);
|
settings = settings.filter((s) => !s.tenant_id || s.tenant_id === tenantId);
|
||||||
counters = counters.filter((c) => c.key.startsWith(`${tenantId}:`));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
|
|
@ -437,7 +401,6 @@ export async function exportDatabaseSnapshot(tenantId?: string): Promise<Databas
|
||||||
records_data,
|
records_data,
|
||||||
audit_log,
|
audit_log,
|
||||||
settings,
|
settings,
|
||||||
counters,
|
|
||||||
},
|
},
|
||||||
metadata: {
|
metadata: {
|
||||||
totalRecords: records_data.length,
|
totalRecords: records_data.length,
|
||||||
|
|
@ -447,256 +410,100 @@ export async function exportDatabaseSnapshot(tenantId?: string): Promise<Databas
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SnapshotDiff {
|
|
||||||
toCreate: number;
|
|
||||||
toUpdate: number;
|
|
||||||
toDelete: number;
|
|
||||||
totalIncoming: number;
|
|
||||||
storesDiff: Record<string, { incoming: number; existing: number }>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ImportSnapshotOptions {
|
|
||||||
targetTenantId?: string;
|
|
||||||
mode?: 'overwrite' | 'merge';
|
|
||||||
createBackup?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function validateSnapshotSchema(data: unknown): DatabaseSnapshot {
|
|
||||||
if (!data || typeof data !== 'object') {
|
|
||||||
throw new Error('Некорректный формат снимка: ожидался JSON-объект.');
|
|
||||||
}
|
|
||||||
const snap = data as Record<string, unknown>;
|
|
||||||
if (typeof snap.version !== 'number' || snap.version < 1) {
|
|
||||||
throw new Error('Невалидная версия снимка: поле version должно быть числом >= 1.');
|
|
||||||
}
|
|
||||||
if (!snap.stores || typeof snap.stores !== 'object' || Array.isArray(snap.stores)) {
|
|
||||||
throw new Error('Невалидный снимок: отсутствует объект таблиц (stores).');
|
|
||||||
}
|
|
||||||
const stores = snap.stores as Record<string, unknown>;
|
|
||||||
for (const [storeName, storeData] of Object.entries(stores)) {
|
|
||||||
if (!Array.isArray(storeData)) {
|
|
||||||
throw new Error(`Невалидные данные таблицы «${storeName}»: ожидался массив записей.`);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return snap as unknown as DatabaseSnapshot;
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function calculateSnapshotDiff(
|
|
||||||
snapshot: DatabaseSnapshot,
|
|
||||||
options?: { targetTenantId?: string; mode?: 'overwrite' | 'merge' }
|
|
||||||
): Promise<SnapshotDiff> {
|
|
||||||
const validSnapshot = validateSnapshotSchema(snapshot);
|
|
||||||
const db = await getDb();
|
|
||||||
const effectiveTenant = options?.targetTenantId || getActiveGlobalTenantId();
|
|
||||||
const mode = options?.mode || 'overwrite';
|
|
||||||
|
|
||||||
let toCreate = 0;
|
|
||||||
let toUpdate = 0;
|
|
||||||
let toDelete = 0;
|
|
||||||
let totalIncoming = 0;
|
|
||||||
const storesDiff: Record<string, { incoming: number; existing: number }> = {};
|
|
||||||
|
|
||||||
for (const storeName of DB_OBJECT_STORES) {
|
|
||||||
const incomingItems = (validSnapshot.stores[storeName] || []) as { id?: string; key?: string }[];
|
|
||||||
totalIncoming += incomingItems.length;
|
|
||||||
|
|
||||||
const existingItems = (await db.getAll(storeName)) as { id?: string; key?: string; tenant_id?: string }[];
|
|
||||||
const relevantExisting = effectiveTenant
|
|
||||||
? existingItems.filter((i) => !i.tenant_id || i.tenant_id === 'system' || i.tenant_id === effectiveTenant)
|
|
||||||
: existingItems;
|
|
||||||
|
|
||||||
storesDiff[storeName] = {
|
|
||||||
incoming: incomingItems.length,
|
|
||||||
existing: relevantExisting.length,
|
|
||||||
};
|
|
||||||
|
|
||||||
const existingKeys = new Set(
|
|
||||||
relevantExisting.map((i) => (i.id ? String(i.id) : i.key ? String(i.key) : ''))
|
|
||||||
);
|
|
||||||
|
|
||||||
let storeCreates = 0;
|
|
||||||
let storeUpdates = 0;
|
|
||||||
|
|
||||||
for (const item of incomingItems) {
|
|
||||||
const key = item.id ? String(item.id) : item.key ? String(item.key) : '';
|
|
||||||
if (key && existingKeys.has(key)) {
|
|
||||||
storeUpdates++;
|
|
||||||
} else {
|
|
||||||
storeCreates++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
toCreate += storeCreates;
|
|
||||||
toUpdate += storeUpdates;
|
|
||||||
|
|
||||||
if (mode === 'overwrite') {
|
|
||||||
const incomingKeys = new Set(
|
|
||||||
incomingItems.map((i) => (i.id ? String(i.id) : i.key ? String(i.key) : ''))
|
|
||||||
);
|
|
||||||
for (const item of relevantExisting) {
|
|
||||||
const key = item.id ? String(item.id) : item.key ? String(item.key) : '';
|
|
||||||
if (key && !incomingKeys.has(key)) {
|
|
||||||
toDelete++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
toCreate,
|
|
||||||
toUpdate,
|
|
||||||
toDelete,
|
|
||||||
totalIncoming,
|
|
||||||
storesDiff,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function importDatabaseSnapshot(
|
export async function importDatabaseSnapshot(
|
||||||
snapshot: DatabaseSnapshot,
|
snapshot: DatabaseSnapshot,
|
||||||
options?: string | ImportSnapshotOptions
|
targetTenantId?: string
|
||||||
): Promise<{ backupCreated: boolean; diff: SnapshotDiff }> {
|
): Promise<void> {
|
||||||
const opts: ImportSnapshotOptions =
|
if (!snapshot || typeof snapshot !== 'object') {
|
||||||
typeof options === 'string'
|
throw new Error('Некорректный формат снимка базы данных (snapshot).');
|
||||||
? { targetTenantId: options }
|
}
|
||||||
: options || {};
|
if (!snapshot.stores || typeof snapshot.stores !== 'object') {
|
||||||
|
throw new Error('Снимок не содержит данных таблиц (stores).');
|
||||||
const validSnapshot = validateSnapshotSchema(snapshot);
|
|
||||||
const targetTenantId = opts.targetTenantId;
|
|
||||||
const mode = opts.mode || 'overwrite';
|
|
||||||
const shouldBackup = opts.createBackup !== false;
|
|
||||||
|
|
||||||
// 1. Calculate diff before mutating DB
|
|
||||||
const diff = await calculateSnapshotDiff(validSnapshot, { targetTenantId, mode });
|
|
||||||
|
|
||||||
// 2. Create automatic backup before overwrite
|
|
||||||
let backupCreated = false;
|
|
||||||
if (shouldBackup) {
|
|
||||||
try {
|
|
||||||
const backup = await exportDatabaseSnapshot(targetTenantId);
|
|
||||||
await putStoreItem('settings', {
|
|
||||||
key: 'krona_last_backup_before_import',
|
|
||||||
backup,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
});
|
|
||||||
backupCreated = true;
|
|
||||||
} catch (e) {
|
|
||||||
console.warn('[indexedDb] Failed to create pre-import backup:', e);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const db = await getDb();
|
const db = await getDb();
|
||||||
const tx = db.transaction(DB_OBJECT_STORES as unknown as string[], 'readwrite');
|
const tx = db.transaction(DB_OBJECT_STORES as unknown as string[], 'readwrite');
|
||||||
|
|
||||||
try {
|
if (!targetTenantId) {
|
||||||
if (!targetTenantId) {
|
// Global wipe & restore
|
||||||
// Global wipe & restore
|
for (const store of DB_OBJECT_STORES) {
|
||||||
for (const store of DB_OBJECT_STORES) {
|
const storeObj = tx.objectStore(store);
|
||||||
const storeObj = tx.objectStore(store);
|
await storeObj.clear();
|
||||||
if (mode === 'overwrite') {
|
const items = snapshot.stores[store] || [];
|
||||||
await storeObj.clear();
|
for (const item of items) {
|
||||||
}
|
await storeObj.put(item);
|
||||||
const items = validSnapshot.stores[store] || [];
|
|
||||||
for (const item of items) {
|
|
||||||
await storeObj.put(item);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
// Isolated tenant import
|
|
||||||
const recordsStore = tx.objectStore('records_data');
|
|
||||||
const existingRecords = (await recordsStore.getAll()) as RecordEntity[];
|
|
||||||
|
|
||||||
if (mode === 'overwrite') {
|
|
||||||
for (const r of existingRecords) {
|
|
||||||
if ((r.tenant_id || DEFAULT_TENANT_ID) === targetTenantId) {
|
|
||||||
await recordsStore.delete(r.id);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const newRecords = (validSnapshot.stores.records_data || []) as RecordEntity[];
|
|
||||||
const idMap = new Map<string, string>();
|
|
||||||
for (const r of newRecords) {
|
|
||||||
const isCrossTenantClone = Boolean(
|
|
||||||
targetTenantId && validSnapshot.tenantId && targetTenantId !== validSnapshot.tenantId
|
|
||||||
);
|
|
||||||
const idConflict = existingRecords.some(
|
|
||||||
(er) => er.id === r.id && (er.tenant_id || DEFAULT_TENANT_ID) !== targetTenantId
|
|
||||||
);
|
|
||||||
const newId = isCrossTenantClone || idConflict ? ulid() : r.id;
|
|
||||||
idMap.set(r.id, newId);
|
|
||||||
await recordsStore.put({ ...r, id: newId, tenant_id: targetTenantId });
|
|
||||||
}
|
|
||||||
|
|
||||||
// Audit log
|
|
||||||
const auditStore = tx.objectStore('audit_log');
|
|
||||||
if (mode === 'overwrite') {
|
|
||||||
const existingAudit = (await auditStore.getAll()) as Record<string, unknown>[];
|
|
||||||
for (const a of existingAudit) {
|
|
||||||
if ((a.tenant_id || DEFAULT_TENANT_ID) === targetTenantId) {
|
|
||||||
await auditStore.delete(String(a.id));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const newAudit = (validSnapshot.stores.audit_log || []) as Record<string, unknown>[];
|
|
||||||
for (const a of newAudit) {
|
|
||||||
const mappedEntityId = a.entityId ? (idMap.get(String(a.entityId)) || a.entityId) : a.entityId;
|
|
||||||
const isCrossTenantClone = Boolean(
|
|
||||||
targetTenantId && validSnapshot.tenantId && targetTenantId !== validSnapshot.tenantId
|
|
||||||
);
|
|
||||||
const newId = isCrossTenantClone ? ulid() : (a.id as string);
|
|
||||||
await auditStore.put({
|
|
||||||
...a,
|
|
||||||
id: newId,
|
|
||||||
entityId: mappedEntityId,
|
|
||||||
tenant_id: targetTenantId,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Metadata: entities, fields, layouts, views, settings
|
|
||||||
for (const store of ['entities_meta', 'fields_meta', 'layouts_meta', 'views_meta', 'settings'] as const) {
|
|
||||||
const s = tx.objectStore(store);
|
|
||||||
const items = (validSnapshot.stores[store] || []) as Record<string, unknown>[];
|
|
||||||
for (const item of items) {
|
|
||||||
if (!item.tenant_id || item.tenant_id === 'system') {
|
|
||||||
await s.put(item);
|
|
||||||
} else {
|
|
||||||
const isCrossTenantClone = Boolean(
|
|
||||||
targetTenantId && validSnapshot.tenantId && targetTenantId !== validSnapshot.tenantId
|
|
||||||
);
|
|
||||||
const newId = isCrossTenantClone && item.id ? ulid() : item.id;
|
|
||||||
await s.put({ ...item, id: newId, tenant_id: targetTenantId });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Counters
|
|
||||||
const countersStore = tx.objectStore('counters');
|
|
||||||
if (mode === 'overwrite') {
|
|
||||||
const existingCounters = (await countersStore.getAll()) as CounterItem[];
|
|
||||||
for (const c of existingCounters) {
|
|
||||||
if (c.key.startsWith(`${targetTenantId}:`)) {
|
|
||||||
await countersStore.delete(c.key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
const newCounters = (validSnapshot.stores.counters || []) as CounterItem[];
|
|
||||||
for (const c of newCounters) {
|
|
||||||
await countersStore.put(c);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
// Isolated tenant import: delete only target tenant records, keep other tenants safe
|
||||||
|
const recordsStore = tx.objectStore('records_data');
|
||||||
|
const existingRecords = (await recordsStore.getAll()) as RecordEntity[];
|
||||||
|
for (const r of existingRecords) {
|
||||||
|
if ((r.tenant_id || DEFAULT_TENANT_ID) === targetTenantId) {
|
||||||
|
await recordsStore.delete(r.id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const newRecords = (snapshot.stores.records_data || []) as RecordEntity[];
|
||||||
|
const idMap = new Map<string, string>();
|
||||||
|
for (const r of newRecords) {
|
||||||
|
const isCrossTenantClone = Boolean(
|
||||||
|
targetTenantId && snapshot.tenantId && targetTenantId !== snapshot.tenantId
|
||||||
|
);
|
||||||
|
const idConflict = existingRecords.some(
|
||||||
|
(er) => er.id === r.id && (er.tenant_id || DEFAULT_TENANT_ID) !== targetTenantId
|
||||||
|
);
|
||||||
|
const newId = isCrossTenantClone || idConflict ? ulid() : r.id;
|
||||||
|
idMap.set(r.id, newId);
|
||||||
|
await recordsStore.put({ ...r, id: newId, tenant_id: targetTenantId });
|
||||||
|
}
|
||||||
|
|
||||||
await tx.done;
|
// Audit log
|
||||||
} catch (err) {
|
const auditStore = tx.objectStore('audit_log');
|
||||||
tx.abort();
|
const existingAudit = (await auditStore.getAll()) as Record<string, unknown>[];
|
||||||
throw err;
|
for (const a of existingAudit) {
|
||||||
|
if ((a.tenant_id || DEFAULT_TENANT_ID) === targetTenantId) {
|
||||||
|
await auditStore.delete(String(a.id));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const newAudit = (snapshot.stores.audit_log || []) as Record<string, unknown>[];
|
||||||
|
for (const a of newAudit) {
|
||||||
|
const mappedEntityId = a.entityId ? (idMap.get(String(a.entityId)) || a.entityId) : a.entityId;
|
||||||
|
const isCrossTenantClone = Boolean(
|
||||||
|
targetTenantId && snapshot.tenantId && targetTenantId !== snapshot.tenantId
|
||||||
|
);
|
||||||
|
const newId = isCrossTenantClone ? ulid() : (a.id as string);
|
||||||
|
await auditStore.put({
|
||||||
|
...a,
|
||||||
|
id: newId,
|
||||||
|
entityId: mappedEntityId,
|
||||||
|
tenant_id: targetTenantId,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Metadata: entities, fields, layouts, views
|
||||||
|
for (const store of ['entities_meta', 'fields_meta', 'layouts_meta', 'views_meta', 'settings'] as const) {
|
||||||
|
const s = tx.objectStore(store);
|
||||||
|
const items = (snapshot.stores[store] || []) as Record<string, unknown>[];
|
||||||
|
for (const item of items) {
|
||||||
|
if (!item.tenant_id || item.tenant_id === 'system') {
|
||||||
|
await s.put(item);
|
||||||
|
} else {
|
||||||
|
const isCrossTenantClone = Boolean(
|
||||||
|
targetTenantId && snapshot.tenantId && targetTenantId !== snapshot.tenantId
|
||||||
|
);
|
||||||
|
const newId = isCrossTenantClone && item.id ? ulid() : item.id;
|
||||||
|
await s.put({ ...item, id: newId, tenant_id: targetTenantId });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await tx.done;
|
||||||
|
|
||||||
storageEventBus.emit('snapshot:imported', {
|
storageEventBus.emit('snapshot:imported', {
|
||||||
action: 'import',
|
action: 'import',
|
||||||
data: validSnapshot.metadata,
|
data: snapshot.metadata,
|
||||||
});
|
});
|
||||||
|
|
||||||
return { backupCreated, diff };
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function resetDatabase(tenantId?: string): Promise<void> {
|
export async function resetDatabase(tenantId?: string): Promise<void> {
|
||||||
|
|
@ -710,7 +517,7 @@ export async function resetDatabase(tenantId?: string): Promise<void> {
|
||||||
await tx.done;
|
await tx.done;
|
||||||
} else {
|
} else {
|
||||||
// Only wipe data belonging to the specified tenant
|
// Only wipe data belonging to the specified tenant
|
||||||
const tx = db.transaction(['records_data', 'audit_log', 'counters'] as const, 'readwrite');
|
const tx = db.transaction(['records_data', 'audit_log'] as const, 'readwrite');
|
||||||
const recordsStore = tx.objectStore('records_data');
|
const recordsStore = tx.objectStore('records_data');
|
||||||
const allRecords = (await recordsStore.getAll()) as RecordEntity[];
|
const allRecords = (await recordsStore.getAll()) as RecordEntity[];
|
||||||
for (const r of allRecords) {
|
for (const r of allRecords) {
|
||||||
|
|
@ -725,13 +532,6 @@ export async function resetDatabase(tenantId?: string): Promise<void> {
|
||||||
await auditStore.delete(String(a.id));
|
await auditStore.delete(String(a.id));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
const countersStore = tx.objectStore('counters');
|
|
||||||
const allCounters = (await countersStore.getAll()) as CounterItem[];
|
|
||||||
for (const c of allCounters) {
|
|
||||||
if (c.key.startsWith(`${tenantId}:`)) {
|
|
||||||
await countersStore.delete(c.key);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
await tx.done;
|
await tx.done;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,93 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import { StateView, StateViewProps, StateViewErrorCode } from './StateView';
|
|
||||||
import { Button, ButtonProps } from './Button';
|
|
||||||
|
|
||||||
export interface AsyncBoundaryProps extends Omit<StateViewProps, 'state'> {
|
|
||||||
isLoading?: boolean;
|
|
||||||
isError?: boolean;
|
|
||||||
isEmpty?: boolean;
|
|
||||||
error?: Error | string | null;
|
|
||||||
errorCode?: StateViewErrorCode;
|
|
||||||
onRetry?: () => void;
|
|
||||||
children: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* AsyncBoundary component wrapping asynchronous data flows.
|
|
||||||
* Renders loading skeleton, error view (403/409/422/500/offline) with retry,
|
|
||||||
* empty state, or child content when resolved successfully (F23).
|
|
||||||
*/
|
|
||||||
export const AsyncBoundary: React.FC<AsyncBoundaryProps> = ({
|
|
||||||
isLoading = false,
|
|
||||||
isError = false,
|
|
||||||
isEmpty = false,
|
|
||||||
error = null,
|
|
||||||
errorCode,
|
|
||||||
onRetry,
|
|
||||||
children,
|
|
||||||
...rest
|
|
||||||
}) => {
|
|
||||||
let activeState: 'loading' | 'error' | 'empty' | 'success' = 'success';
|
|
||||||
|
|
||||||
if (isLoading) {
|
|
||||||
activeState = 'loading';
|
|
||||||
} else if (isError || Boolean(error)) {
|
|
||||||
activeState = 'error';
|
|
||||||
} else if (isEmpty) {
|
|
||||||
activeState = 'empty';
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<StateView
|
|
||||||
state={activeState}
|
|
||||||
error={error}
|
|
||||||
errorCode={errorCode}
|
|
||||||
onRetry={onRetry}
|
|
||||||
{...rest}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</StateView>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
import { useSubmitGuard } from './useSubmitGuard';
|
|
||||||
|
|
||||||
export interface GuardedSubmitButtonProps extends ButtonProps {
|
|
||||||
isSubmitting?: boolean;
|
|
||||||
onGuardedClick?: () => Promise<void>;
|
|
||||||
}
|
|
||||||
|
|
||||||
export const GuardedSubmitButton: React.FC<GuardedSubmitButtonProps> = ({
|
|
||||||
isSubmitting = false,
|
|
||||||
disabled,
|
|
||||||
onClick,
|
|
||||||
onGuardedClick,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}) => {
|
|
||||||
const { isSubmitting: localSubmitting, runGuarded } = useSubmitGuard();
|
|
||||||
const effectiveSubmitting = isSubmitting || localSubmitting;
|
|
||||||
|
|
||||||
const handleClick = (e: React.MouseEvent<HTMLButtonElement>) => {
|
|
||||||
if (effectiveSubmitting) {
|
|
||||||
e.preventDefault();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (onGuardedClick) {
|
|
||||||
void runGuarded(onGuardedClick);
|
|
||||||
} else if (onClick) {
|
|
||||||
onClick(e);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<Button
|
|
||||||
disabled={disabled || effectiveSubmitting}
|
|
||||||
loading={effectiveSubmitting}
|
|
||||||
onClick={handleClick}
|
|
||||||
{...props}
|
|
||||||
>
|
|
||||||
{children}
|
|
||||||
</Button>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
@ -1,195 +0,0 @@
|
||||||
import React from 'react';
|
|
||||||
import {
|
|
||||||
ShieldAlert,
|
|
||||||
AlertTriangle,
|
|
||||||
AlertCircle,
|
|
||||||
WifiOff,
|
|
||||||
RotateCcw,
|
|
||||||
Inbox,
|
|
||||||
LucideIcon,
|
|
||||||
} from 'lucide-react';
|
|
||||||
import { Button } from './Button';
|
|
||||||
import { Skeleton } from './Skeleton';
|
|
||||||
import { EmptyState } from './EmptyState';
|
|
||||||
|
|
||||||
export type StateViewErrorCode = 403 | 409 | 422 | 500 | 'offline' | 'unknown';
|
|
||||||
|
|
||||||
export interface StateViewProps {
|
|
||||||
state?: 'loading' | 'empty' | 'error' | 'success';
|
|
||||||
loadingLines?: number;
|
|
||||||
// Error state options
|
|
||||||
error?: Error | string | null;
|
|
||||||
errorCode?: StateViewErrorCode;
|
|
||||||
onRetry?: () => void;
|
|
||||||
retryText?: string;
|
|
||||||
// Empty state options
|
|
||||||
emptyTitle?: string;
|
|
||||||
emptyDescription?: string;
|
|
||||||
emptyIcon?: LucideIcon;
|
|
||||||
emptyActionText?: string;
|
|
||||||
onEmptyAction?: () => void;
|
|
||||||
// Custom slots
|
|
||||||
loadingComponent?: React.ReactNode;
|
|
||||||
emptyComponent?: React.ReactNode;
|
|
||||||
errorComponent?: React.ReactNode;
|
|
||||||
children?: React.ReactNode;
|
|
||||||
className?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
function detectErrorCode(error?: Error | string | null): StateViewErrorCode {
|
|
||||||
if (!error) return 'unknown';
|
|
||||||
const msg = typeof error === 'string' ? error : error.message || '';
|
|
||||||
if (msg.includes('403') || msg.toLowerCase().includes('forbidden') || msg.toLowerCase().includes('доступ запрещен')) {
|
|
||||||
return 403;
|
|
||||||
}
|
|
||||||
if (msg.includes('409') || msg.toLowerCase().includes('conflict') || msg.toLowerCase().includes('конфликт')) {
|
|
||||||
return 409;
|
|
||||||
}
|
|
||||||
if (msg.includes('422') || msg.toLowerCase().includes('validation') || msg.toLowerCase().includes('валидаци')) {
|
|
||||||
return 422;
|
|
||||||
}
|
|
||||||
if (msg.includes('500') || msg.toLowerCase().includes('server') || msg.toLowerCase().includes('сервер')) {
|
|
||||||
return 500;
|
|
||||||
}
|
|
||||||
if (msg.toLowerCase().includes('network') || msg.toLowerCase().includes('offline') || msg.toLowerCase().includes('сеть')) {
|
|
||||||
return 'offline';
|
|
||||||
}
|
|
||||||
return 'unknown';
|
|
||||||
}
|
|
||||||
|
|
||||||
export const StateView: React.FC<StateViewProps> = ({
|
|
||||||
state = 'success',
|
|
||||||
loadingLines = 4,
|
|
||||||
error,
|
|
||||||
errorCode,
|
|
||||||
onRetry,
|
|
||||||
retryText = 'Повторить',
|
|
||||||
emptyTitle = 'Нет данных',
|
|
||||||
emptyDescription = 'В данном разделе пока нет записей или примененные фильтры не дали результатов.',
|
|
||||||
emptyIcon = Inbox,
|
|
||||||
emptyActionText,
|
|
||||||
onEmptyAction,
|
|
||||||
loadingComponent,
|
|
||||||
emptyComponent,
|
|
||||||
errorComponent,
|
|
||||||
children,
|
|
||||||
className = '',
|
|
||||||
}) => {
|
|
||||||
// 1. Loading State
|
|
||||||
if (state === 'loading') {
|
|
||||||
if (loadingComponent) return <>{loadingComponent}</>;
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="status"
|
|
||||||
aria-label="Загрузка данных"
|
|
||||||
className={`p-6 space-y-3 animate-pulse bg-krona-surface rounded-xl border border-krona-border ${className}`}
|
|
||||||
>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<Skeleton variant="circular" width={36} height={36} />
|
|
||||||
<div className="space-y-1.5 flex-1">
|
|
||||||
<Skeleton variant="text" width="40%" height={16} />
|
|
||||||
<Skeleton variant="text" width="60%" height={12} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Skeleton variant="rectangular" height={120} lines={loadingLines} />
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 2. Error State (403, 409, 422, 500, offline)
|
|
||||||
if (state === 'error' || Boolean(error)) {
|
|
||||||
if (errorComponent) return <>{errorComponent}</>;
|
|
||||||
|
|
||||||
const resolvedCode = errorCode || detectErrorCode(error);
|
|
||||||
const errorMessage = typeof error === 'string' ? error : error?.message;
|
|
||||||
|
|
||||||
let icon = <AlertCircle className="w-8 h-8 text-rose-600" />;
|
|
||||||
let title = 'Произошла ошибка при загрузке';
|
|
||||||
let description = errorMessage || 'Не удалось выполнить операцию. Пожалуйста, повторите попытку.';
|
|
||||||
let codeBadge = 'Ошибка';
|
|
||||||
|
|
||||||
switch (resolvedCode) {
|
|
||||||
case 403:
|
|
||||||
icon = <ShieldAlert className="w-8 h-8 text-amber-600" />;
|
|
||||||
title = '403: Доступ запрещен';
|
|
||||||
description =
|
|
||||||
errorMessage ||
|
|
||||||
'У вашей текущей роли недостаточно прав для просмотра данного ресурса или операции.';
|
|
||||||
codeBadge = '403 Forbidden';
|
|
||||||
break;
|
|
||||||
case 409:
|
|
||||||
icon = <AlertTriangle className="w-8 h-8 text-amber-500" />;
|
|
||||||
title = '409: Конфликт параллельного изменения';
|
|
||||||
description =
|
|
||||||
errorMessage ||
|
|
||||||
'Запись была изменена другим пользователем или процессом. Обновите данные перед сохранением.';
|
|
||||||
codeBadge = '409 Conflict';
|
|
||||||
break;
|
|
||||||
case 422:
|
|
||||||
icon = <AlertCircle className="w-8 h-8 text-rose-500" />;
|
|
||||||
title = '422: Ошибка валидации данных';
|
|
||||||
description =
|
|
||||||
errorMessage ||
|
|
||||||
'Переданные параметры не соответствуют доменным инвариантам или схемам полей.';
|
|
||||||
codeBadge = '422 Validation Error';
|
|
||||||
break;
|
|
||||||
case 500:
|
|
||||||
icon = <AlertCircle className="w-8 h-8 text-rose-600" />;
|
|
||||||
title = '500: Внутренняя ошибка сервера';
|
|
||||||
description =
|
|
||||||
errorMessage ||
|
|
||||||
'Сервис временно недоступен. Попробуйте повторить запрос через несколько секунд.';
|
|
||||||
codeBadge = '500 Server Error';
|
|
||||||
break;
|
|
||||||
case 'offline':
|
|
||||||
icon = <WifiOff className="w-8 h-8 text-slate-500" />;
|
|
||||||
title = 'Сетевая ошибка / Офлайн';
|
|
||||||
description = 'Проверьте интернет-соединение или статус локального хранилища IndexedDB.';
|
|
||||||
codeBadge = 'Network / Offline';
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
role="alert"
|
|
||||||
aria-live="assertive"
|
|
||||||
className={`flex flex-col items-center justify-center p-8 text-center bg-rose-50/50 dark:bg-rose-950/10 border border-rose-200 dark:border-rose-900/40 rounded-xl my-4 space-y-3 ${className}`}
|
|
||||||
>
|
|
||||||
<div className="p-3 bg-white dark:bg-rose-900/20 rounded-2xl shadow-sm border border-rose-100 dark:border-rose-800/40">
|
|
||||||
{icon}
|
|
||||||
</div>
|
|
||||||
<div className="space-y-1 max-w-md">
|
|
||||||
<div className="inline-block px-2 py-0.5 mb-1 text-[11px] font-mono font-bold rounded-md bg-rose-100 text-rose-800 dark:bg-rose-900/40 dark:text-rose-300">
|
|
||||||
{codeBadge}
|
|
||||||
</div>
|
|
||||||
<h4 className="text-sm font-bold text-krona-primary">{title}</h4>
|
|
||||||
<p className="text-xs text-krona-secondary leading-relaxed">{description}</p>
|
|
||||||
</div>
|
|
||||||
{onRetry && (
|
|
||||||
<Button variant="outline" size="sm" onClick={onRetry} className="mt-2">
|
|
||||||
<RotateCcw className="w-3.5 h-3.5 mr-1.5" />
|
|
||||||
{retryText}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 3. Empty State
|
|
||||||
if (state === 'empty') {
|
|
||||||
if (emptyComponent) return <>{emptyComponent}</>;
|
|
||||||
return (
|
|
||||||
<EmptyState
|
|
||||||
title={emptyTitle}
|
|
||||||
description={emptyDescription}
|
|
||||||
icon={emptyIcon}
|
|
||||||
actionText={emptyActionText}
|
|
||||||
onAction={onEmptyAction}
|
|
||||||
className={className}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
// 4. Success / Idle -> Render children
|
|
||||||
return <>{children}</>;
|
|
||||||
};
|
|
||||||
|
|
@ -1,183 +0,0 @@
|
||||||
import { describe, it, expect, vi } from 'vitest';
|
|
||||||
import React from 'react';
|
|
||||||
import { render, screen, fireEvent, act } from '@testing-library/react';
|
|
||||||
import '@testing-library/jest-dom/vitest';
|
|
||||||
import { StateView } from '../StateView';
|
|
||||||
import { AsyncBoundary, GuardedSubmitButton } from '../AsyncBoundary';
|
|
||||||
import { useSubmitGuard } from '../useSubmitGuard';
|
|
||||||
|
|
||||||
describe('Async States, Error Codes & Submit Guard (F23)', () => {
|
|
||||||
describe('StateView Component', () => {
|
|
||||||
it('1. Renders accessible skeleton when state is loading', () => {
|
|
||||||
render(
|
|
||||||
<StateView state="loading">
|
|
||||||
<div>Content Loaded</div>
|
|
||||||
</StateView>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Content Loaded')).not.toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('2. Renders empty state with custom title and description', () => {
|
|
||||||
const handleCreate = vi.fn();
|
|
||||||
render(
|
|
||||||
<StateView
|
|
||||||
state="empty"
|
|
||||||
emptyTitle="Заказов пока нет"
|
|
||||||
emptyDescription="Создайте первый заказ через кнопку ниже"
|
|
||||||
emptyActionText="Создать заказ"
|
|
||||||
onEmptyAction={handleCreate}
|
|
||||||
>
|
|
||||||
<div>Content Loaded</div>
|
|
||||||
</StateView>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('Заказов пока нет')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('Создайте первый заказ через кнопку ниже')).toBeInTheDocument();
|
|
||||||
const actionBtn = screen.getByRole('button', { name: 'Создать заказ' });
|
|
||||||
fireEvent.click(actionBtn);
|
|
||||||
expect(handleCreate).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('3. Renders 403 Forbidden with security badge and explanation', () => {
|
|
||||||
render(
|
|
||||||
<StateView
|
|
||||||
state="error"
|
|
||||||
error="[403 Forbidden]: Отказано в доступе к ресурсу"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('403 Forbidden')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('403: Доступ запрещен')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('4. Renders 409 Conflict with concurrent modification notice', () => {
|
|
||||||
render(
|
|
||||||
<StateView
|
|
||||||
state="error"
|
|
||||||
errorCode={409}
|
|
||||||
error="Конфликт версий документа"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('409 Conflict')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('409: Конфликт параллельного изменения')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('5. Renders 422 Validation Error with schema violation info', () => {
|
|
||||||
render(
|
|
||||||
<StateView
|
|
||||||
state="error"
|
|
||||||
errorCode={422}
|
|
||||||
error="Поле email содержит некорректный адрес"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('422 Validation Error')).toBeInTheDocument();
|
|
||||||
expect(screen.getByText('422: Ошибка валидации данных')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('6. Renders retry button and triggers callback on click', () => {
|
|
||||||
const handleRetry = vi.fn();
|
|
||||||
render(
|
|
||||||
<StateView
|
|
||||||
state="error"
|
|
||||||
error="Сетевая ошибка при загрузке"
|
|
||||||
onRetry={handleRetry}
|
|
||||||
retryText="Попробовать снова"
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
|
|
||||||
const retryBtn = screen.getByRole('button', { name: /Попробовать снова/i });
|
|
||||||
expect(retryBtn).toBeInTheDocument();
|
|
||||||
fireEvent.click(retryBtn);
|
|
||||||
expect(handleRetry).toHaveBeenCalledTimes(1);
|
|
||||||
});
|
|
||||||
|
|
||||||
it('7. Renders children when state is success', () => {
|
|
||||||
render(
|
|
||||||
<StateView state="success">
|
|
||||||
<div>Успешно загруженные данные таблицы</div>
|
|
||||||
</StateView>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('Успешно загруженные данные таблицы')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
describe('AsyncBoundary & Submit Guard', () => {
|
|
||||||
it('8. AsyncBoundary switches dynamically between loading, error, and child content', () => {
|
|
||||||
const { rerender } = render(
|
|
||||||
<AsyncBoundary isLoading={true}>
|
|
||||||
<div>Таблица заказов</div>
|
|
||||||
</AsyncBoundary>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByRole('status')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Таблица заказов')).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
rerender(
|
|
||||||
<AsyncBoundary isLoading={false} isError={true} error="Ошибка сервера">
|
|
||||||
<div>Таблица заказов</div>
|
|
||||||
</AsyncBoundary>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByRole('alert')).toBeInTheDocument();
|
|
||||||
expect(screen.queryByText('Таблица заказов')).not.toBeInTheDocument();
|
|
||||||
|
|
||||||
rerender(
|
|
||||||
<AsyncBoundary isLoading={false} isError={false} isEmpty={false}>
|
|
||||||
<div>Таблица заказов</div>
|
|
||||||
</AsyncBoundary>
|
|
||||||
);
|
|
||||||
|
|
||||||
expect(screen.getByText('Таблица заказов')).toBeInTheDocument();
|
|
||||||
});
|
|
||||||
|
|
||||||
it('9. GuardedSubmitButton prevents double clicks during async execution', async () => {
|
|
||||||
let resolveAction: () => void;
|
|
||||||
const deferredPromise = new Promise<void>((resolve) => {
|
|
||||||
resolveAction = resolve;
|
|
||||||
});
|
|
||||||
const asyncAction = vi.fn().mockReturnValue(deferredPromise);
|
|
||||||
|
|
||||||
const TestComponent = () => {
|
|
||||||
const { isSubmitting, runGuarded } = useSubmitGuard();
|
|
||||||
return (
|
|
||||||
<GuardedSubmitButton
|
|
||||||
isSubmitting={isSubmitting}
|
|
||||||
onGuardedClick={async () => {
|
|
||||||
await runGuarded(asyncAction);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
Сохранить заказ
|
|
||||||
</GuardedSubmitButton>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
render(<TestComponent />);
|
|
||||||
const btn = screen.getByRole('button', { name: /Сохранить заказ/i });
|
|
||||||
|
|
||||||
// First click: triggers asyncAction
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(btn);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(asyncAction).toHaveBeenCalledTimes(1);
|
|
||||||
|
|
||||||
// Second rapid click while promise is unresolved: MUST BE IGNORED
|
|
||||||
await act(async () => {
|
|
||||||
fireEvent.click(btn);
|
|
||||||
});
|
|
||||||
|
|
||||||
expect(asyncAction).toHaveBeenCalledTimes(1); // Still 1! Double click prevented
|
|
||||||
|
|
||||||
// Resolve pending action
|
|
||||||
await act(async () => {
|
|
||||||
resolveAction!();
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
@ -14,6 +14,3 @@ export * from './Skeleton';
|
||||||
export * from './EmptyState';
|
export * from './EmptyState';
|
||||||
export * from './ErrorBoundary';
|
export * from './ErrorBoundary';
|
||||||
export * from './MaturityBadge';
|
export * from './MaturityBadge';
|
||||||
export * from './StateView';
|
|
||||||
export * from './AsyncBoundary';
|
|
||||||
export * from './useSubmitGuard';
|
|
||||||
|
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import { useState, useCallback } from 'react';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Hook to guard against double-clicks and concurrent form submissions (F23).
|
|
||||||
*/
|
|
||||||
export function useSubmitGuard() {
|
|
||||||
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
||||||
|
|
||||||
const runGuarded = useCallback(
|
|
||||||
async <T,>(action: () => Promise<T>): Promise<T | undefined> => {
|
|
||||||
if (isSubmitting) {
|
|
||||||
return undefined;
|
|
||||||
}
|
|
||||||
setIsSubmitting(true);
|
|
||||||
try {
|
|
||||||
const result = await action();
|
|
||||||
return result;
|
|
||||||
} finally {
|
|
||||||
setIsSubmitting(false);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[isSubmitting]
|
|
||||||
);
|
|
||||||
|
|
||||||
return { isSubmitting, runGuarded };
|
|
||||||
}
|
|
||||||
|
|
@ -1,6 +1,4 @@
|
||||||
import { create } from 'zustand';
|
import { create } from 'zustand';
|
||||||
import { useSessionStore } from '../core/session/sessionStore';
|
|
||||||
import { SystemRoleId } from '../core/permissions/types';
|
|
||||||
|
|
||||||
export type DemoStateOverride = 'normal' | 'loading' | 'empty' | 'error' | 'permission_denied';
|
export type DemoStateOverride = 'normal' | 'loading' | 'empty' | 'error' | 'permission_denied';
|
||||||
export type DemoErrorType = 'none' | 'network_error' | 'forbidden_403' | 'conflict_409' | 'validation_422';
|
export type DemoErrorType = 'none' | 'network_error' | 'forbidden_403' | 'conflict_409' | 'validation_422';
|
||||||
|
|
@ -34,8 +32,12 @@ interface DemoState {
|
||||||
const DEFAULT_DEMO_DATE = '2026-09-10T10:00:00.000Z';
|
const DEFAULT_DEMO_DATE = '2026-09-10T10:00:00.000Z';
|
||||||
|
|
||||||
export const useDemoStore = create<DemoState>((set) => ({
|
export const useDemoStore = create<DemoState>((set) => ({
|
||||||
currentProfileId: useSessionStore.getState().currentProfileId,
|
currentProfileId:
|
||||||
currentRoleId: useSessionStore.getState().currentRoleId,
|
(typeof window !== 'undefined' && localStorage.getItem('krona_profile_id')) ||
|
||||||
|
'multi-business',
|
||||||
|
currentRoleId:
|
||||||
|
(typeof window !== 'undefined' && localStorage.getItem('krona_role_id')) ||
|
||||||
|
'owner',
|
||||||
simulateLatency: false,
|
simulateLatency: false,
|
||||||
latencyMs: 300,
|
latencyMs: 300,
|
||||||
simulateErrors: false,
|
simulateErrors: false,
|
||||||
|
|
@ -46,11 +48,15 @@ export const useDemoStore = create<DemoState>((set) => ({
|
||||||
selectedScenario: 'retail-flow',
|
selectedScenario: 'retail-flow',
|
||||||
|
|
||||||
setCurrentProfileId: (profileId) => {
|
setCurrentProfileId: (profileId) => {
|
||||||
useSessionStore.getState().setCurrentProfileId(profileId);
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('krona_profile_id', profileId);
|
||||||
|
}
|
||||||
set({ currentProfileId: profileId });
|
set({ currentProfileId: profileId });
|
||||||
},
|
},
|
||||||
setCurrentRoleId: (roleId) => {
|
setCurrentRoleId: (roleId) => {
|
||||||
useSessionStore.getState().setCurrentRoleId(roleId as SystemRoleId);
|
if (typeof window !== 'undefined') {
|
||||||
|
localStorage.setItem('krona_role_id', roleId);
|
||||||
|
}
|
||||||
set({ currentRoleId: roleId });
|
set({ currentRoleId: roleId });
|
||||||
},
|
},
|
||||||
setSimulateLatency: (enable) => set({ simulateLatency: enable }),
|
setSimulateLatency: (enable) => set({ simulateLatency: enable }),
|
||||||
|
|
@ -67,11 +73,10 @@ export const useDemoStore = create<DemoState>((set) => ({
|
||||||
setStateOverride: (override) => set({ stateOverride: override }),
|
setStateOverride: (override) => set({ stateOverride: override }),
|
||||||
setShowMaturityBadges: (show) => set({ showMaturityBadges: show }),
|
setShowMaturityBadges: (show) => set({ showMaturityBadges: show }),
|
||||||
setSelectedScenario: (scenario) => set({ selectedScenario: scenario }),
|
setSelectedScenario: (scenario) => set({ selectedScenario: scenario }),
|
||||||
resetDemoControls: () => {
|
resetDemoControls: () =>
|
||||||
useSessionStore.getState().resetSession();
|
|
||||||
set({
|
set({
|
||||||
currentProfileId: useSessionStore.getState().currentProfileId,
|
currentProfileId: 'multi-business',
|
||||||
currentRoleId: useSessionStore.getState().currentRoleId,
|
currentRoleId: 'owner',
|
||||||
simulateLatency: false,
|
simulateLatency: false,
|
||||||
latencyMs: 300,
|
latencyMs: 300,
|
||||||
simulateErrors: false,
|
simulateErrors: false,
|
||||||
|
|
@ -80,13 +85,5 @@ export const useDemoStore = create<DemoState>((set) => ({
|
||||||
stateOverride: 'normal',
|
stateOverride: 'normal',
|
||||||
showMaturityBadges: true,
|
showMaturityBadges: true,
|
||||||
selectedScenario: 'retail-flow',
|
selectedScenario: 'retail-flow',
|
||||||
});
|
}),
|
||||||
},
|
|
||||||
}));
|
}));
|
||||||
|
|
||||||
useSessionStore.subscribe((session) => {
|
|
||||||
useDemoStore.setState({
|
|
||||||
currentRoleId: session.currentRoleId,
|
|
||||||
currentProfileId: session.currentProfileId,
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
|
||||||
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue