Compare commits
10 commits
9dbe9b7735
...
257502db4a
| Author | SHA1 | Date | |
|---|---|---|---|
| 257502db4a | |||
| d1ccc1c197 | |||
| 5200b7ebd5 | |||
| 42ac2f478a | |||
| 21fabde520 | |||
| a29d683edb | |||
| ae35865829 | |||
| 8910d5dd6a | |||
| fcabd60523 | |||
| ffc5646ff5 |
55 changed files with 4787 additions and 426 deletions
43
docs/audit/ASTRA-6-REVALIDATION.md
Normal file
43
docs/audit/ASTRA-6-REVALIDATION.md
Normal file
|
|
@ -0,0 +1,43 @@
|
|||
# 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,4 +1,5 @@
|
|||
import React from 'react';
|
||||
import { useSessionStore } from '../../core/session/sessionStore';
|
||||
import { useDemoStore } from '../../stores/demo';
|
||||
import { SYSTEM_ROLES, SystemRoleId } from '../../core/permissions/types';
|
||||
import { BUSINESS_PROFILES } from '../../core/profiles/profiles';
|
||||
|
|
@ -19,7 +20,8 @@ export const RouteGuard: React.FC<RouteGuardProps> = ({
|
|||
requiredCapability,
|
||||
children,
|
||||
}) => {
|
||||
const { currentRoleId, currentProfileId, stateOverride } = useDemoStore();
|
||||
const { currentRoleId, currentProfileId, capabilities, hasModulePermission } = useSessionStore();
|
||||
const { stateOverride } = useDemoStore();
|
||||
|
||||
// 1. Check Demo State Override
|
||||
if (stateOverride === 'permission_denied') {
|
||||
|
|
@ -34,29 +36,26 @@ export const RouteGuard: React.FC<RouteGuardProps> = ({
|
|||
throw new Error(`[Демо-ошибка]: Имитация сбоя при загрузке раздела «${moduleName || module}».`);
|
||||
}
|
||||
|
||||
// 2. Check RBAC Role permissions
|
||||
// 2. Strict deny-by-default role permission check
|
||||
const role = SYSTEM_ROLES[currentRoleId as SystemRoleId];
|
||||
if (role) {
|
||||
const isSuperRole = role.id === 'owner' || role.id === 'administrator' || role.allowedModules.includes('*');
|
||||
if (!isSuperRole && !role.allowedModules.includes(module)) {
|
||||
return (
|
||||
<ForbiddenPage
|
||||
moduleName={moduleName || module}
|
||||
requiredPermission={`Доступ к модулю «${module}» для роли «${role.label}»`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (!role || !hasModulePermission(module)) {
|
||||
return (
|
||||
<ForbiddenPage
|
||||
moduleName={moduleName || module}
|
||||
requiredPermission={`Доступ к модулю «${module}» для роли «${role?.label || 'Не определена'}»`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// 3. Check Business Profile Capabilities
|
||||
const profile = BUSINESS_PROFILES[currentProfileId];
|
||||
if (profile && requiredCapability) {
|
||||
const isCapabilityEnabled = profile.capabilities[requiredCapability];
|
||||
if (requiredCapability) {
|
||||
const isCapabilityEnabled = capabilities ? capabilities[requiredCapability] : profile?.capabilities[requiredCapability];
|
||||
if (isCapabilityEnabled === false) {
|
||||
return (
|
||||
<ForbiddenPage
|
||||
moduleName={moduleName || module}
|
||||
requiredPermission={`Возможность «${requiredCapability}» в бизнес-профиле «${profile.name}»`}
|
||||
requiredPermission={`Возможность «${requiredCapability}» в бизнес-профиле «${profile?.name || currentProfileId}»`}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useEffect, useMemo, useCallback } from 'react';
|
||||
import React, { useEffect, useMemo, useCallback } from 'react';
|
||||
import {
|
||||
DEFAULT_TENANT_ID,
|
||||
createRequestContext,
|
||||
|
|
@ -10,6 +10,7 @@ import {
|
|||
PRESET_TENANTS,
|
||||
TenantContextValue,
|
||||
} from './tenantContext';
|
||||
import { useSessionStore } from '../../core/session/sessionStore';
|
||||
|
||||
export interface TenantProviderProps {
|
||||
children: React.ReactNode;
|
||||
|
|
@ -20,15 +21,20 @@ export const TenantProvider: React.FC<TenantProviderProps> = ({
|
|||
children,
|
||||
initialTenantId,
|
||||
}) => {
|
||||
const [currentTenantId, setCurrentTenantId] = useState<string>(() => {
|
||||
return initialTenantId || getActiveGlobalTenantId() || DEFAULT_TENANT_ID;
|
||||
});
|
||||
const sessionTenantId = useSessionStore((s) => s.activeTenantId);
|
||||
const currentTenantId = sessionTenantId || initialTenantId || getActiveGlobalTenantId() || DEFAULT_TENANT_ID;
|
||||
|
||||
const switchTenant = useCallback((tenantId: string) => {
|
||||
setCurrentTenantId(tenantId);
|
||||
setActiveGlobalTenantId(tenantId);
|
||||
useSessionStore.getState().setActiveTenantId(tenantId);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (initialTenantId && initialTenantId !== sessionTenantId) {
|
||||
useSessionStore.getState().setActiveTenantId(initialTenantId);
|
||||
}
|
||||
}, [initialTenantId, sessionTenantId]);
|
||||
|
||||
useEffect(() => {
|
||||
setActiveGlobalTenantId(currentTenantId);
|
||||
}, [currentTenantId]);
|
||||
|
|
|
|||
127
src/app/router/__tests__/routeTabsDeepLinks.test.tsx
Normal file
127
src/app/router/__tests__/routeTabsDeepLinks.test.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
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 { NavLink } from 'react-router-dom';
|
||||
import { useDemoStore } from '../../mock/store';
|
||||
import { useSessionStore } from '../../core/session/sessionStore';
|
||||
import { useMetadata } from '../../services/hooks/useMetadata';
|
||||
import { SYSTEM_MODULES } from '../../core/modules/registry';
|
||||
import { SYSTEM_ROLES } from '../../core/permissions/types';
|
||||
|
|
@ -55,19 +55,13 @@ export interface SidebarProps {
|
|||
export const Sidebar: React.FC<SidebarProps> = ({ isTabletCompact = false }) => {
|
||||
const [userCollapsed, setUserCollapsed] = useState<boolean | null>(null);
|
||||
const collapsed = userCollapsed !== null ? userCollapsed : isTabletCompact;
|
||||
const { currentRoleId } = useDemoStore();
|
||||
const { currentRoleId, hasModulePermission } = useSessionStore();
|
||||
const { entities } = useMetadata();
|
||||
const customEntities = entities.filter((e) => !e.isSystem);
|
||||
const role = SYSTEM_ROLES[currentRoleId];
|
||||
|
||||
// Filter modules by role permissions (industry profile does not hide modules)
|
||||
const visibleModules = SYSTEM_MODULES.filter((m) => {
|
||||
// Role check
|
||||
if (!role.allowedModules.includes('*') && !role.allowedModules.includes(m.id)) {
|
||||
return false;
|
||||
}
|
||||
return true;
|
||||
});
|
||||
// Filter modules by role permissions (strict deny-by-default via sessionStore)
|
||||
const visibleModules = SYSTEM_MODULES.filter((m) => hasModulePermission(m.id));
|
||||
|
||||
return (
|
||||
<aside
|
||||
|
|
|
|||
234
src/core/session/sessionStore.ts
Normal file
234
src/core/session/sessionStore.ts
Normal file
|
|
@ -0,0 +1,234 @@
|
|||
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,6 +688,7 @@ export function generateSeedFinance(payments: Payment[]): {
|
|||
accountId: 'acc_main_bank',
|
||||
accountName: 'Основной расчетный счет',
|
||||
referenceType: 'manual',
|
||||
referenceId: `ref_outflow_${idx + 1}`,
|
||||
description: o.name,
|
||||
timestamp,
|
||||
audit: {
|
||||
|
|
|
|||
|
|
@ -3,14 +3,16 @@ import { BaseEntity, MoneyAmount } from '../types';
|
|||
export type OrderStatus =
|
||||
| 'draft'
|
||||
| 'confirmed'
|
||||
| 'preparing'
|
||||
| 'processing'
|
||||
| 'ready_for_shipment'
|
||||
| 'ready'
|
||||
| 'shipped'
|
||||
| 'delivered'
|
||||
| 'completed'
|
||||
| 'cancelled';
|
||||
|
||||
export type PaymentStatus = 'pending' | 'partially_paid' | 'paid' | 'refunded';
|
||||
export type PaymentStatus = 'unpaid' | 'pending' | 'partially_paid' | 'paid' | 'overpaid' | 'refunded';
|
||||
export type FulfillmentStatus = 'unfulfilled' | 'partially_fulfilled' | 'fulfilled' | 'returned';
|
||||
|
||||
export interface OrderItem {
|
||||
|
|
@ -43,4 +45,5 @@ export interface Order extends BaseEntity {
|
|||
channel: 'pos' | 'web' | 'mobile' | 'b2b' | 'manual';
|
||||
shippingAddress?: string;
|
||||
notes?: string;
|
||||
dealId?: string;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ export interface Contact extends BaseEntity {
|
|||
firstName: string;
|
||||
lastName: string;
|
||||
middleName?: string;
|
||||
roles: ContactRole[];
|
||||
roles: (ContactRole | string)[];
|
||||
companyId?: string; // Reference to Company
|
||||
jobTitle?: string;
|
||||
email?: string;
|
||||
|
|
@ -14,5 +14,6 @@ export interface Contact extends BaseEntity {
|
|||
telegram?: string;
|
||||
avatarUrl?: string;
|
||||
notes?: string;
|
||||
assignedTo?: string;
|
||||
status: 'active' | 'archived' | 'blocked';
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,18 @@ import { BaseEntity, MoneyAmount } from '../types';
|
|||
|
||||
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 {
|
||||
title: string;
|
||||
pipelineId: string;
|
||||
|
|
@ -13,5 +25,8 @@ export interface Deal extends BaseEntity {
|
|||
contactId?: string;
|
||||
companyId?: string;
|
||||
assignedUserId?: string;
|
||||
assignedTo?: string;
|
||||
lostReason?: string;
|
||||
orderId?: string;
|
||||
items?: DealItem[];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -6,12 +6,14 @@ export interface Lead extends BaseEntity {
|
|||
title: string;
|
||||
contactName: string;
|
||||
companyName?: string;
|
||||
companyId?: string;
|
||||
phone?: string;
|
||||
email?: string;
|
||||
source: 'website' | 'phone' | 'referral' | 'telegram' | 'ads' | 'pos' | 'other';
|
||||
status: LeadStatus;
|
||||
estimatedValue?: MoneyAmount;
|
||||
assignedUserId?: string;
|
||||
assignedTo?: string;
|
||||
score?: number; // 0-100
|
||||
convertedContactId?: string;
|
||||
convertedDealId?: string;
|
||||
|
|
|
|||
308
src/features/__tests__/auditProbesNegativeMatrix.test.ts
Normal file
308
src/features/__tests__/auditProbesNegativeMatrix.test.ts
Normal file
|
|
@ -0,0 +1,308 @@
|
|||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
216
src/features/__tests__/crmEndToEndIntegrity.test.tsx
Normal file
216
src/features/__tests__/crmEndToEndIntegrity.test.tsx
Normal file
|
|
@ -0,0 +1,216 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
127
src/features/__tests__/rbacSessionIntegrity.test.tsx
Normal file
127
src/features/__tests__/rbacSessionIntegrity.test.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
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,6 +8,8 @@ import { Contact } from '../../entities/crm/contact';
|
|||
import { Company } from '../../entities/crm/company';
|
||||
import { Deal, DealStage } from '../../entities/crm/deal';
|
||||
import { Lead } from '../../entities/crm/lead';
|
||||
import { OrderItem } from '../../entities/commerce/order';
|
||||
import { MoneyAmount } from '../../entities/types';
|
||||
import { MaturityBadge } from '../../shared/ui/MaturityBadge';
|
||||
import { useToast } from '../../shared/ui/useToast';
|
||||
import { CsvImportWizard } from './CsvImportWizard';
|
||||
|
|
@ -41,7 +43,7 @@ export const CrmModule: React.FC = () => {
|
|||
createActivity,
|
||||
} = useCrm();
|
||||
|
||||
const { orders, createOrder } = useOrders();
|
||||
const { orders, products, createOrder } = useOrders();
|
||||
|
||||
const contactMeta = useMetadata('contact');
|
||||
const companyMeta = useMetadata('company');
|
||||
|
|
@ -74,15 +76,13 @@ export const CrmModule: React.FC = () => {
|
|||
const [selectedContact, setSelectedContact] = useState<Contact | null>(null);
|
||||
const [selectedCompany, setSelectedCompany] = useState<Company | null>(null);
|
||||
|
||||
// Sync tab with URL on external route changes
|
||||
const [prevPath, setPrevPath] = useState(location.pathname);
|
||||
if (prevPath !== location.pathname) {
|
||||
setPrevPath(location.pathname);
|
||||
const p = location.pathname;
|
||||
if (p.includes('/crm/deals')) setActiveTab('deals');
|
||||
else if (p.includes('/crm/leads')) setActiveTab('leads');
|
||||
else if (p.includes('/crm/companies')) setActiveTab('companies');
|
||||
else if (p.includes('/crm/import')) setActiveTab('import');
|
||||
// Synchronize active tab with URL path if navigated externally
|
||||
const p = location.pathname;
|
||||
if (p !== '/crm' && p !== '/crm/') {
|
||||
if (p.includes('/crm/deals') && activeTab !== 'deals') setActiveTab('deals');
|
||||
else if (p.includes('/crm/leads') && activeTab !== 'leads') setActiveTab('leads');
|
||||
else if (p.includes('/crm/companies') && activeTab !== 'companies') setActiveTab('companies');
|
||||
else if (p.includes('/crm/import') && activeTab !== 'import') setActiveTab('import');
|
||||
else if (p.includes('/crm/contacts') || p === '/crm') setActiveTab('contacts');
|
||||
}
|
||||
|
||||
|
|
@ -101,40 +101,95 @@ export const CrmModule: React.FC = () => {
|
|||
};
|
||||
|
||||
// Confirm Deal Won -> Order creation
|
||||
const handleConfirmOrderCreation = async ({ orderTitle, orderWarehouse }: { orderTitle: string; orderWarehouse: string }) => {
|
||||
const handleConfirmOrderCreation = async ({
|
||||
orderTitle,
|
||||
orderWarehouse,
|
||||
items: customItems,
|
||||
}: {
|
||||
orderTitle: string;
|
||||
orderWarehouse: string;
|
||||
items?: OrderItem[];
|
||||
}) => {
|
||||
if (!wonDeal) return;
|
||||
try {
|
||||
const customer = contacts.find((c) => c.id === wonDeal.contactId);
|
||||
const company = companies.find((c) => c.id === wonDeal.companyId);
|
||||
const customerName = customer ? `${customer.firstName} ${customer.lastName}` : (company?.name || 'Клиент');
|
||||
const customerName = customer
|
||||
? `${customer.firstName} ${customer.lastName}`.trim()
|
||||
: company?.name || 'Клиент';
|
||||
const orderNumber = `ORD-${ulid().slice(-6)}`;
|
||||
|
||||
await createOrder({
|
||||
let orderItems: OrderItem[];
|
||||
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,
|
||||
customerType: wonDeal.contactId ? 'contact' : 'company',
|
||||
customerId: wonDeal.contactId || wonDeal.companyId || 'cnt_default',
|
||||
customerId: wonDeal.contactId || wonDeal.companyId || (customer?.id ?? company?.id ?? 'cust_crm'),
|
||||
customerName,
|
||||
status: 'draft',
|
||||
paymentStatus: 'pending',
|
||||
paymentStatus: 'unpaid',
|
||||
fulfillmentStatus: 'unfulfilled',
|
||||
items: [{
|
||||
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,
|
||||
items: orderItems,
|
||||
subtotal: orderTotal,
|
||||
discountMinor: 0,
|
||||
taxMinor: Math.round(wonDeal.amount.minor * 0.2),
|
||||
taxMinor: Math.round(orderTotal.minor * 0.2),
|
||||
shippingMinor: 0,
|
||||
total: wonDeal.amount,
|
||||
total: orderTotal,
|
||||
warehouseId: orderWarehouse,
|
||||
channel: 'b2b',
|
||||
dealId: wonDeal.id,
|
||||
});
|
||||
|
||||
// Two-way link: deal -> order
|
||||
await updateDeal(wonDeal.id, { orderId: createdOrder.id });
|
||||
|
||||
showToast(`Связанный Заказ ${orderNumber} успешно создан в Коммерции! (Склад: ${orderWarehouse})`, 'success');
|
||||
setIsDealWonModalOpen(false);
|
||||
setWonDeal(null);
|
||||
|
|
|
|||
|
|
@ -101,14 +101,25 @@ export const CrmEntityModals: React.FC<CrmEntityModalsProps> = ({
|
|||
layout={contactMeta.layouts?.[0] || null}
|
||||
mode="create"
|
||||
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({
|
||||
firstName: String(values.firstName || 'Новый'),
|
||||
lastName: String(values.lastName || 'Контакт'),
|
||||
middleName: values.middleName ? String(values.middleName) : undefined,
|
||||
phone: String(values.phone || ''),
|
||||
email: String(values.email || ''),
|
||||
jobTitle: String(values.jobTitle || ''),
|
||||
roles: ['client'],
|
||||
status: 'active',
|
||||
companyId: values.companyId ? String(values.companyId) : undefined,
|
||||
roles: rolesList.length > 0 ? (rolesList as Contact['roles']) : ['client'],
|
||||
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,
|
||||
});
|
||||
onCloseContact();
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState } from 'react';
|
||||
import { Deal } from '../../../entities/crm/deal';
|
||||
import { OrderItem } from '../../../entities/commerce/order';
|
||||
import { Modal } from '../../../shared/ui/Modal';
|
||||
import { Button } from '../../../shared/ui/Button';
|
||||
import { formatCurrencyMinor } from '../../../shared/lib/currency';
|
||||
|
|
@ -9,7 +10,11 @@ export interface DealWonOrderModalProps {
|
|||
isOpen: boolean;
|
||||
wonDeal: Deal | null;
|
||||
onClose: () => void;
|
||||
onConfirmOrder: (params: { orderTitle: string; orderWarehouse: string }) => Promise<void> | void;
|
||||
onConfirmOrder: (params: {
|
||||
orderTitle: string;
|
||||
orderWarehouse: string;
|
||||
items?: OrderItem[];
|
||||
}) => Promise<void> | void;
|
||||
}
|
||||
|
||||
export const DealWonOrderModal: React.FC<DealWonOrderModalProps> = ({
|
||||
|
|
@ -42,7 +47,7 @@ export const DealWonOrderModal: React.FC<DealWonOrderModalProps> = ({
|
|||
<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">
|
||||
<strong>Поздравляем с успехом!</strong> Сделка «{wonDeal?.title}» переведена в статус «Выиграна».
|
||||
Система предлагает автоматически сформировать коммерческий Заказ.
|
||||
Система предлагает автоматически сформировать коммерческий Заказ с двусторонней связью.
|
||||
</div>
|
||||
|
||||
<div className="space-y-3">
|
||||
|
|
@ -60,13 +65,33 @@ export const DealWonOrderModal: React.FC<DealWonOrderModalProps> = ({
|
|||
|
||||
<div>
|
||||
<label className="block font-semibold text-krona-primary mb-1">
|
||||
Сумма заказа (RUB):
|
||||
Сумма заказа:
|
||||
</label>
|
||||
<div className="font-bold text-sm text-krona-green py-1">
|
||||
{wonDeal ? formatCurrencyMinor(wonDeal.amount.minor) : '0 ₽'}
|
||||
</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>
|
||||
<label className="block font-semibold text-krona-primary mb-1">
|
||||
Склад отгрузки:
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { INITIAL_EMPLOYEES, EmployeeEntity } from '../../mock/mockData';
|
||||
import { getStoreItem, putStoreItem } from '../../services/storage/indexedDb';
|
||||
import { Badge, Button, MaturityBadge, Modal, useToast } from '../../shared/ui';
|
||||
import {
|
||||
Clock,
|
||||
|
|
@ -51,6 +52,35 @@ export const EmployeesModule: React.FC = () => {
|
|||
const [shifts, setShifts] = useState<ShiftPlanItem[]>(INITIAL_SHIFTS);
|
||||
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
|
||||
const [isNewEmpModalOpen, setIsNewEmpModalOpen] = useState(false);
|
||||
const [newEmpName, setNewEmpName] = useState('');
|
||||
|
|
@ -83,7 +113,8 @@ export const EmployeesModule: React.FC = () => {
|
|||
avatarInitials: initials,
|
||||
};
|
||||
|
||||
setEmployees((prev) => [newEmp, ...prev]);
|
||||
const updatedEmployees = [newEmp, ...employees];
|
||||
persistEmployees(updatedEmployees);
|
||||
setIsNewEmpModalOpen(false);
|
||||
setNewEmpName('');
|
||||
showToast({
|
||||
|
|
@ -99,26 +130,24 @@ export const EmployeesModule: React.FC = () => {
|
|||
const hasConflict = editingShift.hours > 12;
|
||||
const conflictReason = hasConflict ? 'Смена превышает 12 ч (ТК РФ)' : undefined;
|
||||
|
||||
setShifts((prev) => {
|
||||
const filtered = prev.filter(
|
||||
(s) => !(s.employeeId === editingShift.empId && s.day === editingShift.day)
|
||||
);
|
||||
if (editingShift.shiftText === 'Выходной' || editingShift.hours === 0) {
|
||||
return filtered;
|
||||
}
|
||||
return [
|
||||
...filtered,
|
||||
{
|
||||
employeeId: editingShift.empId,
|
||||
day: editingShift.day,
|
||||
shiftText: editingShift.shiftText,
|
||||
hours: editingShift.hours,
|
||||
hasConflict,
|
||||
conflictReason,
|
||||
},
|
||||
];
|
||||
});
|
||||
const filtered = shifts.filter(
|
||||
(s) => !(s.employeeId === editingShift.empId && s.day === editingShift.day)
|
||||
);
|
||||
const updatedShifts = (editingShift.shiftText === 'Выходной' || editingShift.hours === 0)
|
||||
? filtered
|
||||
: [
|
||||
...filtered,
|
||||
{
|
||||
employeeId: editingShift.empId,
|
||||
day: editingShift.day,
|
||||
shiftText: editingShift.shiftText,
|
||||
hours: editingShift.hours,
|
||||
hasConflict,
|
||||
conflictReason,
|
||||
},
|
||||
];
|
||||
|
||||
persistShifts(updatedShifts);
|
||||
setEditingShift(null);
|
||||
showToast({
|
||||
title: 'Смена обновлена',
|
||||
|
|
|
|||
|
|
@ -1,4 +1,5 @@
|
|||
import React, { useState, useMemo } from 'react';
|
||||
import { useSearchParams } from 'react-router-dom';
|
||||
import { useFinance } from '../../services/hooks/useFinance';
|
||||
import { TransactionCategory } from '../../services/contracts/finance';
|
||||
import { Badge } from '../../shared/ui/Badge';
|
||||
|
|
@ -24,6 +25,7 @@ import {
|
|||
|
||||
export const FinanceModule: React.FC = () => {
|
||||
const { showToast } = useToast();
|
||||
const [searchParams, setSearchParams] = useSearchParams();
|
||||
const {
|
||||
accounts,
|
||||
transactions,
|
||||
|
|
@ -31,7 +33,19 @@ export const FinanceModule: React.FC = () => {
|
|||
createManualTransaction,
|
||||
} = useFinance();
|
||||
|
||||
const [activeTab, setActiveTab] = useState<'transactions' | 'pnl' | 'accounts'>('transactions');
|
||||
const rawTab = (searchParams.get('tab') || '').toLowerCase();
|
||||
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 [filterType, setFilterType] = useState<'all' | 'inflow' | 'outflow'>('all');
|
||||
|
||||
|
|
|
|||
|
|
@ -1,5 +1,6 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { INITIAL_TICKETS, TicketEntity } from '../../mock/mockData';
|
||||
import { getStoreItem, putStoreItem } from '../../services/storage/indexedDb';
|
||||
import { Badge, Button, MaturityBadge, Modal, useToast } from '../../shared/ui';
|
||||
import {
|
||||
AlertCircle,
|
||||
|
|
@ -33,6 +34,27 @@ const ASSIGNEES = ['Алина Соколова', 'Артем Васильев',
|
|||
export const HelpDeskModule: React.FC = () => {
|
||||
const { showToast } = useToast();
|
||||
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 [search, setSearch] = useState('');
|
||||
const [filterStatus, setFilterStatus] = useState<string>('all');
|
||||
|
|
@ -109,9 +131,8 @@ export const HelpDeskModule: React.FC = () => {
|
|||
};
|
||||
|
||||
const handleUpdateTicketStatus = (ticketId: string, nextStatus: ExtendedTicket['status']) => {
|
||||
setTickets((prev) =>
|
||||
prev.map((t) => (t.id === ticketId ? { ...t, status: nextStatus } : t))
|
||||
);
|
||||
const updated = tickets.map((t) => (t.id === ticketId ? { ...t, status: nextStatus } : t));
|
||||
persistTickets(updated);
|
||||
if (selectedTicket && selectedTicket.id === ticketId) {
|
||||
setSelectedTicket({ ...selectedTicket, status: nextStatus });
|
||||
}
|
||||
|
|
@ -123,9 +144,8 @@ export const HelpDeskModule: React.FC = () => {
|
|||
};
|
||||
|
||||
const handleUpdateAssignee = (ticketId: string, assignee: string) => {
|
||||
setTickets((prev) =>
|
||||
prev.map((t) => (t.id === ticketId ? { ...t, assignee } : t))
|
||||
);
|
||||
const updated = tickets.map((t) => (t.id === ticketId ? { ...t, assignee } : t));
|
||||
persistTickets(updated);
|
||||
if (selectedTicket && selectedTicket.id === ticketId) {
|
||||
setSelectedTicket({ ...selectedTicket, assignee });
|
||||
}
|
||||
|
|
@ -153,7 +173,8 @@ export const HelpDeskModule: React.FC = () => {
|
|||
: { publicReplies: [...(selectedTicket.publicReplies || []), newNote] }),
|
||||
};
|
||||
|
||||
setTickets((prev) => prev.map((t) => (t.id === selectedTicket.id ? updatedTicket : t)));
|
||||
const updatedTickets = tickets.map((t) => (t.id === selectedTicket.id ? updatedTicket : t));
|
||||
persistTickets(updatedTickets);
|
||||
setSelectedTicket(updatedTicket);
|
||||
setReplyText('');
|
||||
|
||||
|
|
|
|||
|
|
@ -130,9 +130,9 @@ export const MarketingModule: React.FC = () => {
|
|||
revenue: 74800,
|
||||
});
|
||||
showToast({
|
||||
title: 'Рассылка успешно завершена!',
|
||||
message: '340 сообщений отправлено в Telegram-бот. Оформлено 34 новых заказа.',
|
||||
type: 'success',
|
||||
title: 'Имитация рассылки завершена (Демо)',
|
||||
message: '340 сообщений смоделировано. Реальная отправка ожидает интеграции внешнего провайдера.',
|
||||
type: 'info',
|
||||
});
|
||||
}, 1200);
|
||||
};
|
||||
|
|
@ -241,12 +241,15 @@ export const MarketingModule: React.FC = () => {
|
|||
<div>
|
||||
<div className="flex items-center gap-2">
|
||||
<Sparkles className="w-4 h-4 text-krona-gold" />
|
||||
<h4 className="font-bold text-xs text-krona-primary">
|
||||
<h4 className="font-bold text-xs text-krona-primary flex items-center gap-1.5">
|
||||
Интерактивная симуляция запуска триггерной рассылки
|
||||
<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>
|
||||
</div>
|
||||
<p className="text-[11px] text-krona-secondary mt-0.5">
|
||||
Протестируйте отправку рассылки с промокодом по сегменту «Спящие клиенты» (340 получателей в базе)
|
||||
Протестируйте отправку рассылки с промокодом по сегменту «Спящие клиенты» (340 получателей в базе). Подключение внешнего провайдера ожидает интеграции бэкенда.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useState, useEffect } from 'react';
|
||||
import React, { useState, useEffect, useMemo } from 'react';
|
||||
import {
|
||||
EntityMetadata,
|
||||
FieldDefinition,
|
||||
|
|
@ -57,7 +57,7 @@ export interface DynamicCardRendererProps {
|
|||
export const DynamicCardRenderer: React.FC<DynamicCardRendererProps> = ({
|
||||
entity,
|
||||
record,
|
||||
layout: _layout,
|
||||
layout,
|
||||
onEdit,
|
||||
onDelete,
|
||||
onClose,
|
||||
|
|
@ -67,6 +67,16 @@ export const DynamicCardRenderer: React.FC<DynamicCardRendererProps> = ({
|
|||
const [auditLogs, setAuditLogs] = useState<AuditLogItem[]>([]);
|
||||
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
|
||||
useEffect(() => {
|
||||
async function loadAudit() {
|
||||
|
|
@ -435,27 +445,177 @@ export const DynamicCardRenderer: React.FC<DynamicCardRendererProps> = ({
|
|||
{/* Tab: Overview */}
|
||||
{activeTab === 'overview' && (
|
||||
<div className="space-y-4">
|
||||
<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.map((f) => (
|
||||
<div
|
||||
key={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)}
|
||||
{cardBlocks.length > 0 ? (
|
||||
cardBlocks.map((block) => {
|
||||
if (block.type === 'header') {
|
||||
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 flex items-center justify-between"
|
||||
>
|
||||
<div className="flex items-center gap-3">
|
||||
{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">
|
||||
{recordTitle.slice(0, 1).toUpperCase()}
|
||||
</div>
|
||||
)}
|
||||
<div>
|
||||
<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>
|
||||
))}
|
||||
);
|
||||
}
|
||||
|
||||
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>
|
||||
)}
|
||||
|
||||
{/* 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">
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import {
|
|||
} from '../../shared/lib/fieldEngine';
|
||||
import { ulid } from '../../shared/lib/ulid';
|
||||
import { useToast } from '../../shared/ui/useToast';
|
||||
import { useSessionStore } from '../../core/session/sessionStore';
|
||||
import {
|
||||
AlertCircle,
|
||||
Star,
|
||||
|
|
@ -151,25 +152,74 @@ export const DynamicFormRenderer: React.FC<DynamicFormRendererProps> = ({
|
|||
];
|
||||
}, [layout, entity]);
|
||||
|
||||
// Check conditional visibility
|
||||
const isFieldVisible = useCallback((field: FieldDefinition): boolean => {
|
||||
if (field.hidden) return false;
|
||||
if (!field.conditionalVisibility || !field.conditionalVisibility.fieldKey) return true;
|
||||
const currentRoleId = useSessionStore((s) => s.currentRoleId);
|
||||
|
||||
const { fieldKey, operator, value } = field.conditionalVisibility;
|
||||
const currentVal = formValues[fieldKey];
|
||||
// Check conditional visibility and role permissions
|
||||
const isFieldVisible = useCallback(
|
||||
(field: FieldDefinition): boolean => {
|
||||
if (field.hidden) return false;
|
||||
|
||||
switch (operator) {
|
||||
case 'eq':
|
||||
return String(currentVal ?? '') === String(value ?? '');
|
||||
case 'neq':
|
||||
return String(currentVal ?? '') !== String(value ?? '');
|
||||
case 'contains':
|
||||
return String(currentVal ?? '').includes(String(value ?? ''));
|
||||
default:
|
||||
return true;
|
||||
}
|
||||
}, [formValues]);
|
||||
// Role permission / visibility check
|
||||
if (currentRoleId !== 'owner') {
|
||||
const customRoleVisibility = (field as unknown as { roleVisibility?: string[] }).roleVisibility;
|
||||
const viewRoles = field.permissions?.viewRoles || customRoleVisibility;
|
||||
if (viewRoles && viewRoles.length > 0 && !viewRoles.includes(currentRoleId)) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// Check conditionalRules array or single conditionalVisibility
|
||||
const customRules = (field as unknown as { conditionalRules?: typeof field.conditionalVisibility[] }).conditionalRules;
|
||||
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) => {
|
||||
setFormValues((prev) => ({ ...prev, [key]: val }));
|
||||
|
|
|
|||
|
|
@ -88,6 +88,40 @@ 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
|
||||
if (sortField) {
|
||||
result.sort((a, b) => {
|
||||
|
|
@ -102,7 +136,7 @@ export const DynamicTable: React.FC<DynamicTableProps> = ({
|
|||
}
|
||||
|
||||
return result;
|
||||
}, [records, searchQuery, sortField, sortOrder]);
|
||||
}, [records, searchQuery, sortField, sortOrder, view]);
|
||||
|
||||
// Paginated records
|
||||
const totalPages = Math.ceil(filteredRecords.length / pageSize) || 1;
|
||||
|
|
@ -237,10 +271,16 @@ export const DynamicTable: React.FC<DynamicTableProps> = ({
|
|||
<tr>
|
||||
{columns.map((col) => {
|
||||
const isSorted = sortField === col.key;
|
||||
const customWidth = view?.columnWidths?.[col.key];
|
||||
const colStyle = customWidth
|
||||
? { width: `${customWidth}px`, minWidth: `${customWidth}px` }
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<th
|
||||
key={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"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
|
|
@ -272,9 +312,13 @@ export const DynamicTable: React.FC<DynamicTableProps> = ({
|
|||
{columns.map((col) => {
|
||||
const val = row[col.key];
|
||||
const display = formatFieldValue(col.type, val, col);
|
||||
const customWidth = view?.columnWidths?.[col.key];
|
||||
const colStyle = customWidth
|
||||
? { width: `${customWidth}px`, minWidth: `${customWidth}px` }
|
||||
: undefined;
|
||||
|
||||
return (
|
||||
<td key={col.key} className="p-3 text-krona-secondary truncate max-w-xs">
|
||||
<td key={col.key} style={colStyle} className="p-3 text-krona-secondary truncate max-w-xs">
|
||||
{col.type === 'color' && val ? (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
|
|
|
|||
|
|
@ -1,5 +1,5 @@
|
|||
import React from 'react';
|
||||
import { useLocation, useNavigate } from 'react-router-dom';
|
||||
import { useLocation, useNavigate, useSearchParams } from 'react-router-dom';
|
||||
import { useDemoStore } from '../../mock/store';
|
||||
import { PosView } from './PosView';
|
||||
import { KdsView } from './KdsView';
|
||||
|
|
@ -20,47 +20,49 @@ import { formatCurrency } from '../../shared/utils/formatters';
|
|||
export const OperationsModule: React.FC = () => {
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [searchParams] = useSearchParams();
|
||||
const { getActiveProfile, getActiveCapabilities, setIsQuickActionsOpen, activeLocation } = useDemoStore();
|
||||
const profile = getActiveProfile();
|
||||
const capabilities = getActiveCapabilities();
|
||||
|
||||
const currentPath = location.pathname;
|
||||
const tabParam = (searchParams.get('tab') || '').toLowerCase();
|
||||
|
||||
// Sub-routes dispatcher
|
||||
if (currentPath === '/operations/pos') {
|
||||
// Sub-routes dispatcher (supports both direct path and deep link query params)
|
||||
if (currentPath === '/operations/pos' || tabParam === 'pos') {
|
||||
return <PosView />;
|
||||
}
|
||||
if (currentPath === '/operations/kds') {
|
||||
if (currentPath === '/operations/kds' || tabParam === 'kds') {
|
||||
return <KdsView />;
|
||||
}
|
||||
if (currentPath === '/operations/dining') {
|
||||
if (currentPath === '/operations/dining' || tabParam === 'dining') {
|
||||
return <DiningView />;
|
||||
}
|
||||
if (currentPath === '/operations/work-orders') {
|
||||
if (currentPath === '/operations/work-orders' || tabParam === 'work-orders') {
|
||||
return <WorkOrdersView />;
|
||||
}
|
||||
if (currentPath === '/operations/booking') {
|
||||
if (currentPath === '/operations/booking' || tabParam === 'booking') {
|
||||
return <BookingCalendarView />;
|
||||
}
|
||||
if (currentPath === '/operations/delivery') {
|
||||
if (currentPath === '/operations/delivery' || tabParam === 'delivery') {
|
||||
return <DeliveryView />;
|
||||
}
|
||||
if (currentPath === '/operations/courier') {
|
||||
if (currentPath === '/operations/courier' || tabParam === 'courier') {
|
||||
return <DeliveryView initialMobile={true} />;
|
||||
}
|
||||
if (currentPath === '/operations/recipes') {
|
||||
if (currentPath === '/operations/recipes' || tabParam === 'recipes') {
|
||||
return <RecipesView />;
|
||||
}
|
||||
if (currentPath === '/operations/inventory') {
|
||||
if (currentPath === '/operations/inventory' || tabParam === 'inventory') {
|
||||
return <InventoryModule />;
|
||||
}
|
||||
if (currentPath === '/operations/orders') {
|
||||
if (currentPath === '/operations/orders' || tabParam === 'orders') {
|
||||
return <SalesModule />;
|
||||
}
|
||||
if (currentPath === '/operations/purchasing') {
|
||||
if (currentPath === '/operations/purchasing' || tabParam === 'purchasing') {
|
||||
return <PurchasingView />;
|
||||
}
|
||||
if (currentPath === '/operations/bays') {
|
||||
if (currentPath === '/operations/bays' || tabParam === 'bays') {
|
||||
return <BaysView />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,284 @@
|
|||
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,5 +1,6 @@
|
|||
import React, { useState } from 'react';
|
||||
import React, { useState, useEffect, useCallback } from 'react';
|
||||
import { INITIAL_TASKS, TaskEntity } from '../../mock/mockData';
|
||||
import { getStoreItem, putStoreItem } from '../../services/storage/indexedDb';
|
||||
import { Badge, MaturityBadge, Button, Modal } from '../../shared/ui';
|
||||
import {
|
||||
Clock,
|
||||
|
|
@ -27,6 +28,26 @@ export const TasksModule: React.FC = () => {
|
|||
const [tasks, setTasks] = useState<TaskEntity[]>(INITIAL_TASKS);
|
||||
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
|
||||
const [selectedTask, setSelectedTask] = useState<TaskEntity | null>(null);
|
||||
const [subtasks, setSubtasks] = useState<SubtaskItem[]>([
|
||||
|
|
@ -41,7 +62,8 @@ export const TasksModule: React.FC = () => {
|
|||
const [newComment, setNewComment] = useState('');
|
||||
|
||||
const handleStatusChange = (taskId: string, nextStatus: TaskEntity['status']) => {
|
||||
setTasks(tasks.map(t => t.id === taskId ? { ...t, status: nextStatus } : t));
|
||||
const updated = tasks.map((t) => (t.id === taskId ? { ...t, status: nextStatus } : t));
|
||||
persistTasks(updated);
|
||||
if (selectedTask && selectedTask.id === taskId) {
|
||||
setSelectedTask({ ...selectedTask, status: nextStatus });
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,8 +1,6 @@
|
|||
import { create } from 'zustand';
|
||||
import { useDemoStore as useDemoControlsStore } from '../stores/demo';
|
||||
import { CapabilityId } from '../core/capabilities/types';
|
||||
import { SystemRoleId } from '../core/permissions/types';
|
||||
import { BUSINESS_PROFILES } from '../core/profiles/profiles';
|
||||
import { BusinessProfile } from '../core/profiles/types';
|
||||
|
||||
export type ThemeMode = 'soft-light' | 'light' | 'dark';
|
||||
|
|
@ -338,6 +336,9 @@ const INITIAL_NOTIFICATIONS: AppNotification[] = [
|
|||
},
|
||||
];
|
||||
|
||||
import { useSessionStore } from '../core/session/sessionStore';
|
||||
export { useSessionStore };
|
||||
|
||||
export const useDemoStore = create<DemoStore>((set, get) => ({
|
||||
// Theme & Viewport defaults
|
||||
theme:
|
||||
|
|
@ -356,48 +357,27 @@ export const useDemoStore = create<DemoStore>((set, get) => ({
|
|||
viewportMode: 'desktop',
|
||||
setViewportMode: (viewportMode) => set({ viewportMode }),
|
||||
|
||||
// Profile & Role defaults
|
||||
currentProfileId:
|
||||
(typeof window !== 'undefined' && localStorage.getItem('krona_profile_id')) ||
|
||||
'restaurant_hall_delivery',
|
||||
// Profile & Role defaults (Delegated to canonical useSessionStore)
|
||||
currentProfileId: useSessionStore.getState().currentProfileId,
|
||||
setProfileId: (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);
|
||||
useSessionStore.getState().setCurrentProfileId(currentProfileId);
|
||||
},
|
||||
currentRoleId:
|
||||
(typeof window !== 'undefined' && (localStorage.getItem('krona_role_id') as SystemRoleId)) ||
|
||||
'owner',
|
||||
currentRoleId: useSessionStore.getState().currentRoleId,
|
||||
setRoleId: (currentRoleId) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('krona_role_id', currentRoleId);
|
||||
}
|
||||
set({ currentRoleId });
|
||||
useDemoControlsStore.getState().setCurrentRoleId(currentRoleId);
|
||||
useSessionStore.getState().setCurrentRoleId(currentRoleId);
|
||||
},
|
||||
activeLocation: useSessionStore.getState().activeLocation,
|
||||
setActiveLocation: (activeLocation) => {
|
||||
useSessionStore.getState().setActiveLocation(activeLocation);
|
||||
},
|
||||
activeLocation: 'Горский 1/1',
|
||||
setActiveLocation: (activeLocation) => set({ activeLocation }),
|
||||
|
||||
// Capabilities
|
||||
customCapabilities: { ...BUSINESS_PROFILES.restaurant_hall_delivery.capabilities },
|
||||
toggleCapability: (id) =>
|
||||
set((state) => ({
|
||||
customCapabilities: {
|
||||
...state.customCapabilities,
|
||||
[id]: !state.customCapabilities[id],
|
||||
},
|
||||
})),
|
||||
// Capabilities (Delegated to canonical useSessionStore)
|
||||
customCapabilities: useSessionStore.getState().capabilities,
|
||||
toggleCapability: (id) => {
|
||||
useSessionStore.getState().toggleCapability(id);
|
||||
},
|
||||
resetCapabilitiesToProfile: () => {
|
||||
const state = get();
|
||||
const profile = BUSINESS_PROFILES[state.currentProfileId] || BUSINESS_PROFILES.restaurant_hall_delivery;
|
||||
set({ customCapabilities: { ...profile.capabilities } });
|
||||
useSessionStore.getState().resetCapabilitiesToProfile();
|
||||
},
|
||||
|
||||
// Panels
|
||||
|
|
@ -574,10 +554,18 @@ export const useDemoStore = create<DemoStore>((set, get) => ({
|
|||
|
||||
// Getters
|
||||
getActiveProfile: () => {
|
||||
const s = get();
|
||||
return BUSINESS_PROFILES[s.currentProfileId] || BUSINESS_PROFILES.restaurant_hall_delivery;
|
||||
return useSessionStore.getState().getActiveProfile();
|
||||
},
|
||||
getActiveCapabilities: () => {
|
||||
return get().customCapabilities;
|
||||
return useSessionStore.getState().getActiveCapabilities();
|
||||
},
|
||||
}));
|
||||
|
||||
useSessionStore.subscribe((session) => {
|
||||
useDemoStore.setState({
|
||||
currentRoleId: session.currentRoleId,
|
||||
currentProfileId: session.currentProfileId,
|
||||
activeLocation: session.activeLocation,
|
||||
customCapabilities: session.capabilities,
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -2,8 +2,7 @@ import React from 'react';
|
|||
import { ShieldAlert, Home, RefreshCw } from 'lucide-react';
|
||||
import { useNavigate } from 'react-router-dom';
|
||||
import { Button } from '../../shared/ui/Button';
|
||||
import { useDemoStore } from '../../stores/demo';
|
||||
import { useDemoStore as useMockStore } from '../../mock/store';
|
||||
import { useSessionStore } from '../../core/session/sessionStore';
|
||||
|
||||
export interface ForbiddenPageProps {
|
||||
requiredPermission?: string;
|
||||
|
|
@ -15,11 +14,10 @@ export const ForbiddenPage: React.FC<ForbiddenPageProps> = ({
|
|||
moduleName,
|
||||
}) => {
|
||||
const navigate = useNavigate();
|
||||
const { currentRoleId, setCurrentRoleId } = useDemoStore();
|
||||
const { currentRoleId, setCurrentRoleId } = useSessionStore();
|
||||
|
||||
const handleSwitchToOwner = () => {
|
||||
setCurrentRoleId('owner');
|
||||
useMockStore.getState().setRoleId('owner');
|
||||
};
|
||||
|
||||
return (
|
||||
|
|
|
|||
91
src/services/__tests__/compositionRootContract.test.ts
Normal file
91
src/services/__tests__/compositionRootContract.test.ts
Normal file
|
|
@ -0,0 +1,91 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
328
src/services/__tests__/orderReservationIntegrity.test.ts
Normal file
328
src/services/__tests__/orderReservationIntegrity.test.ts
Normal file
|
|
@ -0,0 +1,328 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
280
src/services/__tests__/orderStateMachine.test.ts
Normal file
280
src/services/__tests__/orderStateMachine.test.ts
Normal file
|
|
@ -0,0 +1,280 @@
|
|||
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);
|
||||
});
|
||||
});
|
||||
192
src/services/__tests__/paginationAndNumbering.test.ts
Normal file
192
src/services/__tests__/paginationAndNumbering.test.ts
Normal file
|
|
@ -0,0 +1,192 @@
|
|||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
197
src/services/__tests__/paymentModelIntegrity.test.ts
Normal file
197
src/services/__tests__/paymentModelIntegrity.test.ts
Normal file
|
|
@ -0,0 +1,197 @@
|
|||
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('Демонстрационная модель расчёта');
|
||||
});
|
||||
});
|
||||
87
src/services/container.ts
Normal file
87
src/services/container.ts
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
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,6 +19,7 @@ export interface Payment extends BaseEntity {
|
|||
method: 'card' | 'cash' | 'online' | 'bank_transfer';
|
||||
status: 'completed' | 'pending' | 'failed' | 'refunded';
|
||||
transactionId?: string;
|
||||
idempotencyKey?: string;
|
||||
paidAt: string;
|
||||
recipientAccount?: string;
|
||||
}
|
||||
|
|
@ -70,7 +71,26 @@ export interface ICommerceService {
|
|||
updateOrderStatus(orderId: string, status: Order['status']): Promise<Order>;
|
||||
confirmOrder(orderId: string, warehouseId?: string): Promise<Order>;
|
||||
cancelOrder(orderId: string): Promise<Order>;
|
||||
createPayment(orderId: string, amount: MoneyAmount, method: Payment['method'], autoRecordFinance?: boolean): Promise<Payment>;
|
||||
createPayment(
|
||||
orderId: string,
|
||||
amount: MoneyAmount,
|
||||
method: Payment['method'],
|
||||
autoRecordFinance?: boolean,
|
||||
idempotencyKey?: string
|
||||
): Promise<Payment>;
|
||||
createShipment(orderId: string, warehouseId: string, items?: ShipmentItem[]): Promise<Shipment>;
|
||||
processReturn(orderId: string, reason: string, items: ReturnItem[], warehouseId?: string, autoRecordFinance?: boolean): Promise<Return>;
|
||||
createReturn(
|
||||
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>;
|
||||
notes: IRepository<Note>;
|
||||
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;
|
||||
accountId: string;
|
||||
accountName: string;
|
||||
referenceType?: 'order_payment' | 'order_return' | 'manual';
|
||||
referenceId?: string;
|
||||
referenceType: 'order_payment' | 'order_return' | 'manual';
|
||||
referenceId: string;
|
||||
description: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
|
@ -43,12 +43,23 @@ export interface PnLMetrics {
|
|||
operatingExpenses: MoneyAmount;
|
||||
netProfit: MoneyAmount;
|
||||
operatingMarginPercent: number;
|
||||
reportDisclaimer: 'Демонстрационная модель расчёта';
|
||||
}
|
||||
|
||||
export interface IFinanceService {
|
||||
accounts: IRepository<Account>;
|
||||
transactions: IRepository<CashFlowTransaction>;
|
||||
getPnLMetrics(period?: string): Promise<PnLMetrics>;
|
||||
createTransactionFromPayment(paymentId: string, orderId: string, amount: MoneyAmount): Promise<CashFlowTransaction>;
|
||||
createTransactionFromReturn(returnId: string, orderId: string, amount: MoneyAmount): Promise<CashFlowTransaction>;
|
||||
getPnLMetrics(period?: string, startDate?: string, endDate?: string): Promise<PnLMetrics>;
|
||||
createTransactionFromPayment(
|
||||
paymentId: string,
|
||||
orderId: string,
|
||||
amount: MoneyAmount,
|
||||
accountId?: string
|
||||
): Promise<CashFlowTransaction>;
|
||||
createTransactionFromReturn(
|
||||
returnId: string,
|
||||
orderId: string,
|
||||
amount: MoneyAmount,
|
||||
accountId?: string
|
||||
): Promise<CashFlowTransaction>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,21 @@ import { Warehouse } from '../../entities/inventory/warehouse';
|
|||
import { BaseEntity, MoneyAmount } from '../../entities/types';
|
||||
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 {
|
||||
warehouseId: string;
|
||||
warehouseName: string;
|
||||
|
|
@ -15,15 +30,21 @@ export interface StockItem extends BaseEntity {
|
|||
minThreshold?: number;
|
||||
}
|
||||
|
||||
export type StockMovementType = 'receipt' | 'shipment' | 'adjustment' | 'return' | 'transfer' | 'loss';
|
||||
|
||||
export interface StockMovement extends BaseEntity {
|
||||
type: 'receipt' | 'shipment' | 'transfer' | 'adjustment' | 'loss';
|
||||
type: StockMovementType;
|
||||
productId: string;
|
||||
productName: string;
|
||||
sku: string;
|
||||
sourceWarehouseId?: string;
|
||||
targetWarehouseId?: string;
|
||||
warehouseId?: string;
|
||||
quantity: number;
|
||||
referenceNumber?: string;
|
||||
orderId?: string;
|
||||
orderLineId?: string;
|
||||
reason?: string;
|
||||
notes?: string;
|
||||
timestamp: string;
|
||||
}
|
||||
|
|
@ -49,10 +70,12 @@ export interface GoodsReceipt extends BaseEntity {
|
|||
|
||||
export interface StockReservation extends BaseEntity {
|
||||
orderId: string;
|
||||
orderLineId?: string;
|
||||
warehouseId: string;
|
||||
productId: string;
|
||||
quantity: number;
|
||||
status: 'active' | 'fulfilled' | 'cancelled';
|
||||
reason?: string;
|
||||
expiresAt?: string;
|
||||
}
|
||||
|
||||
|
|
@ -63,9 +86,25 @@ export interface IInventoryService {
|
|||
receipts: IRepository<GoodsReceipt>;
|
||||
reservations: IRepository<StockReservation>;
|
||||
adjustStock(warehouseId: string, productId: string, delta: number, reason: string): Promise<StockItem>;
|
||||
reserveStock(orderId: string, warehouseId: string, productId: string, quantity: number): Promise<StockReservation>;
|
||||
reserveStock(
|
||||
orderId: string,
|
||||
warehouseId: string,
|
||||
productId: string,
|
||||
quantity: number,
|
||||
orderLineId?: string,
|
||||
reason?: string
|
||||
): Promise<StockReservation>;
|
||||
cancelOrderReservations(orderId: string): Promise<void>;
|
||||
fulfillOrderReservations(orderId: string, warehouseId: string, items?: { productId: string; name: string; sku: string; quantity: number }[]): Promise<void>;
|
||||
fulfillOrderReservations(
|
||||
orderId: string,
|
||||
warehouseId: string,
|
||||
items?: { productId: string; name: string; sku: string; quantity: number }[]
|
||||
): Promise<void>;
|
||||
receiveGoods(receiptId: string): Promise<GoodsReceipt>;
|
||||
createAndReceiveGoods(warehouseId: string, receiptNumber: string, supplierName: string, items: GoodsReceiptItem[]): Promise<GoodsReceipt>;
|
||||
createAndReceiveGoods(
|
||||
warehouseId: string,
|
||||
receiptNumber: string,
|
||||
supplierName: string,
|
||||
items: GoodsReceiptItem[]
|
||||
): Promise<GoodsReceipt>;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -84,6 +84,8 @@ export function useFinance() {
|
|||
amount,
|
||||
accountId,
|
||||
accountName: accName,
|
||||
referenceType: 'manual',
|
||||
referenceId: `manual_${Date.now()}`,
|
||||
description,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
|
|
|||
4
src/services/index.ts
Normal file
4
src/services/index.ts
Normal file
|
|
@ -0,0 +1,4 @@
|
|||
export * from './container';
|
||||
export * from './contracts';
|
||||
export * from './hooks';
|
||||
export * from './storage/indexedDb';
|
||||
|
|
@ -12,6 +12,7 @@ import {
|
|||
getRecordsByEntity,
|
||||
putStoreItem,
|
||||
RecordEntity,
|
||||
generateMonotonicNumber,
|
||||
} from '../storage/indexedDb';
|
||||
import { ulid } from '../../shared/lib/ulid';
|
||||
import { useDemoStore } from '../../stores/demo';
|
||||
|
|
@ -98,8 +99,18 @@ export class BaseMockRepository<T extends BaseEntity> implements IRepository<T>
|
|||
const userId = ctx.actorId || useDemoStore.getState().currentRoleId || 'demo_user';
|
||||
|
||||
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 = {
|
||||
...data,
|
||||
...entityData,
|
||||
id,
|
||||
entityType: this.entityName,
|
||||
tenant_id: ctx.tenantId,
|
||||
|
|
|
|||
|
|
@ -8,12 +8,26 @@ import {
|
|||
ReturnItem,
|
||||
} from '../contracts/commerce';
|
||||
import { IRepository } from '../contracts/repository';
|
||||
import { Order } from '../../entities/commerce/order';
|
||||
import { Order, OrderStatus } from '../../entities/commerce/order';
|
||||
import { Product, ProductCategory } from '../../entities/commerce/product';
|
||||
import { MoneyAmount } from '../../entities/types';
|
||||
import { BaseMockRepository } from './baseRepository';
|
||||
import { ulid } from '../../shared/lib/ulid';
|
||||
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 {
|
||||
products: IRepository<Product>;
|
||||
|
|
@ -34,8 +48,41 @@ export class MockCommerceService implements ICommerceService {
|
|||
this.returns = new BaseMockRepository<Return>('return');
|
||||
}
|
||||
|
||||
async updateOrderStatus(orderId: string, status: Order['status']): Promise<Order> {
|
||||
return this.orders.update(orderId, { status });
|
||||
async updateOrderStatus(orderId: string, nextStatus: OrderStatus): Promise<Order> {
|
||||
const order = await this.orders.getById(orderId);
|
||||
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> {
|
||||
|
|
@ -44,11 +91,47 @@ export class MockCommerceService implements ICommerceService {
|
|||
throw new Error(`Заказ с ID ${orderId} не найден.`);
|
||||
}
|
||||
|
||||
// Reserve stock in inventory
|
||||
// Idempotency: if already confirmed, do not duplicate reservations
|
||||
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;
|
||||
if (inventory && order.items) {
|
||||
if (inventory && order.items && order.items.length > 0) {
|
||||
// Phase 1: Pre-flight availability check for ALL items (All-or-Nothing)
|
||||
for (const item of order.items) {
|
||||
await inventory.reserveStock(orderId, warehouseId, item.productId, item.quantity);
|
||||
const stockList = await inventory.stockItems.list({
|
||||
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}`
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -61,7 +144,17 @@ export class MockCommerceService implements ICommerceService {
|
|||
throw new Error(`Заказ с ID ${orderId} не найден.`);
|
||||
}
|
||||
|
||||
// Release any active warehouse reservations
|
||||
if (order.status === 'cancelled') {
|
||||
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;
|
||||
if (inventory) {
|
||||
await inventory.cancelOrderReservations(orderId);
|
||||
|
|
@ -74,13 +167,33 @@ export class MockCommerceService implements ICommerceService {
|
|||
orderId: string,
|
||||
amount: MoneyAmount,
|
||||
method: Payment['method'],
|
||||
autoRecordFinance = false
|
||||
autoRecordFinance = false,
|
||||
idempotencyKey?: string
|
||||
): Promise<Payment> {
|
||||
const order = await this.orders.getById(orderId);
|
||||
if (!order) {
|
||||
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 payment = await this.payments.create({
|
||||
orderId,
|
||||
|
|
@ -88,18 +201,49 @@ export class MockCommerceService implements ICommerceService {
|
|||
amount,
|
||||
method,
|
||||
status: 'completed',
|
||||
idempotencyKey,
|
||||
paidAt: now,
|
||||
transactionId: `TXN-${ulid().slice(0, 10)}`,
|
||||
});
|
||||
|
||||
// Update order payment status
|
||||
await this.orders.update(orderId, { paymentStatus: 'paid' });
|
||||
// 3. Compute cumulative paid amount to update paymentStatus
|
||||
const orderPayments = await this.payments.list({ filter: { orderId, status: 'completed' } });
|
||||
const cumulativePaidMinor = orderPayments.items.reduce((sum, p) => sum + p.amount.minor, 0);
|
||||
|
||||
// Automatically record finance transaction if requested
|
||||
let nextPaymentStatus: Order['paymentStatus'];
|
||||
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) {
|
||||
const finance = getServices().finance;
|
||||
if (finance) {
|
||||
await finance.createTransactionFromPayment(payment.id, orderId, amount);
|
||||
try {
|
||||
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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -116,6 +260,22 @@ export class MockCommerceService implements ICommerceService {
|
|||
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[] =
|
||||
items ||
|
||||
order.items.map((i) => ({
|
||||
|
|
@ -138,7 +298,7 @@ export class MockCommerceService implements ICommerceService {
|
|||
|
||||
await this.orders.update(orderId, { fulfillmentStatus: 'fulfilled', status: 'shipped' });
|
||||
|
||||
// Deduct reserved stock and physical stock in inventory
|
||||
// Deduct physical stock (onHand) and fulfill active reservations in inventory
|
||||
const inventory = getServices().inventory;
|
||||
if (inventory) {
|
||||
await inventory.fulfillOrderReservations(orderId, warehouseId, shipmentItems);
|
||||
|
|
@ -147,18 +307,56 @@ export class MockCommerceService implements ICommerceService {
|
|||
return shipment;
|
||||
}
|
||||
|
||||
async processReturn(
|
||||
async createReturn(
|
||||
orderId: string,
|
||||
reason: string,
|
||||
items: ReturnItem[],
|
||||
warehouseId = 'wh_main',
|
||||
autoRecordFinance = false
|
||||
autoRecordFinance = true
|
||||
): Promise<Return> {
|
||||
const order = await this.orders.getById(orderId);
|
||||
if (!order) {
|
||||
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 ret = await this.returns.create({
|
||||
|
|
@ -175,7 +373,7 @@ export class MockCommerceService implements ICommerceService {
|
|||
paymentStatus: 'refunded',
|
||||
});
|
||||
|
||||
// Restore physical stock in inventory
|
||||
// Restore physical stock onHand in inventory
|
||||
const inventory = getServices().inventory;
|
||||
if (inventory) {
|
||||
for (const item of items) {
|
||||
|
|
@ -188,7 +386,7 @@ export class MockCommerceService implements ICommerceService {
|
|||
}
|
||||
}
|
||||
|
||||
// Automatically record refund transaction in finance if requested
|
||||
// Automatically record refund transaction in finance
|
||||
if (autoRecordFinance) {
|
||||
const finance = getServices().finance;
|
||||
if (finance) {
|
||||
|
|
@ -198,4 +396,14 @@ export class MockCommerceService implements ICommerceService {
|
|||
|
||||
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 or company, fetch linked deals
|
||||
// 3. If contact, company, or deal, fetch linked deals and orders
|
||||
if (entityType === 'contact' || entityType === 'company') {
|
||||
const dealsRes = await this.deals.list({
|
||||
filter: entityType === 'contact' ? { contactId: entityId } : { companyId: entityId },
|
||||
|
|
@ -68,7 +68,29 @@ export class MockCrmService implements ICrmService {
|
|||
type: 'deal_created',
|
||||
title: `Сделка: ${d.title}`,
|
||||
description: `Этап: ${d.stage}, сумма: ${(d.amount.minor / 100).toLocaleString('ru-RU')} ₽`,
|
||||
actor: 'Отдел продаж',
|
||||
actor: d.assignedTo || 'Отдел продаж',
|
||||
});
|
||||
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: 'Коммерция',
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -77,44 +99,108 @@ export class MockCrmService implements ICrmService {
|
|||
return timeline.sort((a, b) => (b.timestamp > a.timestamp ? 1 : -1));
|
||||
}
|
||||
|
||||
async convertLeadToDeal(leadId: string, dealTitle: string): Promise<{ deal: Deal; contact?: Contact }> {
|
||||
async convertLeadToDeal(
|
||||
leadId: string,
|
||||
dealTitle?: string
|
||||
): Promise<{ deal: Deal; contact?: Contact }> {
|
||||
const lead = await this.leads.getById(leadId);
|
||||
if (!lead) {
|
||||
throw new Error(`Лид с ID ${leadId} не найден.`);
|
||||
}
|
||||
|
||||
// 1. Create or match contact
|
||||
const parts = (lead.contactName || 'Клиент').split(' ');
|
||||
const firstName = parts[0];
|
||||
const lastName = parts.slice(1).join(' ') || 'Клиент';
|
||||
// 1. Idempotency: if already converted, return existing records without duplicating
|
||||
if (lead.status === 'converted' && lead.convertedDealId) {
|
||||
const existingDeal = await this.deals.getById(lead.convertedDealId);
|
||||
const existingContact = lead.convertedContactId
|
||||
? await this.contacts.getById(lead.convertedContactId)
|
||||
: undefined;
|
||||
|
||||
const contact = await this.contacts.create({
|
||||
firstName,
|
||||
lastName,
|
||||
phone: lead.phone,
|
||||
email: lead.email,
|
||||
roles: ['client'],
|
||||
status: 'active',
|
||||
});
|
||||
if (existingDeal) {
|
||||
return { deal: existingDeal, contact: existingContact || undefined };
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Create deal
|
||||
// 2. Resolve company: match existing by name or create
|
||||
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({
|
||||
title: dealTitle || lead.title,
|
||||
title: dealTitle?.trim() || lead.title || `Сделка: ${lead.contactName}`,
|
||||
pipelineId: 'pipe_default',
|
||||
stage: 'proposal',
|
||||
amount: lead.estimatedValue || { minor: 1000000, currency: 'RUB' },
|
||||
probability: 60,
|
||||
contactId: contact.id,
|
||||
companyId: companyId || contact.companyId,
|
||||
assignedUserId: lead.assignedUserId,
|
||||
assignedTo: lead.assignedTo || lead.assignedUserId,
|
||||
});
|
||||
|
||||
// 3. Mark lead as converted
|
||||
// 5. Mark lead as converted
|
||||
await this.leads.update(leadId, {
|
||||
status: 'converted',
|
||||
convertedContactId: contact.id,
|
||||
convertedDealId: deal.id,
|
||||
companyId: companyId || lead.companyId,
|
||||
});
|
||||
|
||||
// 4. Add initial note to timeline
|
||||
// 6. Record note in deal timeline
|
||||
await this.notes.create({
|
||||
targetEntityType: 'deal',
|
||||
targetEntityId: deal.id,
|
||||
|
|
|
|||
|
|
@ -18,12 +18,34 @@ export class MockFinanceService implements IFinanceService {
|
|||
this.transactions = new BaseMockRepository<CashFlowTransaction>('cash_flow_transaction');
|
||||
}
|
||||
|
||||
async getPnLMetrics(period = 'current_month'): Promise<PnLMetrics> {
|
||||
async getPnLMetrics(
|
||||
period = 'current_month',
|
||||
startDate?: string,
|
||||
endDate?: string
|
||||
): Promise<PnLMetrics> {
|
||||
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 expenseMinor = 0;
|
||||
|
||||
for (const tx of txs.items) {
|
||||
for (const tx of filteredTxs) {
|
||||
if (tx.type === 'inflow') {
|
||||
revenueMinor += tx.amount.minor;
|
||||
} else {
|
||||
|
|
@ -31,7 +53,7 @@ export class MockFinanceService implements IFinanceService {
|
|||
}
|
||||
}
|
||||
|
||||
const cogsMinor = Math.round(revenueMinor * 0.42); // 42% cost of goods sold
|
||||
const cogsMinor = Math.round(revenueMinor * 0.42); // 42% cost of goods sold model
|
||||
const grossProfitMinor = revenueMinor - cogsMinor;
|
||||
const operatingExpensesMinor = expenseMinor;
|
||||
const netProfitMinor = grossProfitMinor - operatingExpensesMinor;
|
||||
|
|
@ -46,24 +68,45 @@ export class MockFinanceService implements IFinanceService {
|
|||
operatingExpenses: { minor: operatingExpensesMinor, currency: 'RUB' },
|
||||
netProfit: { minor: netProfitMinor, currency: 'RUB' },
|
||||
operatingMarginPercent,
|
||||
reportDisclaimer: 'Демонстрационная модель расчёта',
|
||||
};
|
||||
}
|
||||
|
||||
async createTransactionFromPayment(
|
||||
paymentId: string,
|
||||
orderId: string,
|
||||
amount: MoneyAmount
|
||||
amount: MoneyAmount,
|
||||
accountId?: string
|
||||
): 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 defaultAcc = accounts.items.find((a) => a.isDefault) || accounts.items[0];
|
||||
const targetAcc = accountId
|
||||
? 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({
|
||||
transactionNumber: `CFT-${ulid().slice(0, 8)}`,
|
||||
type: 'inflow',
|
||||
category: 'sales_revenue',
|
||||
amount,
|
||||
accountId: defaultAcc?.id || 'acc_default',
|
||||
accountName: defaultAcc?.name || 'Основной расчетный счет',
|
||||
accountId: targetAcc.id,
|
||||
accountName: targetAcc.name,
|
||||
referenceType: 'order_payment',
|
||||
referenceId: paymentId,
|
||||
description: `Оплата по заказу (${orderId.slice(0, 8)}...)`,
|
||||
|
|
@ -71,14 +114,12 @@ export class MockFinanceService implements IFinanceService {
|
|||
});
|
||||
|
||||
// Update account balance
|
||||
if (defaultAcc) {
|
||||
await this.accounts.update(defaultAcc.id, {
|
||||
balance: {
|
||||
minor: defaultAcc.balance.minor + amount.minor,
|
||||
currency: defaultAcc.balance.currency,
|
||||
},
|
||||
});
|
||||
}
|
||||
await this.accounts.update(targetAcc.id, {
|
||||
balance: {
|
||||
minor: targetAcc.balance.minor + amount.minor,
|
||||
currency: targetAcc.balance.currency,
|
||||
},
|
||||
});
|
||||
|
||||
return tx;
|
||||
}
|
||||
|
|
@ -86,18 +127,38 @@ export class MockFinanceService implements IFinanceService {
|
|||
async createTransactionFromReturn(
|
||||
returnId: string,
|
||||
orderId: string,
|
||||
amount: MoneyAmount
|
||||
amount: MoneyAmount,
|
||||
accountId?: string
|
||||
): 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 defaultAcc = accounts.items.find((a) => a.isDefault) || accounts.items[0];
|
||||
const targetAcc = accountId
|
||||
? 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({
|
||||
transactionNumber: `CFT-${ulid().slice(0, 8)}`,
|
||||
type: 'outflow',
|
||||
category: 'refund',
|
||||
amount,
|
||||
accountId: defaultAcc?.id || 'acc_default',
|
||||
accountName: defaultAcc?.name || 'Основной расчетный счет',
|
||||
accountId: targetAcc.id,
|
||||
accountName: targetAcc.name,
|
||||
referenceType: 'order_return',
|
||||
referenceId: returnId,
|
||||
description: `Возврат средств по заказу (${orderId.slice(0, 8)}...)`,
|
||||
|
|
@ -105,14 +166,12 @@ export class MockFinanceService implements IFinanceService {
|
|||
});
|
||||
|
||||
// Update account balance
|
||||
if (defaultAcc) {
|
||||
await this.accounts.update(defaultAcc.id, {
|
||||
balance: {
|
||||
minor: Math.max(0, defaultAcc.balance.minor - amount.minor),
|
||||
currency: defaultAcc.balance.currency,
|
||||
},
|
||||
});
|
||||
}
|
||||
await this.accounts.update(targetAcc.id, {
|
||||
balance: {
|
||||
minor: targetAcc.balance.minor - amount.minor,
|
||||
currency: targetAcc.balance.currency,
|
||||
},
|
||||
});
|
||||
|
||||
return tx;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -5,6 +5,7 @@ import {
|
|||
GoodsReceipt,
|
||||
GoodsReceiptItem,
|
||||
StockReservation,
|
||||
InsufficientStockError,
|
||||
} from '../contracts/inventory';
|
||||
import { IRepository } from '../contracts/repository';
|
||||
import { Warehouse } from '../../entities/inventory/warehouse';
|
||||
|
|
@ -39,34 +40,64 @@ export class MockInventoryService implements IInventoryService {
|
|||
const now = new Date().toISOString();
|
||||
|
||||
if (!stockItem) {
|
||||
if (delta < 0) {
|
||||
throw new InsufficientStockError(
|
||||
productId,
|
||||
Math.abs(delta),
|
||||
0,
|
||||
`Невозможно списать товар (ID: ${productId}): товар отсутствует на складе`
|
||||
);
|
||||
}
|
||||
|
||||
stockItem = await this.stockItems.create({
|
||||
warehouseId,
|
||||
warehouseName: 'Склад',
|
||||
productId,
|
||||
sku: 'SKU',
|
||||
productName: 'Товар',
|
||||
onHand: Math.max(0, delta),
|
||||
onHand: delta,
|
||||
reserved: 0,
|
||||
available: Math.max(0, delta),
|
||||
available: delta,
|
||||
unit: 'шт',
|
||||
});
|
||||
} else {
|
||||
const newOnHand = Math.max(0, stockItem.onHand + delta);
|
||||
const newAvailable = Math.max(0, newOnHand - stockItem.reserved);
|
||||
if (stockItem.onHand + delta < 0) {
|
||||
throw new InsufficientStockError(
|
||||
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, {
|
||||
onHand: newOnHand,
|
||||
available: newAvailable,
|
||||
});
|
||||
}
|
||||
|
||||
// Record movement
|
||||
// Record strictly physical movement in Stock Ledger
|
||||
await this.movements.create({
|
||||
type: delta >= 0 ? 'adjustment' : 'loss',
|
||||
productId,
|
||||
productName: stockItem.productName,
|
||||
sku: stockItem.sku,
|
||||
warehouseId,
|
||||
targetWarehouseId: warehouseId,
|
||||
quantity: Math.abs(delta),
|
||||
reason,
|
||||
notes: reason,
|
||||
timestamp: now,
|
||||
});
|
||||
|
|
@ -78,43 +109,50 @@ export class MockInventoryService implements IInventoryService {
|
|||
orderId: string,
|
||||
warehouseId: string,
|
||||
productId: string,
|
||||
quantity: number
|
||||
quantity: number,
|
||||
orderLineId?: string,
|
||||
reason?: string
|
||||
): 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({
|
||||
orderId,
|
||||
orderLineId,
|
||||
warehouseId,
|
||||
productId,
|
||||
quantity,
|
||||
status: 'active',
|
||||
reason: reason || `Резервирование под заказ #${orderId.slice(0, 8)}`,
|
||||
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;
|
||||
}
|
||||
|
||||
|
|
@ -122,29 +160,19 @@ export class MockInventoryService implements IInventoryService {
|
|||
const list = await this.reservations.list({ filter: { orderId, status: 'active' } });
|
||||
for (const r of list.items) {
|
||||
await this.reservations.update(r.id, { status: 'cancelled' });
|
||||
|
||||
const stockList = await this.stockItems.list({
|
||||
filter: { warehouseId: r.warehouseId, productId: r.productId },
|
||||
});
|
||||
if (stockList.items.length > 0) {
|
||||
const si = stockList.items[0];
|
||||
const newReserved = Math.max(0, si.reserved - r.quantity);
|
||||
const newAvailable = Math.max(0, si.onHand - newReserved);
|
||||
const newReserved = si.reserved - r.quantity;
|
||||
const newAvailable = si.onHand - newReserved;
|
||||
await this.stockItems.update(si.id, {
|
||||
reserved: newReserved,
|
||||
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(),
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -153,7 +181,7 @@ export class MockInventoryService implements IInventoryService {
|
|||
warehouseId: string,
|
||||
items?: { productId: string; name: string; sku: string; quantity: number }[]
|
||||
): Promise<void> {
|
||||
// 1. Mark reservations as fulfilled
|
||||
// 1. Mark active reservations as fulfilled
|
||||
const resList = await this.reservations.list({
|
||||
filter: { orderId, status: 'active' },
|
||||
});
|
||||
|
|
@ -161,7 +189,7 @@ export class MockInventoryService implements IInventoryService {
|
|||
await this.reservations.update(r.id, { status: 'fulfilled' });
|
||||
}
|
||||
|
||||
// 2. Deduct physical stock (onHand) and reserved quantity
|
||||
// 2. Deduct physical stock (onHand) and reserved quantity in Stock Ledger
|
||||
if (items && items.length > 0) {
|
||||
for (const item of items) {
|
||||
const stockList = await this.stockItems.list({
|
||||
|
|
@ -169,9 +197,19 @@ export class MockInventoryService implements IInventoryService {
|
|||
});
|
||||
if (stockList.items.length > 0) {
|
||||
const si = stockList.items[0];
|
||||
const newOnHand = Math.max(0, si.onHand - item.quantity);
|
||||
if (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 newAvailable = Math.max(0, newOnHand - newReserved);
|
||||
const newAvailable = newOnHand - newReserved;
|
||||
|
||||
await this.stockItems.update(si.id, {
|
||||
onHand: newOnHand,
|
||||
reserved: newReserved,
|
||||
|
|
@ -179,13 +217,17 @@ export class MockInventoryService implements IInventoryService {
|
|||
});
|
||||
}
|
||||
|
||||
// Record physical shipment in Stock Ledger
|
||||
await this.movements.create({
|
||||
type: 'shipment',
|
||||
productId: item.productId,
|
||||
productName: item.name,
|
||||
sku: item.sku,
|
||||
warehouseId,
|
||||
sourceWarehouseId: warehouseId,
|
||||
quantity: item.quantity,
|
||||
orderId,
|
||||
reason: `Отгрузка по заказу #${orderId.slice(0, 8)}`,
|
||||
notes: `Отгрузка по заказу #${orderId.slice(0, 8)}`,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
|
@ -237,7 +279,6 @@ export class MockInventoryService implements IInventoryService {
|
|||
items,
|
||||
});
|
||||
|
||||
// Find warehouse name
|
||||
const wh = await this.warehouses.getById(warehouseId);
|
||||
const whName = wh?.name || 'Склад';
|
||||
|
||||
|
|
@ -248,7 +289,7 @@ export class MockInventoryService implements IInventoryService {
|
|||
if (list.items.length > 0) {
|
||||
const si = list.items[0];
|
||||
const newOnHand = si.onHand + item.quantity;
|
||||
const newAvailable = Math.max(0, newOnHand - si.reserved);
|
||||
const newAvailable = newOnHand - si.reserved;
|
||||
await this.stockItems.update(si.id, {
|
||||
onHand: newOnHand,
|
||||
available: newAvailable,
|
||||
|
|
@ -272,8 +313,11 @@ export class MockInventoryService implements IInventoryService {
|
|||
productId: item.productId,
|
||||
productName: item.name,
|
||||
sku: item.sku,
|
||||
warehouseId,
|
||||
targetWarehouseId: warehouseId,
|
||||
quantity: item.quantity,
|
||||
referenceNumber: receiptNumber,
|
||||
reason: `Приходная накладная №${receiptNumber} (${supplierName})`,
|
||||
notes: `Приходная накладная №${receiptNumber} (${supplierName})`,
|
||||
timestamp: new Date().toISOString(),
|
||||
});
|
||||
|
|
@ -282,4 +326,3 @@ export class MockInventoryService implements IInventoryService {
|
|||
return receipt;
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
218
src/services/storage/__tests__/safeSnapshotImport.test.ts
Normal file
218
src/services/storage/__tests__/safeSnapshotImport.test.ts
Normal file
|
|
@ -0,0 +1,218 @@
|
|||
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';
|
||||
|
||||
export const DB_NAME = 'krona_business_os_db';
|
||||
export const DB_VERSION = 2;
|
||||
export const DB_VERSION = 3;
|
||||
|
||||
export const DB_OBJECT_STORES = [
|
||||
'entities_meta',
|
||||
|
|
@ -18,6 +18,7 @@ export const DB_OBJECT_STORES = [
|
|||
'records_data',
|
||||
'audit_log',
|
||||
'settings',
|
||||
'counters',
|
||||
] as const;
|
||||
|
||||
export type DbStoreName = (typeof DB_OBJECT_STORES)[number];
|
||||
|
|
@ -53,6 +54,7 @@ export interface DatabaseSnapshot {
|
|||
records_data: unknown[];
|
||||
audit_log: unknown[];
|
||||
settings: unknown[];
|
||||
counters?: unknown[];
|
||||
};
|
||||
metadata: {
|
||||
totalRecords: number;
|
||||
|
|
@ -127,12 +129,44 @@ export function getDb(): Promise<IDBPDatabase> {
|
|||
if (!db.objectStoreNames.contains('settings')) {
|
||||
db.createObjectStore('settings', { keyPath: 'key' });
|
||||
}
|
||||
|
||||
// 8. counters
|
||||
if (!db.objectStoreNames.contains('counters')) {
|
||||
db.createObjectStore('counters', { keyPath: 'key' });
|
||||
}
|
||||
},
|
||||
});
|
||||
}
|
||||
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
|
||||
// ----------------------------------------------------
|
||||
|
|
@ -371,6 +405,7 @@ export async function exportDatabaseSnapshot(tenantId?: string): Promise<Databas
|
|||
let records_data = (await db.getAll('records_data')) as RecordEntity[];
|
||||
let audit_log = (await db.getAll('audit_log')) as Record<string, unknown>[];
|
||||
let settings = (await db.getAll('settings')) as Record<string, unknown>[];
|
||||
let counters = (await db.getAll('counters')) as CounterItem[];
|
||||
|
||||
if (tenantId) {
|
||||
records_data = records_data.filter((r) => (r.tenant_id || DEFAULT_TENANT_ID) === tenantId);
|
||||
|
|
@ -385,6 +420,7 @@ export async function exportDatabaseSnapshot(tenantId?: string): Promise<Databas
|
|||
(v) => !v.tenant_id || v.tenant_id === 'system' || v.tenant_id === tenantId
|
||||
);
|
||||
settings = settings.filter((s) => !s.tenant_id || s.tenant_id === tenantId);
|
||||
counters = counters.filter((c) => c.key.startsWith(`${tenantId}:`));
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
@ -401,6 +437,7 @@ export async function exportDatabaseSnapshot(tenantId?: string): Promise<Databas
|
|||
records_data,
|
||||
audit_log,
|
||||
settings,
|
||||
counters,
|
||||
},
|
||||
metadata: {
|
||||
totalRecords: records_data.length,
|
||||
|
|
@ -410,100 +447,256 @@ export async function exportDatabaseSnapshot(tenantId?: string): Promise<Databas
|
|||
};
|
||||
}
|
||||
|
||||
export async function importDatabaseSnapshot(
|
||||
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,
|
||||
targetTenantId?: string
|
||||
): Promise<void> {
|
||||
if (!snapshot || typeof snapshot !== 'object') {
|
||||
throw new Error('Некорректный формат снимка базы данных (snapshot).');
|
||||
}
|
||||
if (!snapshot.stores || typeof snapshot.stores !== 'object') {
|
||||
throw new Error('Снимок не содержит данных таблиц (stores).');
|
||||
}
|
||||
|
||||
options?: { targetTenantId?: string; mode?: 'overwrite' | 'merge' }
|
||||
): Promise<SnapshotDiff> {
|
||||
const validSnapshot = validateSnapshotSchema(snapshot);
|
||||
const db = await getDb();
|
||||
const tx = db.transaction(DB_OBJECT_STORES as unknown as string[], 'readwrite');
|
||||
const effectiveTenant = options?.targetTenantId || getActiveGlobalTenantId();
|
||||
const mode = options?.mode || 'overwrite';
|
||||
|
||||
if (!targetTenantId) {
|
||||
// Global wipe & restore
|
||||
for (const store of DB_OBJECT_STORES) {
|
||||
const storeObj = tx.objectStore(store);
|
||||
await storeObj.clear();
|
||||
const items = snapshot.stores[store] || [];
|
||||
for (const item of items) {
|
||||
await storeObj.put(item);
|
||||
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++;
|
||||
}
|
||||
}
|
||||
} 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 });
|
||||
}
|
||||
|
||||
// Audit log
|
||||
const auditStore = tx.objectStore('audit_log');
|
||||
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 = (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,
|
||||
});
|
||||
}
|
||||
toCreate += storeCreates;
|
||||
toUpdate += storeUpdates;
|
||||
|
||||
// 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 });
|
||||
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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
await tx.done;
|
||||
return {
|
||||
toCreate,
|
||||
toUpdate,
|
||||
toDelete,
|
||||
totalIncoming,
|
||||
storesDiff,
|
||||
};
|
||||
}
|
||||
|
||||
export async function importDatabaseSnapshot(
|
||||
snapshot: DatabaseSnapshot,
|
||||
options?: string | ImportSnapshotOptions
|
||||
): Promise<{ backupCreated: boolean; diff: SnapshotDiff }> {
|
||||
const opts: ImportSnapshotOptions =
|
||||
typeof options === 'string'
|
||||
? { targetTenantId: options }
|
||||
: options || {};
|
||||
|
||||
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 tx = db.transaction(DB_OBJECT_STORES as unknown as string[], 'readwrite');
|
||||
|
||||
try {
|
||||
if (!targetTenantId) {
|
||||
// Global wipe & restore
|
||||
for (const store of DB_OBJECT_STORES) {
|
||||
const storeObj = tx.objectStore(store);
|
||||
if (mode === 'overwrite') {
|
||||
await storeObj.clear();
|
||||
}
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
await tx.done;
|
||||
} catch (err) {
|
||||
tx.abort();
|
||||
throw err;
|
||||
}
|
||||
|
||||
storageEventBus.emit('snapshot:imported', {
|
||||
action: 'import',
|
||||
data: snapshot.metadata,
|
||||
data: validSnapshot.metadata,
|
||||
});
|
||||
|
||||
return { backupCreated, diff };
|
||||
}
|
||||
|
||||
export async function resetDatabase(tenantId?: string): Promise<void> {
|
||||
|
|
@ -517,7 +710,7 @@ export async function resetDatabase(tenantId?: string): Promise<void> {
|
|||
await tx.done;
|
||||
} else {
|
||||
// Only wipe data belonging to the specified tenant
|
||||
const tx = db.transaction(['records_data', 'audit_log'] as const, 'readwrite');
|
||||
const tx = db.transaction(['records_data', 'audit_log', 'counters'] as const, 'readwrite');
|
||||
const recordsStore = tx.objectStore('records_data');
|
||||
const allRecords = (await recordsStore.getAll()) as RecordEntity[];
|
||||
for (const r of allRecords) {
|
||||
|
|
@ -532,6 +725,13 @@ export async function resetDatabase(tenantId?: string): Promise<void> {
|
|||
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;
|
||||
}
|
||||
|
||||
|
|
|
|||
93
src/shared/ui/AsyncBoundary.tsx
Normal file
93
src/shared/ui/AsyncBoundary.tsx
Normal file
|
|
@ -0,0 +1,93 @@
|
|||
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>
|
||||
);
|
||||
};
|
||||
195
src/shared/ui/StateView.tsx
Normal file
195
src/shared/ui/StateView.tsx
Normal file
|
|
@ -0,0 +1,195 @@
|
|||
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}</>;
|
||||
};
|
||||
183
src/shared/ui/__tests__/asyncStateHandling.test.tsx
Normal file
183
src/shared/ui/__tests__/asyncStateHandling.test.tsx
Normal file
|
|
@ -0,0 +1,183 @@
|
|||
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,3 +14,6 @@ export * from './Skeleton';
|
|||
export * from './EmptyState';
|
||||
export * from './ErrorBoundary';
|
||||
export * from './MaturityBadge';
|
||||
export * from './StateView';
|
||||
export * from './AsyncBoundary';
|
||||
export * from './useSubmitGuard';
|
||||
|
|
|
|||
26
src/shared/ui/useSubmitGuard.ts
Normal file
26
src/shared/ui/useSubmitGuard.ts
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
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,4 +1,6 @@
|
|||
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 DemoErrorType = 'none' | 'network_error' | 'forbidden_403' | 'conflict_409' | 'validation_422';
|
||||
|
|
@ -32,12 +34,8 @@ interface DemoState {
|
|||
const DEFAULT_DEMO_DATE = '2026-09-10T10:00:00.000Z';
|
||||
|
||||
export const useDemoStore = create<DemoState>((set) => ({
|
||||
currentProfileId:
|
||||
(typeof window !== 'undefined' && localStorage.getItem('krona_profile_id')) ||
|
||||
'multi-business',
|
||||
currentRoleId:
|
||||
(typeof window !== 'undefined' && localStorage.getItem('krona_role_id')) ||
|
||||
'owner',
|
||||
currentProfileId: useSessionStore.getState().currentProfileId,
|
||||
currentRoleId: useSessionStore.getState().currentRoleId,
|
||||
simulateLatency: false,
|
||||
latencyMs: 300,
|
||||
simulateErrors: false,
|
||||
|
|
@ -48,15 +46,11 @@ export const useDemoStore = create<DemoState>((set) => ({
|
|||
selectedScenario: 'retail-flow',
|
||||
|
||||
setCurrentProfileId: (profileId) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('krona_profile_id', profileId);
|
||||
}
|
||||
useSessionStore.getState().setCurrentProfileId(profileId);
|
||||
set({ currentProfileId: profileId });
|
||||
},
|
||||
setCurrentRoleId: (roleId) => {
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('krona_role_id', roleId);
|
||||
}
|
||||
useSessionStore.getState().setCurrentRoleId(roleId as SystemRoleId);
|
||||
set({ currentRoleId: roleId });
|
||||
},
|
||||
setSimulateLatency: (enable) => set({ simulateLatency: enable }),
|
||||
|
|
@ -73,10 +67,11 @@ export const useDemoStore = create<DemoState>((set) => ({
|
|||
setStateOverride: (override) => set({ stateOverride: override }),
|
||||
setShowMaturityBadges: (show) => set({ showMaturityBadges: show }),
|
||||
setSelectedScenario: (scenario) => set({ selectedScenario: scenario }),
|
||||
resetDemoControls: () =>
|
||||
resetDemoControls: () => {
|
||||
useSessionStore.getState().resetSession();
|
||||
set({
|
||||
currentProfileId: 'multi-business',
|
||||
currentRoleId: 'owner',
|
||||
currentProfileId: useSessionStore.getState().currentProfileId,
|
||||
currentRoleId: useSessionStore.getState().currentRoleId,
|
||||
simulateLatency: false,
|
||||
latencyMs: 300,
|
||||
simulateErrors: false,
|
||||
|
|
@ -85,5 +80,13 @@ export const useDemoStore = create<DemoState>((set) => ({
|
|||
stateOverride: 'normal',
|
||||
showMaturityBadges: true,
|
||||
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