food-market/tests/food-market.IntegrationTests/MoneyModuleTests.cs
nns 174cb563bf
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
fix(money): нормализовать date-only даты документов к UTC (Npgsql timestamptz)
UI (DateField) шлёт openingDate/date как "YYYY-MM-DD" → DateTime Kind=Unspecified
→ Npgsql не пишет в timestamp with time zone → 500 при создании счёта/платежа/
корректировки. MoneyMath.AsUtc нормализует (Unspecified→UTC as-is дата,
Local→ToUniversalTime, Utc→без изменений); применено в MoneyAccounts/
MoneyTransactions/CounterpartyAdjustments на всех сайтах записи даты.

Интеграционные тесты раньше слали DateTime.UtcNow (Kind=Utc) и баг не ловили —
добавлен Account_create_accepts_date_only_opening_date_from_ui (строго
"2026-10-07"). Money integration 8/8 ✓, unit 25/25 ✓.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-07 22:29:38 +05:00

265 lines
15 KiB
C#
Raw 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()}");
(await resp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("currentBalance").GetDecimal().Should().Be(50_000m);
}
[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()!;
}
}