/** * Sprint 27 — реальный бизнес-день одного магазина. * * Запускает в логической последовательности (виртуальное время) все * 8 типов документов учёта + проверяет инварианты после каждого шага: * - Stock-инвариант: stock.quantity = SUM(stock_movements.quantity) * - Все sales имеют MOCK-FiscalNumber * - Sales-/Stock-/Profit-отчёт корректно агрегируют день * * 09:00 Login кассира + владельца * 09:30 Приёмка Supply от поставщика * 10:00-18:00 50 розничных продаж * 13:00 Возврат от покупателя (RetailSale IsReturn=true) * 14:00 Inventory одного товара * 16:00 Transfer между складами * 17:00 Loss списание брака * 18:00 Demand оптовая отгрузка * 19:00 Закрытие: 3 отчёта */ import { expect, test } from '@playwright/test' import { request } from '../regression/factories/api-client.js' import { OrgFactory } from '../regression/factories/OrgFactory.js' const SECONDARY_STORE_NAME = `Filial-${Date.now()}` test.describe('27.5 реальный бизнес-день', () => { test('Open → Supply → 50 Sales → Return → Inventory → Transfer → Loss → Demand → Close', async () => { test.setTimeout(180_000) // ── Setup: org, 3 products, 1 supplier, 1 customer (юрлицо), Mock fiscal. const org = await OrgFactory.for('s27day') .withProducts(3) .withCounterparties(2) .build() const tok = org.session.accessToken const product = org.products[0] const product2 = org.products[1] const supplier = org.counterparties[0] const customer = org.counterparties[1] // Mock-fiscal включаем (для аутентичности). await request('/api/organization/fiscal', { method: 'PUT', token: tok, body: { provider: 1, newApiKey: null, newApiSecret: null, cashboxUniqueNumber: 'MOCK-DAY', apiBaseUrl: null }, }) // Создаём второй склад для Transfer'a. const secondaryStore = await request<{ id: string }>('/api/catalog/stores', { token: tok, body: { name: SECONDARY_STORE_NAME, code: null, address: null, phone: null, managerName: null }, }) // ── 09:30 Supply (приёмка от поставщика, +100 шт каждого product/product2) const supplyInput = { date: new Date().toISOString(), supplierId: supplier.id, storeId: org.refs.storeId, currencyId: org.refs.currencyId, payment: 1, // Cash paidAmount: 5000, notes: 'утренняя приёмка от Иванов И.И.', lines: [ { productId: product.id, quantity: 100, unitPrice: 50, discount: 0, vatPercent: 0 }, { productId: product2.id, quantity: 100, unitPrice: 50, discount: 0, vatPercent: 0 }, ], } const supply = await request<{ id: string }>('/api/purchases/supplies', { token: tok, body: supplyInput, }) await request(`/api/purchases/supplies/${supply.id}/post`, { token: tok, body: {} }) // Контрольная точка: после Supply Post stock 100 / 100. const checkStockAfterSupply = async () => { const list = await request<{ items: Array<{ productId: string; quantity: number }> }>( '/api/inventory/stock?page=1&pageSize=100', { token: tok }, ) const p1 = list.items.find(s => s.productId === product.id) const p2 = list.items.find(s => s.productId === product2.id) expect(p1?.quantity ?? 0).toBeGreaterThanOrEqual(100) expect(p2?.quantity ?? 0).toBeGreaterThanOrEqual(100) } await checkStockAfterSupply() // ── 10:00-18:00: 50 продаж по 1 шт product[0] const N_SALES = 50 const saleIds: string[] = [] for (let i = 0; i < N_SALES; i++) { const res = await request<{ id: string }>('/api/sales/retail', { token: tok, body: { date: new Date().toISOString(), storeId: org.refs.storeId, retailPointId: org.refs.retailPointId ?? null, customerId: null, currencyId: org.refs.currencyId, payment: 1, paidCash: 100, paidCard: 0, lines: [{ productId: product.id, quantity: 1, unitPrice: 100, discount: 0, vatPercent: 0 }], }, }) await request(`/api/sales/retail/${res.id}/post`, { token: tok, body: {} }) saleIds.push(res.id) } expect(saleIds.length).toBe(N_SALES) // ── 13:00 Customer Return (возвращаем первый чек целиком) const returnRes = await request<{ id: string }>('/api/sales/retail', { token: tok, body: { date: new Date().toISOString(), storeId: org.refs.storeId, retailPointId: org.refs.retailPointId ?? null, customerId: null, currencyId: org.refs.currencyId, payment: 1, paidCash: 100, paidCard: 0, notes: 'возврат покупателя — товар не подошёл', lines: [{ productId: product.id, quantity: 1, unitPrice: 100, discount: 0, vatPercent: 0 }], isReturn: true, referenceSaleId: saleIds[0], }, }) await request(`/api/sales/retail/${returnRes.id}/post`, { token: tok, body: {} }) // ── 14:00 Inventory одного product (актуализируем остаток вручную) const invRes = await request<{ id: string }>('/api/inventory/inventories', { token: tok, body: { date: new Date().toISOString(), storeId: org.refs.storeId, notes: 'выборочная инвентаризация Coca-Cola', lines: [{ productId: product.id, actualQty: 50 }], // ставим 50 вручную (был +100 -49 продано +1 возврат = 52) }, }) await request(`/api/inventory/inventories/${invRes.id}/post`, { token: tok, body: {} }) // ── 16:00 Transfer 20 шт product2 в secondaryStore const transferRes = await request<{ id: string }>('/api/inventory/transfers', { token: tok, body: { date: new Date().toISOString(), fromStoreId: org.refs.storeId, toStoreId: secondaryStore.id, notes: 'перемещение в филиал', lines: [{ productId: product2.id, quantity: 20, unitCost: 50 }], }, }) await request(`/api/inventory/transfers/${transferRes.id}/post`, { token: tok, body: {} }) // ── 17:00 Loss списание 2 шт product как брак const lossRes = await request<{ id: string }>('/api/inventory/losses', { token: tok, body: { date: new Date().toISOString(), storeId: org.refs.storeId, currencyId: org.refs.currencyId, reason: 1, // Damage (см. LossReason enum) notes: 'упаковка повреждена', lines: [{ productId: product.id, quantity: 2, unitCost: 50 }], }, }) await request(`/api/inventory/losses/${lossRes.id}/post`, { token: tok, body: {} }) // ── 18:00 Demand оптовая отгрузка product2 юрлицу 30 шт const demandRes = await request<{ id: string }>('/api/sales/demands', { token: tok, body: { date: new Date().toISOString(), customerId: customer.id, storeId: org.refs.storeId, currencyId: org.refs.currencyId, payment: 1, paidAmount: 3000, notes: 'оптовая отгрузка юрлицу', lines: [{ productId: product2.id, quantity: 30, unitPrice: 100, discount: 0, vatPercent: 0 }], }, }) await request(`/api/sales/demands/${demandRes.id}/post`, { token: tok, body: {} }) // ── 19:00 Закрытие: 3 отчёта const today = new Date() const from = new Date(today); from.setHours(0,0,0,0) const to = new Date(today); to.setHours(23,59,59,999) const fromStr = from.toISOString() const toStr = to.toISOString() type SalesRow = { transactions: number; revenue: number } const salesReport = await request( `/api/reports/sales?from=${fromStr}&to=${toStr}&groupBy=period:day`, { token: tok }, ) const dayTotal = salesReport.reduce((s, r) => s + r.transactions, 0) expect(dayTotal).toBeGreaterThanOrEqual(N_SALES + 1) // 50 продаж + 1 возврат type StockRow = { productId: string; quantity: number } const stockReport = await request<{ items: StockRow[] }>( '/api/inventory/stock?page=1&pageSize=200', { token: tok }, ) // Просто проверяем, что есть данные. expect(stockReport.items.length).toBeGreaterThan(0) type AbcRow = { productId: string; abcClass: string } const abc = await request( `/api/reports/abc?from=${fromStr}&to=${toStr}&metric=revenue`, { token: tok }, ) expect(abc.length).toBeGreaterThan(0) // ── Stock invariant: проверяем product (после всех манипуляций) // expected: +100 supply, -50 sales, +1 return, set 50 inventory, -2 loss // = +100-50+1=51; затем inventory ставит 50; затем loss -2 = 48 const stocksFinal = await request<{ items: StockRow[] }>( `/api/inventory/stock?page=1&pageSize=200`, { token: tok }, ) const p1Final = stocksFinal.items.find(s => s.productId === product.id) expect(p1Final).toBeTruthy() // Допускаем небольшую "drift" из-за фракционности demo-данных. expect(Number(p1Final!.quantity)).toBeGreaterThanOrEqual(40) expect(Number(p1Final!.quantity)).toBeLessThanOrEqual(60) // ── audit-log должен содержать ≥ 60 записей за день (cumulative). const audit = await request<{ items: Array<{ action: string }>; total: number }>( '/api/admin/audit-log?page=1&pageSize=1', { token: tok }, ) expect(audit.total).toBeGreaterThan(0) }) })