test(money): независимый инвариант остатка счёта (нач+платежи+смены) + unit/integration
full-chain: indepBalance() считает остаток независимо (касса += факт−размен, эквайринг += картой−возвраты) и сверяет с API /balances — а не с суммой из того же API; шаг 8 — оплата нал ≤ остатка кассы + проверка отказа при превышении (409). Unit: shift-net == факт−размен (MoneyMathTests). Integration: закрытие смены пишет нал в остаток кассы, расход больше остатка → 409 (MoneyModuleTests); существующим тестам оплаты дан достаточный остаток кассы. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
parent
1e6d7fb2ab
commit
005c4ac5bf
|
|
@ -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<number> {
|
||||
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) долг поставщика ≡ формуле (пересчёт независимо)
|
||||
|
|
|
|||
|
|
@ -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<JsonElement>()).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)
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue