feat(platform): monotonic numbering, pagination invariants, route tabs deep linking, and module persistence

This commit is contained in:
ochenstarik 2026-09-11 15:30:56 +07:00
parent 5200b7ebd5
commit d1ccc1c197
10 changed files with 519 additions and 54 deletions

View 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();
});
});
});

View file

@ -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,14 +130,12 @@ export const EmployeesModule: React.FC = () => {
const hasConflict = editingShift.hours > 12;
const conflictReason = hasConflict ? 'Смена превышает 12 ч (ТК РФ)' : undefined;
setShifts((prev) => {
const filtered = prev.filter(
const filtered = shifts.filter(
(s) => !(s.employeeId === editingShift.empId && s.day === editingShift.day)
);
if (editingShift.shiftText === 'Выходной' || editingShift.hours === 0) {
return filtered;
}
return [
const updatedShifts = (editingShift.shiftText === 'Выходной' || editingShift.hours === 0)
? filtered
: [
...filtered,
{
employeeId: editingShift.empId,
@ -117,8 +146,8 @@ export const EmployeesModule: React.FC = () => {
conflictReason,
},
];
});
persistShifts(updatedShifts);
setEditingShift(null);
showToast({
title: 'Смена обновлена',

View file

@ -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');

View file

@ -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('');

View file

@ -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>

View file

@ -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 />;
}

View file

@ -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 });
}

View 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');
});
});
});

View file

@ -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,

View file

@ -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;
}