From deef65d8198d024d30aa46d539702a6aed2fa645 Mon Sep 17 00:00:00 2001 From: nns Date: Wed, 7 Oct 2026 22:11:19 +0500 Subject: [PATCH] =?UTF-8?q?test(money):=20unit=20(MoneyMath=2025)=20+=20in?= =?UTF-8?q?tegration=20(7=20=D0=BD=D0=B0=20Postgres)=20+=20MoneyMath=20ref?= =?UTF-8?q?actor?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - MoneyMath: вынесены чистые формулы (статус оплаты, ожидаемая наличность, разница смены, остаток счёта, долг поставщика, защита от переплаты); сервис/SuppliesController/MoneyReportsController теперь зовут их. - UnitTests/MoneyMathTests: 25 кейсов на формулы + money-флаги RolePermissions. - IntegrationTests/MoneyModuleTests (реальный Postgres, 7 [Fact]): мультитенантность (Б не видит счета А), инвариант остатка, частичная→полная оплата приёмки, переплата→409, долг поставщика, продажа без смены→409 + после open→ok, расчёт expected/difference при закрытии смены. - ApiActor: helpers CreateRetailPoint/CreateMoneyAccount/CreatePostedSupply. - tests/e2e/scrape-moysklad.ts — скрейпер для docs/money-moysklad-reference.md. Прогон: UnitTests 25/25 ✓, IntegrationTests money 7/7 ✓ (33s, Testcontainers). Co-Authored-By: Claude Opus 4.8 --- .../Money/MoneyReportsController.cs | 12 +- .../Purchases/SuppliesController.cs | 6 +- .../Money/MoneyMath.cs | 54 ++++ .../Money/MoneyService.cs | 8 +- tests/e2e/scrape-moysklad.ts | 74 ++++++ .../MoneyModuleTests.cs | 244 ++++++++++++++++++ .../Support/ApiActor.cs | 40 +++ tests/food-market.UnitTests/MoneyMathTests.cs | 120 +++++++++ 8 files changed, 544 insertions(+), 14 deletions(-) create mode 100644 src/food-market.application/Money/MoneyMath.cs create mode 100644 tests/e2e/scrape-moysklad.ts create mode 100644 tests/food-market.IntegrationTests/MoneyModuleTests.cs create mode 100644 tests/food-market.UnitTests/MoneyMathTests.cs diff --git a/src/food-market.api/Controllers/Money/MoneyReportsController.cs b/src/food-market.api/Controllers/Money/MoneyReportsController.cs index f1cefe6..54c2007 100644 --- a/src/food-market.api/Controllers/Money/MoneyReportsController.cs +++ b/src/food-market.api/Controllers/Money/MoneyReportsController.cs @@ -1,5 +1,6 @@ using foodmarket.Api.Infrastructure.Authorization; using foodmarket.Application.Common.Tenancy; +using foodmarket.Application.Money; using foodmarket.Domain.Money; using foodmarket.Domain.Purchases; using foodmarket.Infrastructure.Persistence; @@ -57,7 +58,7 @@ public async Task> Balances(CancellationToken ct) || m.Kind == MoneyTransactionKind.TransferOut)).Sum(m => m.Sum); return new AccountBalanceRow(a.Id, a.Name, a.Kind, currencies.TryGetValue(a.CurrencyId, out var code) ? code : "", - a.OpeningBalance, a.OpeningBalance + inSum - outSum, a.IsActive); + a.OpeningBalance, MoneyMath.AccountBalance(a.OpeningBalance, inSum, outSum), a.IsActive); }).ToList(); return new BalancesReport(rows, rows.Sum(r => r.CurrentBalance)); @@ -169,10 +170,11 @@ public record SupplierDebtsReport(IReadOnlyList Rows, decimal T var r = returns.FirstOrDefault(x => x.SupplierId == sup.Id); var a = adjustments.FirstOrDefault(x => x.CounterpartyId == sup.Id); // Долг = (приёмки − оплаченные приёмки) − (возвраты − полученные обратно деньги) + (корр: мы должны − они должны). - var debt = (s?.Total ?? 0) - (s?.Paid ?? 0) - - ((r?.Total ?? 0) - (r?.Paid ?? 0)) - + (a?.WeOwe ?? 0) - (a?.TheyOwe ?? 0); - if (withDebtOnly && Math.Abs(debt) < 0.0001m) continue; + var debt = MoneyMath.SupplierDebt( + s?.Total ?? 0, s?.Paid ?? 0, + r?.Total ?? 0, r?.Paid ?? 0, + a?.WeOwe ?? 0, a?.TheyOwe ?? 0); + if (withDebtOnly && Math.Abs(debt) < MoneyMath.Eps) continue; rows.Add(new SupplierDebtRow( sup.Id, sup.Name, sup.Bin, sup.Phone, s?.Total ?? 0, r?.Total ?? 0, diff --git a/src/food-market.api/Controllers/Purchases/SuppliesController.cs b/src/food-market.api/Controllers/Purchases/SuppliesController.cs index e58b89d..be313da 100644 --- a/src/food-market.api/Controllers/Purchases/SuppliesController.cs +++ b/src/food-market.api/Controllers/Purchases/SuppliesController.cs @@ -43,11 +43,7 @@ public record SupplyListRow( /// "unpaid" (0), "partial" (0 < payed < total), "paid" (payed ≥ total). /// Фронт показывает цветной бейдж. Расчёт здесь — простейший порог. private static string ComputePaymentStatus(decimal total, decimal payed) - { - if (total <= 0) return "paid"; - if (payed <= 0) return "unpaid"; - return payed + 0.0001m >= total ? "paid" : "partial"; - } + => foodmarket.Application.Money.MoneyMath.PaymentStatus(total, payed); public record SupplyLineDto( Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, diff --git a/src/food-market.application/Money/MoneyMath.cs b/src/food-market.application/Money/MoneyMath.cs new file mode 100644 index 0000000..7aa34e3 --- /dev/null +++ b/src/food-market.application/Money/MoneyMath.cs @@ -0,0 +1,54 @@ +namespace foodmarket.Application.Money; + +/// Чистые денежные формулы модуля «Деньги» — вынесены отдельно, чтобы +/// (а) посчитать одинаково в сервисе, контроллерах и отчётах и (б) покрыть +/// unit-тестами без БД. Все суммы — decimal, порог сравнения 0.0001 (мы храним +/// NUMERIC(18,4)). +public static class MoneyMath +{ + /// Порог «копеечной» погрешности для сравнений сумм. + public const decimal Eps = 0.0001m; + + /// Агрегатный статус оплаты документа (приёмки/возврата): + /// "unpaid" | "partial" | "paid". Нулевой/отрицательный + /// total считаем оплаченным (нечего платить). + public static string PaymentStatus(decimal total, decimal payed) + { + if (total <= 0) return "paid"; + if (payed <= 0) return "unpaid"; + return payed + Eps >= total ? "paid" : "partial"; + } + + /// Ожидаемая наличность в кассе при закрытии смены: + /// разменник + продажи наличными − возвраты наличными + внесения − выплаты. + public static decimal ExpectedCash(decimal openingFloat, decimal salesCash, + decimal returnsCash, decimal drawerIn, decimal drawerOut) + => openingFloat + salesCash - returnsCash + drawerIn - drawerOut; + + /// Разница при закрытии смены: факт − ожидание. <0 — недостача, + /// >0 — излишек. + public static decimal CashDifference(decimal actualCash, decimal expectedCash) + => actualCash - expectedCash; + + /// Текущий остаток счёта = начальный + Σ приходов − Σ расходов + /// (только проведённые движения; переводы учитываются как обычные in/out). + public static decimal AccountBalance(decimal openingBalance, decimal inSum, decimal outSum) + => openingBalance + inSum - outSum; + + /// Долг перед поставщиком: + /// (приёмки − оплаченные приёмки) − (возвраты − возвращённые деньги) + /// + (корректировки: «мы должны» − «они должны»). + /// Плюс — мы должны поставщику; минус — переплата/он должен нам. + public static decimal SupplierDebt( + decimal suppliesTotal, decimal suppliesPaid, + decimal returnsTotal, decimal returnsPaid, + decimal adjustmentsWeOwe, decimal adjustmentsTheyOwe) + => (suppliesTotal - suppliesPaid) + - (returnsTotal - returnsPaid) + + (adjustmentsWeOwe - adjustmentsTheyOwe); + + /// Можно ли довнести к документу, не + /// переплатив (payed + add ≤ total + ε). + public static bool CanApplyPayment(decimal currentPayed, decimal addSum, decimal total) + => currentPayed + addSum <= total + Eps; +} diff --git a/src/food-market.infrastructure/Money/MoneyService.cs b/src/food-market.infrastructure/Money/MoneyService.cs index fc03a06..cc46c69 100644 --- a/src/food-market.infrastructure/Money/MoneyService.cs +++ b/src/food-market.infrastructure/Money/MoneyService.cs @@ -71,7 +71,7 @@ public async Task PostAsync(Guid transactionId, Guid current { var supply = await _db.Supplies.FirstAsync(s => s.Id == sId, ct); var newPayed = supply.PayedSum + link.LinkedSum; - if (newPayed > supply.Total + 0.0001m) + if (!MoneyMath.CanApplyPayment(supply.PayedSum, link.LinkedSum, supply.Total)) return MoneyServiceResult.Fail(MoneyServiceStatus.OverpaidDocument, "supply-overpaid", $"Приёмка {supply.Number} была бы переплачена: {newPayed:0.##} > {supply.Total:0.##}."); supply.PayedSum = newPayed; @@ -81,7 +81,7 @@ public async Task PostAsync(Guid transactionId, Guid current { var sr = await _db.SupplierReturns.FirstAsync(s => s.Id == srId, ct); var newPayed = sr.PayedSum + link.LinkedSum; - if (newPayed > sr.Total + 0.0001m) + if (!MoneyMath.CanApplyPayment(sr.PayedSum, link.LinkedSum, sr.Total)) return MoneyServiceResult.Fail(MoneyServiceStatus.OverpaidDocument, "supplier-return-overpaid", $"Возврат поставщику {sr.Number} был бы переплачен: {newPayed:0.##} > {sr.Total:0.##}."); sr.PayedSum = newPayed; @@ -252,7 +252,7 @@ public async Task UnpostAsync(Guid transactionId, Guid curre var returnsCard = sales.Where(s => s.IsReturn).Sum(s => s.PaidCard); var drawerIn = shift.DrawerOperations.Where(o => o.Direction == CashDrawerDirection.In).Sum(o => o.Amount); var drawerOut = shift.DrawerOperations.Where(o => o.Direction == CashDrawerDirection.Out).Sum(o => o.Amount); - var expected = shift.OpeningFloat + salesCash - returnsCash + drawerIn - drawerOut; + var expected = MoneyMath.ExpectedCash(shift.OpeningFloat, salesCash, returnsCash, drawerIn, drawerOut); shift.SalesCash = salesCash; shift.SalesCard = salesCard; @@ -262,7 +262,7 @@ public async Task UnpostAsync(Guid transactionId, Guid curre shift.DrawerOut = drawerOut; shift.ExpectedCash = expected; shift.ActualCash = actualCash; - shift.CashDifference = actualCash - expected; + shift.CashDifference = MoneyMath.CashDifference(actualCash, expected); shift.Status = CashShiftStatus.Closed; shift.ClosedAt = DateTime.UtcNow; shift.ClosedByUserId = closedByUserId; diff --git a/tests/e2e/scrape-moysklad.ts b/tests/e2e/scrape-moysklad.ts new file mode 100644 index 0000000..2c8fcd0 --- /dev/null +++ b/tests/e2e/scrape-moysklad.ts @@ -0,0 +1,74 @@ +// Scrape MoySklad REST API docs for sections relevant to the money module. +// Headless-Chromium нужен потому что doc SPA рендерит разделы JS-ом. +// Запуск: cd tests/e2e && npx tsx scrape-moysklad.ts +import { chromium } from "playwright"; +import { writeFile, mkdir } from "node:fs/promises"; +import { dirname } from "node:path"; + +const targets: { slug: string; label: string }[] = [ + { slug: "#/documents/payment-in", label: "Входящий платёж (безнал)" }, + { slug: "#/documents/payment-out", label: "Исходящий платёж (безнал)" }, + { slug: "#/documents/cashin", label: "Приходный ордер (наличные)" }, + { slug: "#/documents/cashout", label: "Расходный ордер (наличные)" }, + { slug: "#/documents/retaildrawercashin", label: "Внесение денег в кассу" }, + { slug: "#/documents/retaildrawercashout", label: "Выплата денег из кассы" }, + { slug: "#/documents/retailshift", label: "Розничная смена" }, + { slug: "#/documents/counterpartyadjustment", label: "Корректировка взаиморасчётов" }, + { slug: "#/reports/report-money", label: "Отчёт «Деньги»" }, + { slug: "#/reports/report-counterparty", label: "Показатели контрагентов" }, + { slug: "#/dictionaries/organization", label: "Юрлицо / счета юрлица" }, +]; + +const BASE = "https://dev.moysklad.ru/doc/api/remap/1.2/"; +const OUT = "/tmp/moysklad-dump"; + +async function dumpOne(page: import("playwright").Page, slug: string, label: string): Promise { + const url = BASE + slug; + await page.goto(url, { waitUntil: "domcontentloaded", timeout: 45_000 }); + // SPA рендер — ждём, пока в DOM появятся заголовки раздела. + await page.waitForTimeout(2500); + // Снимаем контент самой длинной «статьи» (у них разметка меняется от раздела). + const text = await page.evaluate(() => { + // Пытаемся найти самую большую семантическую зону — main/article или просто body. + const candidates = Array.from(document.querySelectorAll("main, article, section, .markdown-section, .content, body")); + let best: Element = document.body; + let bestLen = 0; + for (const el of candidates) { + const len = (el as HTMLElement).innerText?.length ?? 0; + if (len > bestLen) { + bestLen = len; + best = el; + } + } + return (best as HTMLElement).innerText; + }); + return `=== ${label} (${slug}) ===\nURL: ${url}\n\n${text}\n\n`; +} + +async function main() { + const browser = await chromium.launch({ headless: true }); + const ctx = await browser.newContext({ viewport: { width: 1400, height: 900 }, locale: "ru-RU" }); + const page = await ctx.newPage(); + await mkdir(OUT, { recursive: true }); + + for (const t of targets) { + const safe = t.slug.replace(/[^a-z0-9]+/gi, "_"); + console.log("→", t.slug); + try { + const dump = await dumpOne(page, t.slug, t.label); + const path = `${OUT}/${safe}.txt`; + await mkdir(dirname(path), { recursive: true }); + await writeFile(path, dump, "utf8"); + console.log(" wrote", path, dump.length, "chars"); + } catch (e: any) { + console.error(" FAILED", t.slug, e?.message); + } + } + + await browser.close(); +} + +main().catch((e) => { + console.error(e); + process.exit(1); +}); diff --git a/tests/food-market.IntegrationTests/MoneyModuleTests.cs b/tests/food-market.IntegrationTests/MoneyModuleTests.cs new file mode 100644 index 0000000..215e017 --- /dev/null +++ b/tests/food-market.IntegrationTests/MoneyModuleTests.cs @@ -0,0 +1,244 @@ +using System.Net.Http.Json; +using System.Text.Json; +using FluentAssertions; +using foodmarket.IntegrationTests.Support; +using Xunit; + +namespace foodmarket.IntegrationTests; + +/// Интеграционные тесты модуля «Деньги» на реальном Postgres: +/// мультитенантность, оплата приёмок (частичная/полная/переплата), долг +/// поставщика, инвариант остатка счёта, запрет продажи без открытой смены, +/// расчёт ожидаемой наличности при закрытии смены. +[Collection(ApiCollection.Name)] +public class MoneyModuleTests +{ + private readonly ApiFactory _factory; + public MoneyModuleTests(ApiFactory factory) => _factory = factory; + + private static string RandomBarcode() + => string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10))); + + // Cash=1, Safe=2, Bank=3, Acquiring=4 ; CashIn=3, CashOut=4 + private const int Cash = 1, Bank = 3; + + [Fact] + public async Task Tenant_B_cannot_see_tenant_A_money_accounts() + { + var a = new ApiActor(_factory.CreateClient()); + await a.SignupAndLoginAsync($"money-a-{Guid.NewGuid():N}"); + var refsA = await a.LoadRefsAsync(); + var accA = await a.CreateMoneyAccountAsync("Касса А", Cash, refsA.CurrencyId, refsA.StoreId, 10_000m); + + var b = new ApiActor(_factory.CreateClient()); + await b.SignupAndLoginAsync($"money-b-{Guid.NewGuid():N}"); + + var listB = await b.ListAsync("/api/money/accounts?pageSize=200"); + listB.Select(x => x.GetProperty("id").GetString()).Should().NotContain(accA, + "орг Б не должна видеть счета орг А (query-filter по OrganizationId)"); + + // Прямой GET чужого счёта — 404 (фильтр прячет как «нет такого»). + using var direct = await b.Http.GetAsync($"/api/money/accounts/{accA}"); + ((int)direct.StatusCode).Should().Be(404); + } + + [Fact] + public async Task Account_balance_invariant_opening_plus_posted_movements() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"bal-{Guid.NewGuid():N}"); + var refs = await api.LoadRefsAsync(); + var acc = await api.CreateMoneyAccountAsync("Сейф", 2, refs.CurrencyId, null, 100_000m); + + // CashIn 30 000 (проведён) + CashOut 12 000 (проведён) → остаток 118 000. + await PostTxAsync(api, kind: 3, accountId: acc, amount: 30_000m, refs.CurrencyId); + await PostTxAsync(api, kind: 4, accountId: acc, amount: 12_000m, refs.CurrencyId, needExpenseItem: true, api2: api); + + var balances = await api.GetJsonAsync("/api/money/reports/balances"); + var row = balances.GetProperty("accounts").EnumerateArray().First(x => x.GetProperty("accountId").GetString() == acc); + row.GetProperty("currentBalance").GetDecimal().Should().Be(118_000m); + } + + [Fact] + public async Task Partial_then_full_payment_moves_supply_status_unpaid_partial_paid() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"pay-{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}", 150m, RandomBarcode()); + var cash = await api.CreateMoneyAccountAsync("Касса", Cash, refs.CurrencyId, refs.StoreId, 0m); + + var (supplyId, total) = await api.CreatePostedSupplyAsync(refs, supplier, product, qty: 10m, unitPrice: 100m); + total.Should().Be(1000m); + + (await SupplyPaymentStatusAsync(api, supplyId)).Should().Be("unpaid"); + + // Частичная оплата 400 из кассы (CashOut, привязка к приёмке). + await PaySupplyAsync(api, cash, refs.CurrencyId, supplyId, 400m); + (await SupplyPaymentStatusAsync(api, supplyId)).Should().Be("partial"); + + // Добивка 600 → полностью оплачено. + await PaySupplyAsync(api, cash, refs.CurrencyId, supplyId, 600m); + var paid = await api.GetJsonAsync($"/api/purchases/supplies/{supplyId}"); + paid.GetProperty("paymentStatus").GetString().Should().Be("paid"); + paid.GetProperty("payedSum").GetDecimal().Should().Be(1000m); + } + + [Fact] + public async Task Overpaying_supply_is_rejected() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"over-{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}", 150m, RandomBarcode()); + var cash = await api.CreateMoneyAccountAsync("Касса", Cash, refs.CurrencyId, refs.StoreId, 0m); + var (supplyId, _) = await api.CreatePostedSupplyAsync(refs, supplier, product, 5m, 100m); // total 500 + + // Платёж на 800 с привязкой 800 → при проведении переплата → 409. + var created = await api.Http.PostAsJsonAsync("/api/money/transactions", new + { + date = DateTime.UtcNow, kind = 4, accountId = cash, currencyId = refs.CurrencyId, + amount = 800m, purpose = "overpay", + links = new[] { new { supplyId, linkedSum = 800m } }, + }); + created.EnsureSuccessStatusCode(); + var txId = (await created.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + using var post = await api.Http.PostAsJsonAsync($"/api/money/transactions/{txId}/post", new { }); + ((int)post.StatusCode).Should().Be(409, "переплата приёмки должна блокироваться"); + } + + [Fact] + public async Task Supplier_debt_report_reflects_supplies_minus_payments() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"debt-{Guid.NewGuid():N}"); + 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 (supplyId, _) = await api.CreatePostedSupplyAsync(refs, supplier, product, 10m, 100m); // 1000 + await PaySupplyAsync(api, cash, refs.CurrencyId, supplyId, 300m); + + var report = await api.GetJsonAsync("/api/money/reports/supplier-debts?withDebtOnly=false"); + var row = report.GetProperty("rows").EnumerateArray().First(x => x.GetProperty("supplierId").GetString() == supplier); + row.GetProperty("debt").GetDecimal().Should().Be(700m, "1000 приёмка − 300 оплачено"); + } + + [Fact] + public async Task Sale_without_open_shift_is_rejected_then_allowed_after_open() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"shift-{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); + + // Продажа на точке БЕЗ смены → 409 cash-shift-required. + var saleId = await CreateCashSaleDraftAsync(api, refs, retailPoint, product, qty: 1m, price: 500m); + using (var noShift = await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { })) + { + ((int)noShift.StatusCode).Should().Be(409); + (await noShift.Content.ReadAsStringAsync()).Should().Contain("cash-shift-required"); + } + + // Открываем смену и повторяем — проходит. + await OpenShiftAsync(api, retailPoint, cash, openingFloat: 0m); + using var ok = await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { }); + ok.IsSuccessStatusCode.Should().BeTrue($"после открытия смены продажа должна провестись: {(int)ok.StatusCode} {await ok.Content.ReadAsStringAsync()}"); + } + + [Fact] + public async Task Closing_shift_computes_expected_cash_and_difference() + { + var api = new ApiActor(_factory.CreateClient()); + await api.SignupAndLoginAsync($"close-{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, "Точка 2"); + var cash = await api.CreateMoneyAccountAsync("Касса точки", Cash, refs.CurrencyId, refs.StoreId, 0m); + + var shiftId = await OpenShiftAsync(api, retailPoint, cash, openingFloat: 10_000m); + + // Продажа 1 шт за 500 наличными. + var saleId = await CreateCashSaleDraftAsync(api, refs, retailPoint, product, 1m, 500m); + (await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { })).EnsureSuccessStatusCode(); + + // Внесение 1 000 + выплата (инкассация) 2 000. + (await api.Http.PostAsJsonAsync($"/api/money/shifts/{shiftId}/drawer", new { direction = 1, amount = 1_000m, reason = "Размен" })).EnsureSuccessStatusCode(); + (await api.Http.PostAsJsonAsync($"/api/money/shifts/{shiftId}/drawer", new { direction = 2, amount = 2_000m, reason = "Инкассация" })).EnsureSuccessStatusCode(); + + // expected = 10 000 + 500 − 0 + 1 000 − 2 000 = 9 500. Факт 9 500 → diff 0. + using var close = await api.Http.PostAsJsonAsync($"/api/money/shifts/{shiftId}/close", new { actualCash = 9_500m, notes = (string?)null }); + close.EnsureSuccessStatusCode(); + var body = await close.Content.ReadFromJsonAsync(); + body.GetProperty("expectedCash").GetDecimal().Should().Be(9_500m); + body.GetProperty("difference").GetDecimal().Should().Be(0m); + } + + // ── helpers ───────────────────────────────────────────────────────────── + private static async Task PostTxAsync(ApiActor api, int kind, string accountId, decimal amount, string currencyId, + bool needExpenseItem = false, ApiActor? api2 = null) + { + string? expenseItemId = null; + if (needExpenseItem) + { + var items = await api.ListAsync("/api/money/expense-items?pageSize=50"); + expenseItemId = items.First().GetProperty("id").GetString(); + } + var created = await api.Http.PostAsJsonAsync("/api/money/transactions", new + { + date = DateTime.UtcNow, kind, accountId, currencyId, amount, + expenseItemId, purpose = "test", + }); + created.EnsureSuccessStatusCode(); + var txId = (await created.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + (await api.Http.PostAsJsonAsync($"/api/money/transactions/{txId}/post", new { })).EnsureSuccessStatusCode(); + } + + private static async Task PaySupplyAsync(ApiActor api, string accountId, string currencyId, string supplyId, decimal amount) + { + var created = await api.Http.PostAsJsonAsync("/api/money/transactions", new + { + date = DateTime.UtcNow, kind = 4, accountId, currencyId, amount, purpose = "Оплата приёмки", + links = new[] { new { supplyId, linkedSum = amount } }, + }); + created.EnsureSuccessStatusCode(); + var txId = (await created.Content.ReadFromJsonAsync()).GetProperty("id").GetString(); + (await api.Http.PostAsJsonAsync($"/api/money/transactions/{txId}/post", new { })).EnsureSuccessStatusCode(); + } + + private static async Task SupplyPaymentStatusAsync(ApiActor api, string supplyId) + => (await api.GetJsonAsync($"/api/purchases/supplies/{supplyId}")).GetProperty("paymentStatus").GetString()!; + + private static async Task CreateCashSaleDraftAsync(ApiActor api, ApiActor.Refs refs, string retailPointId, + string productId, decimal qty, decimal price) + { + var draft = await api.Http.PostAsJsonAsync("/api/sales/retail", new + { + date = DateTime.UtcNow, storeId = refs.StoreId, retailPointId, + customerId = (string?)null, currencyId = refs.CurrencyId, + payment = 0, paidCash = qty * price, paidCard = 0m, notes = "shift-test", + lines = new[] { new { productId, quantity = qty, unitPrice = price, discount = 0m, vatPercent = 0m } }, + }); + draft.EnsureSuccessStatusCode(); + return (await draft.Content.ReadFromJsonAsync()).GetProperty("id").GetString()!; + } + + private static async Task OpenShiftAsync(ApiActor api, string retailPointId, string cashAccountId, decimal openingFloat) + { + var resp = await api.Http.PostAsJsonAsync("/api/money/shifts/open", new + { + retailPointId, cashAccountId, acquiringAccountId = (string?)null, openingFloat, + }); + var body = await resp.Content.ReadAsStringAsync(); + if (!resp.IsSuccessStatusCode) throw new InvalidOperationException($"OpenShift failed {(int)resp.StatusCode}: {body}"); + return JsonDocument.Parse(body).RootElement.GetProperty("id").GetString()!; + } +} diff --git a/tests/food-market.IntegrationTests/Support/ApiActor.cs b/tests/food-market.IntegrationTests/Support/ApiActor.cs index 47aa506..dc2b61b 100644 --- a/tests/food-market.IntegrationTests/Support/ApiActor.cs +++ b/tests/food-market.IntegrationTests/Support/ApiActor.cs @@ -139,6 +139,46 @@ public async Task CreateProductAsync(Refs refs, string name, decimal ret return JsonDocument.Parse(body).RootElement.GetProperty("id").GetString()!; } + // ── Money module helpers (Sprint 33) ─────────────────────────────────── + public async Task CreateRetailPointAsync(string storeId, string name) + { + var resp = await Http.PostAsJsonAsync("/api/catalog/retail-points", new { name, code = (string?)null, storeId }); + var body = await resp.Content.ReadAsStringAsync(); + if (!resp.IsSuccessStatusCode) throw new InvalidOperationException($"CreateRetailPoint failed {(int)resp.StatusCode}: {body}"); + return JsonDocument.Parse(body).RootElement.GetProperty("id").GetString()!; + } + + /// Создаёт счёт/кассу. kind: 1=Cash,2=Safe,3=Bank,4=Acquiring. + public async Task CreateMoneyAccountAsync(string name, int kind, string currencyId, + string? storeId = null, decimal openingBalance = 0m, string? accountNumber = null) + { + var resp = await Http.PostAsJsonAsync("/api/money/accounts", new + { + name, kind, bankName = (string?)null, accountNumber, + storeId, currencyId, openingBalance, + openingDate = DateTime.UtcNow, isActive = true, isDefault = false, + }); + var body = await resp.Content.ReadAsStringAsync(); + if (!resp.IsSuccessStatusCode) throw new InvalidOperationException($"CreateMoneyAccount failed {(int)resp.StatusCode}: {body}"); + return JsonDocument.Parse(body).RootElement.GetProperty("id").GetString()!; + } + + /// Создаёт и проводит приёмку, возвращает (supplyId, total). + public async Task<(string Id, decimal Total)> CreatePostedSupplyAsync(Refs refs, string supplierId, + string productId, decimal qty, decimal unitPrice) + { + var draft = await Http.PostAsJsonAsync("/api/purchases/supplies", new + { + date = DateTime.UtcNow, supplierId, storeId = refs.StoreId, currencyId = refs.CurrencyId, + notes = (string?)null, lines = new[] { new { productId, quantity = qty, unitPrice } }, + }); + draft.EnsureSuccessStatusCode(); + var supplyId = (await draft.Content.ReadFromJsonAsync()).GetProperty("id").GetString()!; + using var post = await Http.PostAsJsonAsync($"/api/purchases/supplies/{supplyId}/post", new { }); + post.EnsureSuccessStatusCode(); + return (supplyId, qty * unitPrice); + } + public async Task StockOfAsync(string storeId, string productId) { var list = await ListAsync($"/api/inventory/stock?productId={productId}&pageSize=200"); diff --git a/tests/food-market.UnitTests/MoneyMathTests.cs b/tests/food-market.UnitTests/MoneyMathTests.cs new file mode 100644 index 0000000..3ee4bb9 --- /dev/null +++ b/tests/food-market.UnitTests/MoneyMathTests.cs @@ -0,0 +1,120 @@ +using FluentAssertions; +using foodmarket.Application.Money; +using foodmarket.Domain.Organizations; +using Xunit; + +namespace foodmarket.UnitTests; + +/// Чистые денежные формулы модуля «Деньги» (): +/// статус оплаты приёмки, ожидаемая наличность смены, инвариант остатка счёта, +/// долг поставщика, защита от переплаты. Эти же формулы использует сервис, +/// контроллеры и отчёты — тест фиксирует их поведение без БД. +public class MoneyMathTests +{ + // ── Статус оплаты приёмки ──────────────────────────────────────────── + [Theory] + [InlineData(1000, 0, "unpaid")] + [InlineData(1000, 400, "partial")] + [InlineData(1000, 999, "partial")] + [InlineData(1000, 999.9999, "paid")] // в пределах ε считаем оплаченным + [InlineData(1000, 1000, "paid")] + [InlineData(1000, 1200, "paid")] // переплату отображаем как «оплачено» + [InlineData(0, 0, "paid")] // платить нечего + [InlineData(-5, 0, "paid")] + public void PaymentStatus_classifies(decimal total, decimal payed, string expected) + => MoneyMath.PaymentStatus(total, payed).Should().Be(expected); + + // ── Ожидаемая наличность смены ─────────────────────────────────────── + [Fact] + public void ExpectedCash_is_float_plus_cash_sales_minus_returns_plus_in_minus_out() + { + // разменник 20 000 + продажи нал 50 000 − возвраты нал 3 000 + // + внесения 1 000 − выплаты (инкассация) 40 000 = 28 000 + MoneyMath.ExpectedCash(20_000m, 50_000m, 3_000m, 1_000m, 40_000m) + .Should().Be(28_000m); + } + + [Fact] + public void ExpectedCash_empty_shift_equals_opening_float() + => MoneyMath.ExpectedCash(15_000m, 0, 0, 0, 0).Should().Be(15_000m); + + [Theory] + [InlineData(10_000, 10_000, 0)] // ровно сошлось + [InlineData(9_500, 10_000, -500)] // недостача + [InlineData(10_300, 10_000, 300)] // излишек + public void CashDifference_is_actual_minus_expected(decimal actual, decimal expected, decimal diff) + => MoneyMath.CashDifference(actual, expected).Should().Be(diff); + + // ── Инвариант остатка счёта ────────────────────────────────────────── + [Fact] + public void AccountBalance_is_opening_plus_in_minus_out() + => MoneyMath.AccountBalance(100_000m, 75_000m, 40_000m).Should().Be(135_000m); + + [Fact] + public void AccountBalance_can_go_negative() + => MoneyMath.AccountBalance(0m, 0m, 5_000m).Should().Be(-5_000m); + + // ── Долг поставщика ────────────────────────────────────────────────── + [Fact] + public void SupplierDebt_supplies_minus_paid() + { + // 3 приёмки на 300 000, оплачено 100 000, возвратов/корректировок нет. + MoneyMath.SupplierDebt(300_000m, 100_000m, 0, 0, 0, 0) + .Should().Be(200_000m); + } + + [Fact] + public void SupplierDebt_returns_reduce_debt() + { + // приёмки 300 000 (оплачено 300 000), возврат на 50 000 (деньги назад не получили) + // → поставщик должен нам 50 000 → долг −50 000. + MoneyMath.SupplierDebt(300_000m, 300_000m, 50_000m, 0m, 0, 0) + .Should().Be(-50_000m); + } + + [Fact] + public void SupplierDebt_starting_adjustment_adds_debt() + { + // стартовый долг «мы должны» 500 000 при переходе на систему. + MoneyMath.SupplierDebt(0, 0, 0, 0, 500_000m, 0) + .Should().Be(500_000m); + } + + [Fact] + public void SupplierDebt_full_formula() + { + // приёмки 1 000 000 (оплачено 600 000) − возвраты 100 000 (вернули деньги 100 000) + // + корр.(мы должны 50 000 − они должны 20 000) + // = 400 000 − 0 + 30 000 = 430 000 + MoneyMath.SupplierDebt(1_000_000m, 600_000m, 100_000m, 100_000m, 50_000m, 20_000m) + .Should().Be(430_000m); + } + + // ── Защита от переплаты ────────────────────────────────────────────── + [Theory] + [InlineData(0, 1000, 1000, true)] // первая полная оплата + [InlineData(400, 600, 1000, true)] // добивка ровно до total + [InlineData(400, 601, 1000, false)] // чуть больше остатка — переплата + [InlineData(1000, 0.00005, 1000, true)] // в пределах ε + public void CanApplyPayment_guards_overpay(decimal payed, decimal add, decimal total, bool ok) + => MoneyMath.CanApplyPayment(payed, add, total).Should().Be(ok); + + // ── Права роли ─────────────────────────────────────────────────────── + [Fact] + public void RolePermissions_All_includes_money_flags() + { + var p = RolePermissions.All(); + p.MoneyView.Should().BeTrue(); + p.MoneyEdit.Should().BeTrue(); + p.CashShiftOperate.Should().BeTrue(); + } + + [Fact] + public void RolePermissions_default_denies_money() + { + var p = new RolePermissions(); + p.MoneyView.Should().BeFalse(); + p.MoneyEdit.Should().BeFalse(); + p.CashShiftOperate.Should().BeFalse(); + } +}