fix(money): нормализовать date-only даты документов к UTC (Npgsql timestamptz)
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
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>
This commit is contained in:
parent
7e3841ad48
commit
174cb563bf
|
|
@ -2,6 +2,7 @@
|
|||
using foodmarket.Api.Infrastructure.Authorization;
|
||||
using foodmarket.Application.Common;
|
||||
using foodmarket.Application.Common.Tenancy;
|
||||
using foodmarket.Application.Money;
|
||||
using foodmarket.Domain.Money;
|
||||
using foodmarket.Infrastructure.Money;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
|
|
@ -71,7 +72,7 @@ public async Task<ActionResult<AdjustmentRow>> Create([FromBody] AdjustmentInput
|
|||
{
|
||||
OrganizationId = orgId,
|
||||
Number = await MoneyNumbering.NextAdjustmentNumberAsync(_db, orgId, input.Date.Year, ct),
|
||||
Date = input.Date,
|
||||
Date = MoneyMath.AsUtc(input.Date),
|
||||
CounterpartyId = input.CounterpartyId,
|
||||
Direction = input.Direction,
|
||||
Amount = input.Amount,
|
||||
|
|
@ -99,7 +100,7 @@ public async Task<ActionResult<AdjustmentRow>> Update(Guid id, [FromBody] Adjust
|
|||
return Conflict(new { code = "already-posted", message = "Проведённую корректировку редактировать нельзя." });
|
||||
if (input.Xmin is not null && input.Xmin.Value != 0)
|
||||
_db.Entry(a).Property("xmin").OriginalValue = input.Xmin.Value;
|
||||
a.Date = input.Date;
|
||||
a.Date = MoneyMath.AsUtc(input.Date);
|
||||
a.CounterpartyId = input.CounterpartyId;
|
||||
a.Direction = input.Direction;
|
||||
a.Amount = input.Amount;
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@
|
|||
using foodmarket.Api.Infrastructure.Authorization;
|
||||
using foodmarket.Application.Common;
|
||||
using foodmarket.Application.Common.Tenancy;
|
||||
using foodmarket.Application.Money;
|
||||
using foodmarket.Domain.Money;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
|
|
@ -149,7 +150,7 @@ public async Task<ActionResult<MoneyAccountRow>> Create([FromBody] MoneyAccountI
|
|||
StoreId = input.StoreId,
|
||||
CurrencyId = input.CurrencyId,
|
||||
OpeningBalance = input.OpeningBalance,
|
||||
OpeningDate = input.OpeningDate,
|
||||
OpeningDate = MoneyMath.AsUtc(input.OpeningDate),
|
||||
IsActive = input.IsActive,
|
||||
IsDefault = false,
|
||||
};
|
||||
|
|
@ -183,7 +184,7 @@ public async Task<ActionResult<MoneyAccountRow>> Update(Guid id, [FromBody] Mone
|
|||
|
||||
// OpeningBalance/OpeningDate после создания менять нельзя — ломает
|
||||
// инвариант «CurrentBalance = Opening + Σ Posted».
|
||||
if (a.OpeningBalance != input.OpeningBalance || a.OpeningDate != input.OpeningDate)
|
||||
if (a.OpeningBalance != input.OpeningBalance || a.OpeningDate != MoneyMath.AsUtc(input.OpeningDate))
|
||||
return Problem("Начальный остаток и дата после создания счёта не редактируются.",
|
||||
statusCode: 409, type: "https://food-market.kz/errors/opening-balance-locked");
|
||||
|
||||
|
|
|
|||
|
|
@ -163,7 +163,7 @@ public async Task<ActionResult<MoneyTxRow>> Create([FromBody] MoneyTxInput input
|
|||
{
|
||||
OrganizationId = orgId,
|
||||
Number = await MoneyNumbering.NextMoneyTxNumberAsync(_db, orgId, input.Date.Year, ct),
|
||||
Date = input.Date,
|
||||
Date = MoneyMath.AsUtc(input.Date),
|
||||
Kind = input.Kind,
|
||||
Status = MoneyTransactionStatus.Draft,
|
||||
AccountId = input.AccountId,
|
||||
|
|
@ -204,7 +204,7 @@ public async Task<ActionResult<MoneyTxRow>> Update(Guid id, [FromBody] MoneyTxIn
|
|||
if (input.Xmin is not null && input.Xmin.Value != 0)
|
||||
_db.Entry(mt).Property("xmin").OriginalValue = input.Xmin.Value;
|
||||
|
||||
mt.Date = input.Date;
|
||||
mt.Date = MoneyMath.AsUtc(input.Date);
|
||||
mt.AccountId = input.AccountId;
|
||||
mt.CounterpartyId = input.CounterpartyId;
|
||||
mt.ExpenseItemId = input.ExpenseItemId;
|
||||
|
|
@ -292,7 +292,7 @@ public async Task<ActionResult<object>> Transfer([FromBody] TransferInput input,
|
|||
{
|
||||
OrganizationId = orgId,
|
||||
Number = await MoneyNumbering.NextMoneyTxNumberAsync(_db, orgId, input.Date.Year, ct),
|
||||
Date = input.Date,
|
||||
Date = MoneyMath.AsUtc(input.Date),
|
||||
Kind = MoneyTransactionKind.TransferOut,
|
||||
Status = MoneyTransactionStatus.Draft,
|
||||
AccountId = from.Id,
|
||||
|
|
@ -304,7 +304,7 @@ public async Task<ActionResult<object>> Transfer([FromBody] TransferInput input,
|
|||
{
|
||||
OrganizationId = orgId,
|
||||
Number = await MoneyNumbering.NextMoneyTxNumberAsync(_db, orgId, input.Date.Year, ct),
|
||||
Date = input.Date,
|
||||
Date = MoneyMath.AsUtc(input.Date),
|
||||
Kind = MoneyTransactionKind.TransferIn,
|
||||
Status = MoneyTransactionStatus.Draft,
|
||||
AccountId = to.Id,
|
||||
|
|
|
|||
|
|
@ -51,4 +51,17 @@ public static decimal AccountBalance(decimal openingBalance, decimal inSum, deci
|
|||
/// переплатив (payed + add ≤ total + ε).</summary>
|
||||
public static bool CanApplyPayment(decimal currentPayed, decimal addSum, decimal total)
|
||||
=> currentPayed + addSum <= total + Eps;
|
||||
|
||||
/// <summary>Нормализует дату документа к UTC перед записью в
|
||||
/// <c>timestamp with time zone</c>. Date-only из UI (<c>YYYY-MM-DD</c>)
|
||||
/// десериализуется с <see cref="DateTimeKind.Unspecified"/> — Npgsql такой
|
||||
/// в timestamptz не пишет (кидает 500), поэтому помечаем как UTC (та же
|
||||
/// календарная дата, полночь UTC). Полный ISO с <c>Z</c> (Kind=Utc) —
|
||||
/// as-is; локальный — переводим в UTC.</summary>
|
||||
public static DateTime AsUtc(DateTime d) => d.Kind switch
|
||||
{
|
||||
DateTimeKind.Utc => d,
|
||||
DateTimeKind.Local => d.ToUniversalTime(),
|
||||
_ => DateTime.SpecifyKind(d, DateTimeKind.Utc),
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -42,6 +42,26 @@ public async Task Tenant_B_cannot_see_tenant_A_money_accounts()
|
|||
((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()
|
||||
{
|
||||
|
|
|
|||
Loading…
Reference in a new issue