feat(platform): monotonic numbering, pagination invariants, route tabs deep linking, and module persistence
This commit is contained in:
parent
5200b7ebd5
commit
d1ccc1c197
10 changed files with 519 additions and 54 deletions
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,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,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 />;
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 });
|
||||
}
|
||||
|
|
|
|||
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');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
|
@ -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,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,
|
||||
|
|
@ -517,7 +554,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 +569,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;
|
||||
}
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue