From 005c4ac5bf196976a386d5983c0caeb7975b91ad Mon Sep 17 00:00:00 2001 From: nns Date: Thu, 8 Oct 2026 03:11:09 +0500 Subject: [PATCH] =?UTF-8?q?test(money):=20=D0=BD=D0=B5=D0=B7=D0=B0=D0=B2?= =?UTF-8?q?=D0=B8=D1=81=D0=B8=D0=BC=D1=8B=D0=B9=20=D0=B8=D0=BD=D0=B2=D0=B0?= =?UTF-8?q?=D1=80=D0=B8=D0=B0=D0=BD=D1=82=20=D0=BE=D1=81=D1=82=D0=B0=D1=82?= =?UTF-8?q?=D0=BA=D0=B0=20=D1=81=D1=87=D1=91=D1=82=D0=B0=20(=D0=BD=D0=B0?= =?UTF-8?q?=D1=87+=D0=BF=D0=BB=D0=B0=D1=82=D0=B5=D0=B6=D0=B8+=D1=81=D0=BC?= =?UTF-8?q?=D0=B5=D0=BD=D1=8B)=20+=20unit/integration?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit full-chain: indepBalance() считает остаток независимо (касса += факт−размен, эквайринг += картой−возвраты) и сверяет с API /balances — а не с суммой из того же API; шаг 8 — оплата нал ≤ остатка кассы + проверка отказа при превышении (409). Unit: shift-net == факт−размен (MoneyMathTests). Integration: закрытие смены пишет нал в остаток кассы, расход больше остатка → 409 (MoneyModuleTests); существующим тестам оплаты дан достаточный остаток кассы. Co-Authored-By: Claude Opus 5.5 --- tests/e2e/scenarios/full-chain.steps.ts | 91 +++++++++++++++---- .../MoneyModuleTests.cs | 51 ++++++++++- tests/food-market.UnitTests/MoneyMathTests.cs | 14 +++ 3 files changed, 134 insertions(+), 22 deletions(-) diff --git a/tests/e2e/scenarios/full-chain.steps.ts b/tests/e2e/scenarios/full-chain.steps.ts index 8f53def..4d7ea53 100644 --- a/tests/e2e/scenarios/full-chain.steps.ts +++ b/tests/e2e/scenarios/full-chain.steps.ts @@ -41,13 +41,17 @@ interface Ctx { supplies: SupplyRec[] pay: PayRec[] shift?: ShiftRec + closedShifts: ShiftContrib[] reportFrom?: string reportTo?: string } +/** Вклад закрытой смены в счета (для НЕЗАВИСИМого расчёта остатка в тесте): + * касса += actual − opening; эквайринг += salesCard − returnsCard. */ +interface ShiftContrib { cashAcctId: string; actual: number; opening: number; acqAcctId?: string; salesCard: number; returnsCard: number } interface Prod { id: string; name: string; price: number; cost: number; unitId: string; unitName: string; barcode: string; weighted: boolean } interface EmpRec { id: string; email: string; password: string; role: string; token?: string } interface SupplyRec { id: string; number?: string; total: number; supplierId: string; lines: { productId: string; quantity: number; unitPrice: number }[]; paid: number } -interface PayRec { txId: string; supplyIdx: number; amount: number; account: string } +interface PayRec { txId: string; supplyIdx: number; amount: number; account: string; accountId: string } interface ShiftRec { id: string; pointId: string; cashId: string; openingFloat: number salesCash: number; salesCard: number; returnsCash: number; drawerIn: number; drawerOut: number @@ -78,6 +82,7 @@ function initCtx(ctx: Ctx) { ctx.emp ??= {} ctx.supplies ??= [] ctx.pay ??= [] + ctx.closedShifts ??= [] ctx.cashOpening ??= 0 ctx.bankOpening ??= 0 ctx.acqOpening ??= 0 @@ -133,6 +138,17 @@ async function stockOf(api: AxiosInstance, storeId: string, productId: string): const row = items.find((i) => i.storeId === storeId && i.productId === productId) return row ? Number(row.quantity) : 0 } +// НЕЗАВИСИМЫЙ расчёт остатка счёта (считает сам тест из своих данных, не из +// money_transactions API): начальный − расходы теста + вклад закрытых смен +// (касса += факт − размен; эквайринг += продажи картой − возвраты картой). +function indepBalance(ctx: Ctx, accountId: string | undefined, opening: number): number { + if (!accountId) return opening + const payOut = ctx.pay.filter((p) => p.accountId === accountId).reduce((a, p) => a + p.amount, 0) + const shiftCash = ctx.closedShifts.filter((s) => s.cashAcctId === accountId).reduce((a, s) => a + (s.actual - s.opening), 0) + const shiftCard = ctx.closedShifts.filter((s) => s.acqAcctId === accountId).reduce((a, s) => a + (s.salesCard - s.returnsCard), 0) + return round2(opening - payOut + shiftCash + shiftCard) +} + // helper: sum of movements for a product+store (инвариант) async function movementsSum(api: AxiosInstance, storeId: string, productId: string): Promise { const r = await api.get(`/api/inventory/movements?productId=${productId}&storeId=${storeId}&pageSize=500`) @@ -800,16 +816,32 @@ export async function step08_supplier_payments({ ctx, step, report }: StepCtx) { const s1 = ctx.supplies[0] // приёмка №1 (реальный поставщик) const s2 = ctx.supplies[1] // приёмка №2 (placeholder) - // №1 — частично наличными из кассы - const partial = round2(s1.total * 0.4) + // №1 — частично наличными из кассы. Сумма НЕ должна превышать остаток кассы + // (нач. 50000, пока без движений смены): берём ≤ 30000. + const partial = round2(Math.min(s1.total * 0.4, 30_000)) const p1 = await api.post('/api/money/transactions', { Date: new Date().toISOString(), Kind: 4 /* CashOut */, AccountId: ctx.cash1Id, CounterpartyId: s1.supplierId, CurrencyId: ctx.currencyId, Amount: partial, Purpose: 'Оплата приёмки №1 (частично)', Links: [{ SupplyId: s1.id, LinkedSum: partial }], }) let ok1 = p1.status < 400 && !!p1.data?.id - if (ok1) { const post = await api.post(`/api/money/transactions/${p1.data.id}/post`, {}); ok1 = post.status < 400; ctx.pay.push({ txId: p1.data.id, supplyIdx: 0, amount: partial, account: 'cash' }); s1.paid = partial } - rec(step, 'api', 'Оплата №1 частично наличными из кассы', ok1, '204', `${p1.status}`) + if (ok1) { const post = await api.post(`/api/money/transactions/${p1.data.id}/post`, {}); ok1 = post.status < 400; ctx.pay.push({ txId: p1.data.id, supplyIdx: 0, amount: partial, account: 'cash', accountId: ctx.cash1Id! }); s1.paid = partial } + rec(step, 'api', 'Оплата №1 частично наличными из кассы (≤ остатка)', ok1, '204', `${p1.status}`) + + // Отказ при оплате больше остатка кассы (нет настройки «минус»). + const over = await api.post('/api/money/transactions', { + Date: new Date().toISOString(), Kind: 4 /* CashOut */, AccountId: ctx.cash1Id, + CounterpartyId: s1.supplierId, CurrencyId: ctx.currencyId, Amount: round2(ctx.cashOpening + 100_000), + Purpose: 'Переплата (тест отказа)', Links: [{ SupplyId: s1.id, LinkedSum: 0 }], ExpenseItemId: null, + }) + if (over.data?.id) { + const overPost = await api.post(`/api/money/transactions/${over.data.id}/post`, {}) + rec(step, 'api', 'Оплата больше остатка кассы → отказ (409 insufficient-funds)', overPost.status === 409, 409, overPost.status) + if (overPost.status !== 409) { await api.post(`/api/money/transactions/${over.data.id}/unpost`, {}); report.bug({ step: '08', severity: 'critical', title: 'Расход больше остатка кассы прошёл (касса в минус)', detail: `post=${overPost.status}` }) } + await api.delete(`/api/money/transactions/${over.data.id}`) + } else { + rec(step, 'api', 'Оплата больше остатка кассы → отказ', over.status === 409, 409, over.status) + } // №2 — полностью с банковского счёта const p2 = await api.post('/api/money/transactions', { @@ -818,7 +850,7 @@ export async function step08_supplier_payments({ ctx, step, report }: StepCtx) { Purpose: 'Оплата приёмки №2 (полностью)', Links: [{ SupplyId: s2.id, LinkedSum: s2.total }], }) let ok2 = p2.status < 400 && !!p2.data?.id - if (ok2) { const post = await api.post(`/api/money/transactions/${p2.data.id}/post`, {}); ok2 = post.status < 400; ctx.pay.push({ txId: p2.data.id, supplyIdx: 1, amount: s2.total, account: 'bank' }); s2.paid = s2.total } + if (ok2) { const post = await api.post(`/api/money/transactions/${p2.data.id}/post`, {}); ok2 = post.status < 400; ctx.pay.push({ txId: p2.data.id, supplyIdx: 1, amount: s2.total, account: 'bank', accountId: ctx.bankId! }); s2.paid = s2.total } rec(step, 'api', 'Оплата №2 полностью с банковского счёта', ok2, '204', `${p2.status}`) // статусы оплаты (PayedSum) @@ -926,6 +958,16 @@ export async function step09_cashier_shift({ ctx, step, report }: StepCtx) { rec(step, 'api', 'Z-отчёт: нал.продажи и expectedCash сходятся', zOk, `${salesCash}/${expected}`, `${z.data?.sales?.cash}/${z.data?.expectedCash}`) ctx.shift = { id: shiftId, pointId: ctx.point1Id, cashId: ctx.cash1Id, openingFloat, salesCash, salesCard, returnsCash: returnCash, drawerIn, drawerOut, expected, actual: actualCash } + ctx.closedShifts.push({ cashAcctId: ctx.cash1Id!, actual: actualCash, opening: openingFloat, acqAcctId: ctx.acq1Id, salesCard, returnsCard: 0 }) + + // Движения смены попали в остаток кассы: касса выросла на (факт − размен). + const balAfter = await api.get('/api/money/reports/balances') + const cashBal = ((balAfter.data?.accounts ?? []) as { accountId: string; currentBalance: number }[]).find((a) => a.accountId === ctx.cash1Id)?.currentBalance ?? 0 + const expCashAfter = round2(ctx.cashOpening - sum(ctx.pay.filter((p) => p.accountId === ctx.cash1Id).map((p) => p.amount)) + (actualCash - openingFloat)) + rec(step, 'api', 'Деньги смены в остатке кассы (касса += факт − размен)', approxEq(cashBal, expCashAfter), expCashAfter, cashBal) + if (!approxEq(cashBal, expCashAfter)) report.bug({ step: '09', severity: 'critical', title: 'Движения смены не попали в остаток кассы', detail: `ожид=${expCashAfter} факт=${cashBal}` }) + const acqBal = ((balAfter.data?.accounts ?? []) as { accountId: string; currentBalance: number }[]).find((a) => a.accountId === ctx.acq1Id)?.currentBalance ?? 0 + rec(step, 'api', 'Продажи картой в остатке эквайринга', approxEq(acqBal, salesCard), salesCard, acqBal) // продажа без смены → отказ (смена уже закрыта) const noShift = await makeSale(api, ctx, { productId: prods[1].id, qty: 1, unitPrice: prods[1].price, method: 0, pointId: ctx.point1Id }) @@ -990,11 +1032,13 @@ export async function step09a_points_binding({ ctx, step, report }: StepCtx) { const exp1 = round2(1000 + sp1.paidCash) const c1 = await api.post(`/api/money/shifts/${shift1}/close`, { ActualCash: exp1, Notes: 't1' }) rec(step, 'api', 'Точка 1: касса = размен + её продажа (деньги не смешаны)', approxEq(Number(c1.data?.expectedCash), exp1), exp1, c1.data?.expectedCash) + ctx.closedShifts.push({ cashAcctId: ctx.cash1Id!, actual: exp1, opening: 1000, acqAcctId: ctx.acq1Id, salesCard: 0, returnsCard: 0 }) } if (shift2) { const exp2 = round2(2000 + sp2.paidCash) const c2 = await api.post(`/api/money/shifts/${shift2}/close`, { ActualCash: exp2, Notes: 't2' }) rec(step, 'api', 'Точка 2: касса = размен + её продажа (деньги не смешаны)', approxEq(Number(c2.data?.expectedCash), exp2), exp2, c2.data?.expectedCash) + ctx.closedShifts.push({ cashAcctId: ctx.cash2Id!, actual: exp2, opening: 2000, acqAcctId: ctx.acq2Id, salesCard: 0, returnsCard: 0 }) } // продажа на НЕактивной точке → отказ @@ -1145,13 +1189,16 @@ export async function step11_reports({ ctx, step, report }: StepCtx) { // отчёт «Деньги»: остаток каждого счёта = начальный + движения const bal = await api.get('/api/money/reports/balances') const accs = (bal.data?.accounts ?? []) as { accountId: string; openingBalance: number; currentBalance: number }[] - // касса: opening − оплата №1 наличными (partial); банк: opening − оплата №2 - const paidCash = ctx.pay.filter((p) => p.account === 'cash').reduce((a, p) => a + p.amount, 0) - const paidBank = ctx.pay.filter((p) => p.account === 'bank').reduce((a, p) => a + p.amount, 0) + // остаток счёта = начальный + платежи + ДВИЖЕНИЯ СМЕН (независимый расчёт). const cashAcc = accs.find((a) => a.accountId === ctx.cash1Id) - rec(step, 'api', 'Деньги: касса = начальный − оплата наличными', !!cashAcc && approxEq(cashAcc.currentBalance, round2(ctx.cashOpening - paidCash)), round2(ctx.cashOpening - paidCash), cashAcc?.currentBalance) + const expCash = indepBalance(ctx, ctx.cash1Id, ctx.cashOpening) + rec(step, 'api', 'Деньги: касса = начальный − оплаты + движения смен', !!cashAcc && approxEq(cashAcc.currentBalance, expCash), expCash, cashAcc?.currentBalance) const bankAcc = accs.find((a) => a.accountId === ctx.bankId) - rec(step, 'api', 'Деньги: банк = начальный − оплата с банка', !!bankAcc && approxEq(bankAcc.currentBalance, round2(ctx.bankOpening - paidBank)), round2(ctx.bankOpening - paidBank), bankAcc?.currentBalance) + const expBank = indepBalance(ctx, ctx.bankId, ctx.bankOpening) + rec(step, 'api', 'Деньги: банк = начальный − оплата с банка', !!bankAcc && approxEq(bankAcc.currentBalance, expBank), expBank, bankAcc?.currentBalance) + const acqAcc = accs.find((a) => a.accountId === ctx.acq1Id) + const expAcq = indepBalance(ctx, ctx.acq1Id, ctx.acqOpening) + rec(step, 'api', 'Деньги: эквайринг = продажи картой − возвраты картой', !!acqAcc && approxEq(acqAcc.currentBalance, expAcq), expAcq, acqAcc?.currentBalance) // долги поставщикам const debts = await api.get('/api/money/reports/supplier-debts?withDebtOnly=true') @@ -1194,16 +1241,22 @@ export async function step12_invariants({ ctx, step, report }: StepCtx) { } if (!stockOk) report.bug({ step: '12', severity: 'critical', title: 'Остаток ≠ сумме движений склада', detail: 'см. строки шага' }) - // 2) по каждому счёту: баланс ≡ начальный + Σ проведённых операций + // 2) по каждому счёту: остаток ≡ НЕЗАВИСИМО посчитанной в тесте сумме + // (начальный + платежи + движения смен), а НЕ сумме из того же API. const bal = await api.get('/api/money/reports/balances') const accs = (bal.data?.accounts ?? []) as { accountId: string; name: string; openingBalance: number; currentBalance: number }[] - for (const a of accs) { - const txs = await api.get(`/api/money/transactions?accountId=${a.accountId}&status=1&pageSize=500`) - const items = (txs.data?.items ?? []) as { kind: number; amount: number }[] - const inSum = sum(items.filter((t) => [1, 3, 6].includes(t.kind)).map((t) => Number(t.amount))) - const outSum = sum(items.filter((t) => [2, 4, 5].includes(t.kind)).map((t) => Number(t.amount))) - const expected = round2(Number(a.openingBalance) + inSum - outSum) - rec(step, 'api', `Инвариант счёт≡начальный+Σопераций: ${a.name.slice(0, 16)}`, approxEq(Number(a.currentBalance), expected), expected, a.currentBalance) + const known: [string | undefined, number, string][] = [ + [ctx.cash1Id, ctx.cashOpening, 'Касса'], + [ctx.bankId, ctx.bankOpening, 'Банк'], + [ctx.acq1Id, ctx.acqOpening, 'Эквайринг'], + [ctx.cash2Id, 3000, 'Касса-2'], + ] + for (const [acctId, opening, label] of known) { + if (!acctId) continue + const a = accs.find((x) => x.accountId === acctId) + const expected = indepBalance(ctx, acctId, opening) + rec(step, 'api', `Инвариант ${label} ≡ независимому расчёту (нач+платежи+смены)`, !!a && approxEq(Number(a.currentBalance), expected), expected, a?.currentBalance) + if (a && !approxEq(Number(a.currentBalance), expected)) report.bug({ step: '12', severity: 'critical', title: `Остаток счёта ${label} ≠ независимой формуле`, detail: `ожид=${expected} факт=${a.currentBalance}` }) } // 3) долг поставщика ≡ формуле (пересчёт независимо) diff --git a/tests/food-market.IntegrationTests/MoneyModuleTests.cs b/tests/food-market.IntegrationTests/MoneyModuleTests.cs index 44268de..efe9269 100644 --- a/tests/food-market.IntegrationTests/MoneyModuleTests.cs +++ b/tests/food-market.IntegrationTests/MoneyModuleTests.cs @@ -96,7 +96,7 @@ public async Task Partial_then_full_payment_moves_supply_status_unpaid_partial_p var refs = await api.LoadRefsAsync(); var supplier = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}"); var product = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 150m, RandomBarcode()); - var cash = await api.CreateMoneyAccountAsync("Касса", Cash, refs.CurrencyId, refs.StoreId, 0m); + var cash = await api.CreateMoneyAccountAsync("Касса", Cash, refs.CurrencyId, refs.StoreId, 1_000m); var (supplyId, total) = await api.CreatePostedSupplyAsync(refs, supplier, product, qty: 10m, unitPrice: 100m); total.Should().Be(1000m); @@ -122,7 +122,7 @@ public async Task Overpaying_supply_is_rejected() var refs = await api.LoadRefsAsync(); var supplier = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}"); var product = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 150m, RandomBarcode()); - var cash = await api.CreateMoneyAccountAsync("Касса", Cash, refs.CurrencyId, refs.StoreId, 0m); + var cash = await api.CreateMoneyAccountAsync("Касса", Cash, refs.CurrencyId, refs.StoreId, 900m); var (supplyId, _) = await api.CreatePostedSupplyAsync(refs, supplier, product, 5m, 100m); // total 500 // Платёж на 800 с привязкой 800 → при проведении переплата → 409. @@ -146,7 +146,7 @@ public async Task Supplier_debt_report_reflects_supplies_minus_payments() var refs = await api.LoadRefsAsync(); var supplier = await api.CreateCounterpartyAsync($"Долговой-{Guid.NewGuid():N}"); var product = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 150m, RandomBarcode()); - var cash = await api.CreateMoneyAccountAsync("Касса", Cash, refs.CurrencyId, refs.StoreId, 0m); + var cash = await api.CreateMoneyAccountAsync("Касса", Cash, refs.CurrencyId, refs.StoreId, 1_000m); var (supplyId, _) = await api.CreatePostedSupplyAsync(refs, supplier, product, 10m, 100m); // 1000 await PaySupplyAsync(api, cash, refs.CurrencyId, supplyId, 300m); @@ -211,6 +211,51 @@ public async Task Closing_shift_computes_expected_cash_and_difference() body.GetProperty("difference").GetDecimal().Should().Be(0m); } + [Fact] + public async Task Closing_shift_posts_cash_sales_into_cash_account_balance() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"shbal-{Guid.NewGuid():N}"); + var refs = await api.LoadRefsAsync(); + var supplier = await api.CreateCounterpartyAsync($"S-{Guid.NewGuid():N}"); + var product = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 500m, RandomBarcode()); + await api.CreatePostedSupplyAsync(refs, supplier, product, 10m, 100m); + var retailPoint = await api.CreateRetailPointAsync(refs.StoreId, "Т1"); + var cash = await api.CreateMoneyAccountAsync("Касса", Cash, refs.CurrencyId, refs.StoreId, 0m); + + var shiftId = await OpenShiftAsync(api, retailPoint, cash, openingFloat: 5_000m); + var saleId = await CreateCashSaleDraftAsync(api, refs, retailPoint, product, 2m, 500m); // 1000 нал + (await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { })).EnsureSuccessStatusCode(); + (await api.Http.PostAsJsonAsync($"/api/money/shifts/{shiftId}/close", new { actualCash = 6_000m, notes = (string?)null })).EnsureSuccessStatusCode(); + + // Остаток кассы = 0 (нач. счёта) + (6000 факт − 5000 размен) = 1000 (продажа нал). + var balances = await api.GetJsonAsync("/api/money/reports/balances"); + var row = balances.GetProperty("accounts").EnumerateArray().First(x => x.GetProperty("accountId").GetString() == cash); + row.GetProperty("currentBalance").GetDecimal().Should().Be(1_000m); + } + + [Fact] + public async Task Cash_expense_over_balance_is_rejected() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"neg-{Guid.NewGuid():N}"); + var refs = await api.LoadRefsAsync(); + var cash = await api.CreateMoneyAccountAsync("Касса", Cash, refs.CurrencyId, refs.StoreId, 5_000m); + + var items = await api.ListAsync("/api/money/expense-items?pageSize=50"); + var expenseItemId = items.First().GetProperty("id").GetString(); + var created = await api.Http.PostAsJsonAsync("/api/money/transactions", new + { + date = DateTime.UtcNow, kind = 4, accountId = cash, currencyId = refs.CurrencyId, + amount = 10_000m, expenseItemId, purpose = "big", + }); + created.EnsureSuccessStatusCode(); + var txId = (await created.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + // 10 000 > остаток 5 000 → 409 (орг не разрешает минус). + var post = await api.Http.PostAsJsonAsync($"/api/money/transactions/{txId}/post", new { }); + ((int)post.StatusCode).Should().Be(409); + } + // ── helpers ───────────────────────────────────────────────────────────── private static async Task PostTxAsync(ApiActor api, int kind, string accountId, decimal amount, string currencyId, bool needExpenseItem = false, ApiActor? api2 = null) diff --git a/tests/food-market.UnitTests/MoneyMathTests.cs b/tests/food-market.UnitTests/MoneyMathTests.cs index 3ee4bb9..757fb69 100644 --- a/tests/food-market.UnitTests/MoneyMathTests.cs +++ b/tests/food-market.UnitTests/MoneyMathTests.cs @@ -117,4 +117,18 @@ public void RolePermissions_default_denies_money() p.MoneyEdit.Should().BeFalse(); p.CashShiftOperate.Should().BeFalse(); } + + [Fact] + public void Shift_net_cash_movement_equals_actual_minus_opening() + { + // Вклад смены в кассу = продажи нал − возвраты нал + внесения − выплаты + + // расхождение = (expected − размен) + (факт − expected) = факт − размен. + // Числа — из факта стенда (смена С-2026-000001). + decimal opening = 5_000m, salesCash = 3_775m, returnsCash = 1_500m, drawerIn = 10_000m, drawerOut = 3_000m; + var expected = MoneyMath.ExpectedCash(opening, salesCash, returnsCash, drawerIn, drawerOut); + decimal actual = 14_775m; + var diff = MoneyMath.CashDifference(actual, expected); + var net = salesCash - returnsCash + drawerIn - drawerOut + diff; + net.Should().Be(actual - opening); + } }