From 174cb563bfcacf7d515e8b2c92faf02e2ade8dbc Mon Sep 17 00:00:00 2001 From: nns Date: Wed, 7 Oct 2026 22:29:38 +0500 Subject: [PATCH] =?UTF-8?q?fix(money):=20=D0=BD=D0=BE=D1=80=D0=BC=D0=B0?= =?UTF-8?q?=D0=BB=D0=B8=D0=B7=D0=BE=D0=B2=D0=B0=D1=82=D1=8C=20date-only=20?= =?UTF-8?q?=D0=B4=D0=B0=D1=82=D1=8B=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D0=BE=D0=B2=20=D0=BA=20UTC=20(Npgsql=20timestamptz?= =?UTF-8?q?)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- .../CounterpartyAdjustmentsController.cs | 5 +++-- .../Money/MoneyAccountsController.cs | 5 +++-- .../Money/MoneyTransactionsController.cs | 8 ++++---- .../Money/MoneyMath.cs | 13 ++++++++++++ .../MoneyModuleTests.cs | 20 +++++++++++++++++++ 5 files changed, 43 insertions(+), 8 deletions(-) diff --git a/src/food-market.api/Controllers/Money/CounterpartyAdjustmentsController.cs b/src/food-market.api/Controllers/Money/CounterpartyAdjustmentsController.cs index f05fade..2e7a598 100644 --- a/src/food-market.api/Controllers/Money/CounterpartyAdjustmentsController.cs +++ b/src/food-market.api/Controllers/Money/CounterpartyAdjustmentsController.cs @@ -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> 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> 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; diff --git a/src/food-market.api/Controllers/Money/MoneyAccountsController.cs b/src/food-market.api/Controllers/Money/MoneyAccountsController.cs index 0f34612..254539a 100644 --- a/src/food-market.api/Controllers/Money/MoneyAccountsController.cs +++ b/src/food-market.api/Controllers/Money/MoneyAccountsController.cs @@ -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> 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> 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"); diff --git a/src/food-market.api/Controllers/Money/MoneyTransactionsController.cs b/src/food-market.api/Controllers/Money/MoneyTransactionsController.cs index a633101..3396536 100644 --- a/src/food-market.api/Controllers/Money/MoneyTransactionsController.cs +++ b/src/food-market.api/Controllers/Money/MoneyTransactionsController.cs @@ -163,7 +163,7 @@ public async Task> 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> 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> 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> 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, diff --git a/src/food-market.application/Money/MoneyMath.cs b/src/food-market.application/Money/MoneyMath.cs index 7aa34e3..6e925cc 100644 --- a/src/food-market.application/Money/MoneyMath.cs +++ b/src/food-market.application/Money/MoneyMath.cs @@ -51,4 +51,17 @@ public static decimal AccountBalance(decimal openingBalance, decimal inSum, deci /// переплатив (payed + add ≤ total + ε). public static bool CanApplyPayment(decimal currentPayed, decimal addSum, decimal total) => currentPayed + addSum <= total + Eps; + + /// Нормализует дату документа к UTC перед записью в + /// timestamp with time zone. Date-only из UI (YYYY-MM-DD) + /// десериализуется с — Npgsql такой + /// в timestamptz не пишет (кидает 500), поэтому помечаем как UTC (та же + /// календарная дата, полночь UTC). Полный ISO с Z (Kind=Utc) — + /// as-is; локальный — переводим в UTC. + public static DateTime AsUtc(DateTime d) => d.Kind switch + { + DateTimeKind.Utc => d, + DateTimeKind.Local => d.ToUniversalTime(), + _ => DateTime.SpecifyKind(d, DateTimeKind.Utc), + }; } diff --git a/tests/food-market.IntegrationTests/MoneyModuleTests.cs b/tests/food-market.IntegrationTests/MoneyModuleTests.cs index 215e017..11c2fb1 100644 --- a/tests/food-market.IntegrationTests/MoneyModuleTests.cs +++ b/tests/food-market.IntegrationTests/MoneyModuleTests.cs @@ -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()).GetProperty("currentBalance").GetDecimal().Should().Be(50_000m); + } + [Fact] public async Task Account_balance_invariant_opening_plus_posted_movements() {