test(money): unit (MoneyMath 25) + integration (7 на Postgres) + MoneyMath refactor
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
- 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 <noreply@anthropic.com>
This commit is contained in:
parent
830cd73ee0
commit
deef65d819
|
|
@ -1,5 +1,6 @@
|
||||||
using foodmarket.Api.Infrastructure.Authorization;
|
using foodmarket.Api.Infrastructure.Authorization;
|
||||||
using foodmarket.Application.Common.Tenancy;
|
using foodmarket.Application.Common.Tenancy;
|
||||||
|
using foodmarket.Application.Money;
|
||||||
using foodmarket.Domain.Money;
|
using foodmarket.Domain.Money;
|
||||||
using foodmarket.Domain.Purchases;
|
using foodmarket.Domain.Purchases;
|
||||||
using foodmarket.Infrastructure.Persistence;
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
|
@ -57,7 +58,7 @@ public async Task<ActionResult<BalancesReport>> Balances(CancellationToken ct)
|
||||||
|| m.Kind == MoneyTransactionKind.TransferOut)).Sum(m => m.Sum);
|
|| m.Kind == MoneyTransactionKind.TransferOut)).Sum(m => m.Sum);
|
||||||
return new AccountBalanceRow(a.Id, a.Name, a.Kind,
|
return new AccountBalanceRow(a.Id, a.Name, a.Kind,
|
||||||
currencies.TryGetValue(a.CurrencyId, out var code) ? code : "",
|
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();
|
}).ToList();
|
||||||
|
|
||||||
return new BalancesReport(rows, rows.Sum(r => r.CurrentBalance));
|
return new BalancesReport(rows, rows.Sum(r => r.CurrentBalance));
|
||||||
|
|
@ -169,10 +170,11 @@ public record SupplierDebtsReport(IReadOnlyList<SupplierDebtRow> Rows, decimal T
|
||||||
var r = returns.FirstOrDefault(x => x.SupplierId == sup.Id);
|
var r = returns.FirstOrDefault(x => x.SupplierId == sup.Id);
|
||||||
var a = adjustments.FirstOrDefault(x => x.CounterpartyId == sup.Id);
|
var a = adjustments.FirstOrDefault(x => x.CounterpartyId == sup.Id);
|
||||||
// Долг = (приёмки − оплаченные приёмки) − (возвраты − полученные обратно деньги) + (корр: мы должны − они должны).
|
// Долг = (приёмки − оплаченные приёмки) − (возвраты − полученные обратно деньги) + (корр: мы должны − они должны).
|
||||||
var debt = (s?.Total ?? 0) - (s?.Paid ?? 0)
|
var debt = MoneyMath.SupplierDebt(
|
||||||
- ((r?.Total ?? 0) - (r?.Paid ?? 0))
|
s?.Total ?? 0, s?.Paid ?? 0,
|
||||||
+ (a?.WeOwe ?? 0) - (a?.TheyOwe ?? 0);
|
r?.Total ?? 0, r?.Paid ?? 0,
|
||||||
if (withDebtOnly && Math.Abs(debt) < 0.0001m) continue;
|
a?.WeOwe ?? 0, a?.TheyOwe ?? 0);
|
||||||
|
if (withDebtOnly && Math.Abs(debt) < MoneyMath.Eps) continue;
|
||||||
rows.Add(new SupplierDebtRow(
|
rows.Add(new SupplierDebtRow(
|
||||||
sup.Id, sup.Name, sup.Bin, sup.Phone,
|
sup.Id, sup.Name, sup.Bin, sup.Phone,
|
||||||
s?.Total ?? 0, r?.Total ?? 0,
|
s?.Total ?? 0, r?.Total ?? 0,
|
||||||
|
|
|
||||||
|
|
@ -43,11 +43,7 @@ public record SupplyListRow(
|
||||||
/// "unpaid" (0), "partial" (0 < payed < total), "paid" (payed ≥ total).
|
/// "unpaid" (0), "partial" (0 < payed < total), "paid" (payed ≥ total).
|
||||||
/// Фронт показывает цветной бейдж. Расчёт здесь — простейший порог.</summary>
|
/// Фронт показывает цветной бейдж. Расчёт здесь — простейший порог.</summary>
|
||||||
private static string ComputePaymentStatus(decimal total, decimal payed)
|
private static string ComputePaymentStatus(decimal total, decimal payed)
|
||||||
{
|
=> foodmarket.Application.Money.MoneyMath.PaymentStatus(total, payed);
|
||||||
if (total <= 0) return "paid";
|
|
||||||
if (payed <= 0) return "unpaid";
|
|
||||||
return payed + 0.0001m >= total ? "paid" : "partial";
|
|
||||||
}
|
|
||||||
|
|
||||||
public record SupplyLineDto(
|
public record SupplyLineDto(
|
||||||
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle,
|
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle,
|
||||||
|
|
|
||||||
54
src/food-market.application/Money/MoneyMath.cs
Normal file
54
src/food-market.application/Money/MoneyMath.cs
Normal file
|
|
@ -0,0 +1,54 @@
|
||||||
|
namespace foodmarket.Application.Money;
|
||||||
|
|
||||||
|
/// <summary>Чистые денежные формулы модуля «Деньги» — вынесены отдельно, чтобы
|
||||||
|
/// (а) посчитать одинаково в сервисе, контроллерах и отчётах и (б) покрыть
|
||||||
|
/// unit-тестами без БД. Все суммы — decimal, порог сравнения 0.0001 (мы храним
|
||||||
|
/// NUMERIC(18,4)).</summary>
|
||||||
|
public static class MoneyMath
|
||||||
|
{
|
||||||
|
/// <summary>Порог «копеечной» погрешности для сравнений сумм.</summary>
|
||||||
|
public const decimal Eps = 0.0001m;
|
||||||
|
|
||||||
|
/// <summary>Агрегатный статус оплаты документа (приёмки/возврата):
|
||||||
|
/// <c>"unpaid"</c> | <c>"partial"</c> | <c>"paid"</c>. Нулевой/отрицательный
|
||||||
|
/// total считаем оплаченным (нечего платить).</summary>
|
||||||
|
public static string PaymentStatus(decimal total, decimal payed)
|
||||||
|
{
|
||||||
|
if (total <= 0) return "paid";
|
||||||
|
if (payed <= 0) return "unpaid";
|
||||||
|
return payed + Eps >= total ? "paid" : "partial";
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Ожидаемая наличность в кассе при закрытии смены:
|
||||||
|
/// разменник + продажи наличными − возвраты наличными + внесения − выплаты.</summary>
|
||||||
|
public static decimal ExpectedCash(decimal openingFloat, decimal salesCash,
|
||||||
|
decimal returnsCash, decimal drawerIn, decimal drawerOut)
|
||||||
|
=> openingFloat + salesCash - returnsCash + drawerIn - drawerOut;
|
||||||
|
|
||||||
|
/// <summary>Разница при закрытии смены: факт − ожидание. <0 — недостача,
|
||||||
|
/// >0 — излишек.</summary>
|
||||||
|
public static decimal CashDifference(decimal actualCash, decimal expectedCash)
|
||||||
|
=> actualCash - expectedCash;
|
||||||
|
|
||||||
|
/// <summary>Текущий остаток счёта = начальный + Σ приходов − Σ расходов
|
||||||
|
/// (только проведённые движения; переводы учитываются как обычные in/out).</summary>
|
||||||
|
public static decimal AccountBalance(decimal openingBalance, decimal inSum, decimal outSum)
|
||||||
|
=> openingBalance + inSum - outSum;
|
||||||
|
|
||||||
|
/// <summary>Долг перед поставщиком:
|
||||||
|
/// (приёмки − оплаченные приёмки) − (возвраты − возвращённые деньги)
|
||||||
|
/// + (корректировки: «мы должны» − «они должны»).
|
||||||
|
/// Плюс — мы должны поставщику; минус — переплата/он должен нам.</summary>
|
||||||
|
public static decimal SupplierDebt(
|
||||||
|
decimal suppliesTotal, decimal suppliesPaid,
|
||||||
|
decimal returnsTotal, decimal returnsPaid,
|
||||||
|
decimal adjustmentsWeOwe, decimal adjustmentsTheyOwe)
|
||||||
|
=> (suppliesTotal - suppliesPaid)
|
||||||
|
- (returnsTotal - returnsPaid)
|
||||||
|
+ (adjustmentsWeOwe - adjustmentsTheyOwe);
|
||||||
|
|
||||||
|
/// <summary>Можно ли довнести <paramref name="addSum"/> к документу, не
|
||||||
|
/// переплатив (payed + add ≤ total + ε).</summary>
|
||||||
|
public static bool CanApplyPayment(decimal currentPayed, decimal addSum, decimal total)
|
||||||
|
=> currentPayed + addSum <= total + Eps;
|
||||||
|
}
|
||||||
|
|
@ -71,7 +71,7 @@ public async Task<MoneyServiceResult> PostAsync(Guid transactionId, Guid current
|
||||||
{
|
{
|
||||||
var supply = await _db.Supplies.FirstAsync(s => s.Id == sId, ct);
|
var supply = await _db.Supplies.FirstAsync(s => s.Id == sId, ct);
|
||||||
var newPayed = supply.PayedSum + link.LinkedSum;
|
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",
|
return MoneyServiceResult.Fail(MoneyServiceStatus.OverpaidDocument, "supply-overpaid",
|
||||||
$"Приёмка {supply.Number} была бы переплачена: {newPayed:0.##} > {supply.Total:0.##}.");
|
$"Приёмка {supply.Number} была бы переплачена: {newPayed:0.##} > {supply.Total:0.##}.");
|
||||||
supply.PayedSum = newPayed;
|
supply.PayedSum = newPayed;
|
||||||
|
|
@ -81,7 +81,7 @@ public async Task<MoneyServiceResult> PostAsync(Guid transactionId, Guid current
|
||||||
{
|
{
|
||||||
var sr = await _db.SupplierReturns.FirstAsync(s => s.Id == srId, ct);
|
var sr = await _db.SupplierReturns.FirstAsync(s => s.Id == srId, ct);
|
||||||
var newPayed = sr.PayedSum + link.LinkedSum;
|
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",
|
return MoneyServiceResult.Fail(MoneyServiceStatus.OverpaidDocument, "supplier-return-overpaid",
|
||||||
$"Возврат поставщику {sr.Number} был бы переплачен: {newPayed:0.##} > {sr.Total:0.##}.");
|
$"Возврат поставщику {sr.Number} был бы переплачен: {newPayed:0.##} > {sr.Total:0.##}.");
|
||||||
sr.PayedSum = newPayed;
|
sr.PayedSum = newPayed;
|
||||||
|
|
@ -252,7 +252,7 @@ public async Task<MoneyServiceResult> UnpostAsync(Guid transactionId, Guid curre
|
||||||
var returnsCard = sales.Where(s => s.IsReturn).Sum(s => s.PaidCard);
|
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 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 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.SalesCash = salesCash;
|
||||||
shift.SalesCard = salesCard;
|
shift.SalesCard = salesCard;
|
||||||
|
|
@ -262,7 +262,7 @@ public async Task<MoneyServiceResult> UnpostAsync(Guid transactionId, Guid curre
|
||||||
shift.DrawerOut = drawerOut;
|
shift.DrawerOut = drawerOut;
|
||||||
shift.ExpectedCash = expected;
|
shift.ExpectedCash = expected;
|
||||||
shift.ActualCash = actualCash;
|
shift.ActualCash = actualCash;
|
||||||
shift.CashDifference = actualCash - expected;
|
shift.CashDifference = MoneyMath.CashDifference(actualCash, expected);
|
||||||
shift.Status = CashShiftStatus.Closed;
|
shift.Status = CashShiftStatus.Closed;
|
||||||
shift.ClosedAt = DateTime.UtcNow;
|
shift.ClosedAt = DateTime.UtcNow;
|
||||||
shift.ClosedByUserId = closedByUserId;
|
shift.ClosedByUserId = closedByUserId;
|
||||||
|
|
|
||||||
74
tests/e2e/scrape-moysklad.ts
Normal file
74
tests/e2e/scrape-moysklad.ts
Normal file
|
|
@ -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<string> {
|
||||||
|
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);
|
||||||
|
});
|
||||||
244
tests/food-market.IntegrationTests/MoneyModuleTests.cs
Normal file
244
tests/food-market.IntegrationTests/MoneyModuleTests.cs
Normal file
|
|
@ -0,0 +1,244 @@
|
||||||
|
using System.Net.Http.Json;
|
||||||
|
using System.Text.Json;
|
||||||
|
using FluentAssertions;
|
||||||
|
using foodmarket.IntegrationTests.Support;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace foodmarket.IntegrationTests;
|
||||||
|
|
||||||
|
/// <summary>Интеграционные тесты модуля «Деньги» на реальном Postgres:
|
||||||
|
/// мультитенантность, оплата приёмок (частичная/полная/переплата), долг
|
||||||
|
/// поставщика, инвариант остатка счёта, запрет продажи без открытой смены,
|
||||||
|
/// расчёт ожидаемой наличности при закрытии смены.</summary>
|
||||||
|
[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<JsonElement>()).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<JsonElement>();
|
||||||
|
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<JsonElement>()).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<JsonElement>()).GetProperty("id").GetString();
|
||||||
|
(await api.Http.PostAsJsonAsync($"/api/money/transactions/{txId}/post", new { })).EnsureSuccessStatusCode();
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> SupplyPaymentStatusAsync(ApiActor api, string supplyId)
|
||||||
|
=> (await api.GetJsonAsync($"/api/purchases/supplies/{supplyId}")).GetProperty("paymentStatus").GetString()!;
|
||||||
|
|
||||||
|
private static async Task<string> 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<JsonElement>()).GetProperty("id").GetString()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static async Task<string> 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()!;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -139,6 +139,46 @@ public async Task<string> CreateProductAsync(Refs refs, string name, decimal ret
|
||||||
return JsonDocument.Parse(body).RootElement.GetProperty("id").GetString()!;
|
return JsonDocument.Parse(body).RootElement.GetProperty("id").GetString()!;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ── Money module helpers (Sprint 33) ───────────────────────────────────
|
||||||
|
public async Task<string> 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()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Создаёт счёт/кассу. kind: 1=Cash,2=Safe,3=Bank,4=Acquiring.</summary>
|
||||||
|
public async Task<string> 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()!;
|
||||||
|
}
|
||||||
|
|
||||||
|
/// <summary>Создаёт и проводит приёмку, возвращает (supplyId, total).</summary>
|
||||||
|
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<JsonElement>()).GetProperty("id").GetString()!;
|
||||||
|
using var post = await Http.PostAsJsonAsync($"/api/purchases/supplies/{supplyId}/post", new { });
|
||||||
|
post.EnsureSuccessStatusCode();
|
||||||
|
return (supplyId, qty * unitPrice);
|
||||||
|
}
|
||||||
|
|
||||||
public async Task<decimal> StockOfAsync(string storeId, string productId)
|
public async Task<decimal> StockOfAsync(string storeId, string productId)
|
||||||
{
|
{
|
||||||
var list = await ListAsync($"/api/inventory/stock?productId={productId}&pageSize=200");
|
var list = await ListAsync($"/api/inventory/stock?productId={productId}&pageSize=200");
|
||||||
|
|
|
||||||
120
tests/food-market.UnitTests/MoneyMathTests.cs
Normal file
120
tests/food-market.UnitTests/MoneyMathTests.cs
Normal file
|
|
@ -0,0 +1,120 @@
|
||||||
|
using FluentAssertions;
|
||||||
|
using foodmarket.Application.Money;
|
||||||
|
using foodmarket.Domain.Organizations;
|
||||||
|
using Xunit;
|
||||||
|
|
||||||
|
namespace foodmarket.UnitTests;
|
||||||
|
|
||||||
|
/// <summary>Чистые денежные формулы модуля «Деньги» (<see cref="MoneyMath"/>):
|
||||||
|
/// статус оплаты приёмки, ожидаемая наличность смены, инвариант остатка счёта,
|
||||||
|
/// долг поставщика, защита от переплаты. Эти же формулы использует сервис,
|
||||||
|
/// контроллеры и отчёты — тест фиксирует их поведение без БД.</summary>
|
||||||
|
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();
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue