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
П.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>
329 lines
20 KiB
C#
329 lines
20 KiB
C#
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()!;
|
||
}
|
||
}
|