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.Api.Infrastructure.Authorization;
|
||||||
using foodmarket.Application.Common;
|
using foodmarket.Application.Common;
|
||||||
using foodmarket.Application.Common.Tenancy;
|
using foodmarket.Application.Common.Tenancy;
|
||||||
|
using foodmarket.Application.Money;
|
||||||
using foodmarket.Domain.Money;
|
using foodmarket.Domain.Money;
|
||||||
using foodmarket.Infrastructure.Money;
|
using foodmarket.Infrastructure.Money;
|
||||||
using foodmarket.Infrastructure.Persistence;
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
|
@ -71,7 +72,7 @@ public async Task<ActionResult<AdjustmentRow>> Create([FromBody] AdjustmentInput
|
||||||
{
|
{
|
||||||
OrganizationId = orgId,
|
OrganizationId = orgId,
|
||||||
Number = await MoneyNumbering.NextAdjustmentNumberAsync(_db, orgId, input.Date.Year, ct),
|
Number = await MoneyNumbering.NextAdjustmentNumberAsync(_db, orgId, input.Date.Year, ct),
|
||||||
Date = input.Date,
|
Date = MoneyMath.AsUtc(input.Date),
|
||||||
CounterpartyId = input.CounterpartyId,
|
CounterpartyId = input.CounterpartyId,
|
||||||
Direction = input.Direction,
|
Direction = input.Direction,
|
||||||
Amount = input.Amount,
|
Amount = input.Amount,
|
||||||
|
|
@ -99,7 +100,7 @@ public async Task<ActionResult<AdjustmentRow>> Update(Guid id, [FromBody] Adjust
|
||||||
return Conflict(new { code = "already-posted", message = "Проведённую корректировку редактировать нельзя." });
|
return Conflict(new { code = "already-posted", message = "Проведённую корректировку редактировать нельзя." });
|
||||||
if (input.Xmin is not null && input.Xmin.Value != 0)
|
if (input.Xmin is not null && input.Xmin.Value != 0)
|
||||||
_db.Entry(a).Property("xmin").OriginalValue = input.Xmin.Value;
|
_db.Entry(a).Property("xmin").OriginalValue = input.Xmin.Value;
|
||||||
a.Date = input.Date;
|
a.Date = MoneyMath.AsUtc(input.Date);
|
||||||
a.CounterpartyId = input.CounterpartyId;
|
a.CounterpartyId = input.CounterpartyId;
|
||||||
a.Direction = input.Direction;
|
a.Direction = input.Direction;
|
||||||
a.Amount = input.Amount;
|
a.Amount = input.Amount;
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@
|
||||||
using foodmarket.Api.Infrastructure.Authorization;
|
using foodmarket.Api.Infrastructure.Authorization;
|
||||||
using foodmarket.Application.Common;
|
using foodmarket.Application.Common;
|
||||||
using foodmarket.Application.Common.Tenancy;
|
using foodmarket.Application.Common.Tenancy;
|
||||||
|
using foodmarket.Application.Money;
|
||||||
using foodmarket.Domain.Money;
|
using foodmarket.Domain.Money;
|
||||||
using foodmarket.Infrastructure.Persistence;
|
using foodmarket.Infrastructure.Persistence;
|
||||||
using Microsoft.AspNetCore.Authorization;
|
using Microsoft.AspNetCore.Authorization;
|
||||||
|
|
@ -149,7 +150,7 @@ public async Task<ActionResult<MoneyAccountRow>> Create([FromBody] MoneyAccountI
|
||||||
StoreId = input.StoreId,
|
StoreId = input.StoreId,
|
||||||
CurrencyId = input.CurrencyId,
|
CurrencyId = input.CurrencyId,
|
||||||
OpeningBalance = input.OpeningBalance,
|
OpeningBalance = input.OpeningBalance,
|
||||||
OpeningDate = input.OpeningDate,
|
OpeningDate = MoneyMath.AsUtc(input.OpeningDate),
|
||||||
IsActive = input.IsActive,
|
IsActive = input.IsActive,
|
||||||
IsDefault = false,
|
IsDefault = false,
|
||||||
};
|
};
|
||||||
|
|
@ -183,7 +184,7 @@ public async Task<ActionResult<MoneyAccountRow>> Update(Guid id, [FromBody] Mone
|
||||||
|
|
||||||
// OpeningBalance/OpeningDate после создания менять нельзя — ломает
|
// OpeningBalance/OpeningDate после создания менять нельзя — ломает
|
||||||
// инвариант «CurrentBalance = Opening + Σ Posted».
|
// инвариант «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("Начальный остаток и дата после создания счёта не редактируются.",
|
return Problem("Начальный остаток и дата после создания счёта не редактируются.",
|
||||||
statusCode: 409, type: "https://food-market.kz/errors/opening-balance-locked");
|
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,
|
OrganizationId = orgId,
|
||||||
Number = await MoneyNumbering.NextMoneyTxNumberAsync(_db, orgId, input.Date.Year, ct),
|
Number = await MoneyNumbering.NextMoneyTxNumberAsync(_db, orgId, input.Date.Year, ct),
|
||||||
Date = input.Date,
|
Date = MoneyMath.AsUtc(input.Date),
|
||||||
Kind = input.Kind,
|
Kind = input.Kind,
|
||||||
Status = MoneyTransactionStatus.Draft,
|
Status = MoneyTransactionStatus.Draft,
|
||||||
AccountId = input.AccountId,
|
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)
|
if (input.Xmin is not null && input.Xmin.Value != 0)
|
||||||
_db.Entry(mt).Property("xmin").OriginalValue = input.Xmin.Value;
|
_db.Entry(mt).Property("xmin").OriginalValue = input.Xmin.Value;
|
||||||
|
|
||||||
mt.Date = input.Date;
|
mt.Date = MoneyMath.AsUtc(input.Date);
|
||||||
mt.AccountId = input.AccountId;
|
mt.AccountId = input.AccountId;
|
||||||
mt.CounterpartyId = input.CounterpartyId;
|
mt.CounterpartyId = input.CounterpartyId;
|
||||||
mt.ExpenseItemId = input.ExpenseItemId;
|
mt.ExpenseItemId = input.ExpenseItemId;
|
||||||
|
|
@ -292,7 +292,7 @@ public async Task<ActionResult<object>> Transfer([FromBody] TransferInput input,
|
||||||
{
|
{
|
||||||
OrganizationId = orgId,
|
OrganizationId = orgId,
|
||||||
Number = await MoneyNumbering.NextMoneyTxNumberAsync(_db, orgId, input.Date.Year, ct),
|
Number = await MoneyNumbering.NextMoneyTxNumberAsync(_db, orgId, input.Date.Year, ct),
|
||||||
Date = input.Date,
|
Date = MoneyMath.AsUtc(input.Date),
|
||||||
Kind = MoneyTransactionKind.TransferOut,
|
Kind = MoneyTransactionKind.TransferOut,
|
||||||
Status = MoneyTransactionStatus.Draft,
|
Status = MoneyTransactionStatus.Draft,
|
||||||
AccountId = from.Id,
|
AccountId = from.Id,
|
||||||
|
|
@ -304,7 +304,7 @@ public async Task<ActionResult<object>> Transfer([FromBody] TransferInput input,
|
||||||
{
|
{
|
||||||
OrganizationId = orgId,
|
OrganizationId = orgId,
|
||||||
Number = await MoneyNumbering.NextMoneyTxNumberAsync(_db, orgId, input.Date.Year, ct),
|
Number = await MoneyNumbering.NextMoneyTxNumberAsync(_db, orgId, input.Date.Year, ct),
|
||||||
Date = input.Date,
|
Date = MoneyMath.AsUtc(input.Date),
|
||||||
Kind = MoneyTransactionKind.TransferIn,
|
Kind = MoneyTransactionKind.TransferIn,
|
||||||
Status = MoneyTransactionStatus.Draft,
|
Status = MoneyTransactionStatus.Draft,
|
||||||
AccountId = to.Id,
|
AccountId = to.Id,
|
||||||
|
|
|
||||||
|
|
@ -51,4 +51,17 @@ public static decimal AccountBalance(decimal openingBalance, decimal inSum, deci
|
||||||
/// переплатив (payed + add ≤ total + ε).</summary>
|
/// переплатив (payed + add ≤ total + ε).</summary>
|
||||||
public static bool CanApplyPayment(decimal currentPayed, decimal addSum, decimal total)
|
public static bool CanApplyPayment(decimal currentPayed, decimal addSum, decimal total)
|
||||||
=> currentPayed + addSum <= total + Eps;
|
=> 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);
|
((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]
|
[Fact]
|
||||||
public async Task Account_balance_invariant_opening_plus_posted_movements()
|
public async Task Account_balance_invariant_opening_plus_posted_movements()
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue