food-market/tests/food-market.IntegrationTests/MoneyModuleTests.cs
nns ff4391d19f
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
feat(money): автооткрытие смены первой продажей + Z-отчёт по товарам + 24ч
П.1: продажа на точке без открытой смены — смена автооткрывается (подбор Cash-счёта
точки/орг., размен 0, можно внести позже), как МойСклад; ручное открытие остаётся.
Длительность смены и отметка overLimit24h в /current и Z-отчёте (мягкое правило:
предупреждение + отметка, не жёсткий лимит).
П.2: Z-отчёт /shifts/{id}/report теперь с разбивкой byProduct (продано кол-во/сумма,
возвращено кол-во/сумма).

Тест переписан: продажа без смены → автооткрытие + проводится; /current open=true;
Z-отчёт содержит byProduct с верными кол-вом/суммой.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-09 05:58:31 +05:00

329 lines
20 KiB
C#
Raw Permalink Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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_create_accepts_date_only_opening_date_from_ui()
{
// UI (DateField) шлёт openingDate как "YYYY-MM-DD" (Kind=Unspecified).
// Регресс: такой DateTime Npgsql не писал в timestamptz → 500. Проверяем
// что контроллер нормализует к UTC (MoneyMath.AsUtc) и создаёт счёт.
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"dateonly-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
using var resp = await api.Http.PostAsJsonAsync("/api/money/accounts", new
{
name = "Касса (date-only)", kind = Cash, bankName = (string?)null, accountNumber = (string?)null,
storeId = refs.StoreId, currencyId = refs.CurrencyId,
openingBalance = 50_000m, openingDate = "2026-10-07", // ← строго date-only
isActive = true, isDefault = false,
});
resp.IsSuccessStatusCode.Should().BeTrue($"date-only openingDate должен приниматься: {(int)resp.StatusCode} {await resp.Content.ReadAsStringAsync()}");
var created = await resp.Content.ReadFromJsonAsync<JsonElement>();
created.GetProperty("currentBalance").GetDecimal().Should().Be(50_000m);
// Регресс: список счетов С ДАННЫМИ не должен падать (xmin проецировался
// в памяти на AsNoTracking → 500; с пустым списком не всплывало).
using var list = await api.Http.GetAsync("/api/money/accounts?pageSize=50");
list.IsSuccessStatusCode.Should().BeTrue($"список счетов с данными должен отдаваться: {(int)list.StatusCode}");
var listed = await api.ListAsync("/api/money/accounts?pageSize=50");
listed.Select(x => x.GetProperty("id").GetString())
.Should().Contain(created.GetProperty("id").GetString());
}
[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, 1_000m);
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, 900m);
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, 1_000m);
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_shift_auto_opens_shift_and_report_has_breakdown()
{
// Паритет п.1: смена автооткрывается первой продажей (как МойСклад), если
// есть денежный счёт. Z-отчёт (п.2) содержит разбивку по товарам.
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);
// Продажа на точке БЕЗ открытой смены → смена автооткрывается, чек проводится.
var saleId = await CreateCashSaleDraftAsync(api, refs, retailPoint, product, qty: 2m, price: 500m);
using (var auto = await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { }))
auto.IsSuccessStatusCode.Should().BeTrue($"смена должна автооткрыться: {(int)auto.StatusCode} {await auto.Content.ReadAsStringAsync()}");
// На точке теперь есть открытая смена.
var current = await api.GetJsonAsync($"/api/money/shifts/current?retailPointId={retailPoint}");
current.GetProperty("open").GetBoolean().Should().BeTrue();
current.GetProperty("overLimit24h").GetBoolean().Should().BeFalse();
var shiftId = current.GetProperty("shift").GetProperty("id").GetString();
// Z-отчёт содержит разбивку по товарам (п.2).
var report = await api.GetJsonAsync($"/api/money/shifts/{shiftId}/report");
report.TryGetProperty("byProduct", out var bp).Should().BeTrue();
var rows = bp.EnumerateArray().ToList();
rows.Should().ContainSingle(r => r.GetProperty("productId").GetString() == product);
var row = rows.First(r => r.GetProperty("productId").GetString() == product);
row.GetProperty("soldQty").GetDecimal().Should().Be(2m);
row.GetProperty("soldAmount").GetDecimal().Should().Be(1000m);
report.GetProperty("overLimit24h").GetBoolean().Should().BeFalse();
}
[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);
}
[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)
{
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()!;
}
}