diff --git a/src/food-market.api/Controllers/Pos/PosController.cs b/src/food-market.api/Controllers/Pos/PosController.cs index 559175b..1bcebdb 100644 --- a/src/food-market.api/Controllers/Pos/PosController.cs +++ b/src/food-market.api/Controllers/Pos/PosController.cs @@ -436,11 +436,14 @@ orderby l.SortOrder if (currencyId == Guid.Empty) throw new PosSaleRejectedException("Не найдена валюта KZT.", "currency"); - var number = await GenerateNumberAsync(src.OccurredAt, ct); + // Время чека с кассы → UTC-инстант без «будущих» времён (касса могла прислать + // локальное/спешащее время). Остальные отметки движения берут тот же инстант. + var occurredAtUtc = foodmarket.Application.Common.BusinessTime.ToInstantUtc(src.OccurredAt); + var number = await GenerateNumberAsync(occurredAtUtc, ct); var sale = new RetailSale { Number = number, - Date = src.OccurredAt, + Date = occurredAtUtc, Status = RetailSaleStatus.Draft, StoreId = storeId, CurrencyId = currencyId, @@ -498,7 +501,7 @@ orderby l.SortOrder DocumentId: sale.Id, DocumentNumber: sale.Number, UnitCost: l.UnitPrice, - OccurredAt: src.OccurredAt), ct); + OccurredAt: occurredAtUtc), ct); } // Возврат по чеку: инкрементим QtyReturned на исходных строках (защита // от повторного возврата той же позиции). Насыщаем по строкам продукта. diff --git a/src/food-market.api/Controllers/Sales/RetailSalesController.cs b/src/food-market.api/Controllers/Sales/RetailSalesController.cs index e1cf5d2..e538fc8 100644 --- a/src/food-market.api/Controllers/Sales/RetailSalesController.cs +++ b/src/food-market.api/Controllers/Sales/RetailSalesController.cs @@ -328,7 +328,10 @@ public async Task> Create([FromBody] RetailSaleInput return BadRequest(new { error = $"У товара «{noPrice}» нет цены продажи. Укажите цену в карточке или введите вручную.", code = "no-sale-price" }); } } - var number = await GenerateNumberAsync(input.Date, ct); + // Нормализуем дату чека к UTC и не даём «будущих» времён (иначе ленты + // «Последние продажи»/Z-отчёт показывают времена вперёд). Корень фикса. + var saleDateUtc = foodmarket.Application.Common.BusinessTime.ToInstantUtc(input.Date); + var number = await GenerateNumberAsync(saleDateUtc, ct); var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct); decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero); // Если возврат привязан к чеку — валидация что reference существует и проведён. @@ -355,7 +358,7 @@ public async Task> Create([FromBody] RetailSaleInput var sale = new RetailSale { Number = number, - Date = input.Date, + Date = saleDateUtc, Status = RetailSaleStatus.Draft, StoreId = input.StoreId, RetailPointId = input.RetailPointId, diff --git a/src/food-market.application/Common/BusinessTime.cs b/src/food-market.application/Common/BusinessTime.cs index 9cbf6a0..22419cf 100644 --- a/src/food-market.application/Common/BusinessTime.cs +++ b/src/food-market.application/Common/BusinessTime.cs @@ -33,4 +33,22 @@ public static DateTime StartOfMonthUtc(DateTime instantUtc) /// Местная дата (без времени) для группировки «по дням» в отчётах. public static DateTime LocalDate(DateTime instantUtc) => (DateTime.SpecifyKind(instantUtc, DateTimeKind.Utc) + Offset).Date; + + /// Нормализует дату документа от клиента к UTC-инстанту и защищает от + /// «будущих» времён. Kind=Utc — как есть; Local — в UTC; Unspecified (наивная, + /// без 'Z') — трактуем как UTC (без сдвига). Если результат оказался в будущем + /// больше допуска (клиентские часы спешат / наивная локаль дала сдвиг вперёд) — + /// заменяем на текущий момент: чек/документ не может быть в будущем, иначе ленты + /// («Последние продажи», Z-отчёт, список продаж) показывают будущие времена. + public static DateTime ToInstantUtc(DateTime d, TimeSpan? futureTolerance = null) + { + var utc = d.Kind switch + { + DateTimeKind.Utc => d, + DateTimeKind.Local => d.ToUniversalTime(), + _ => DateTime.SpecifyKind(d, DateTimeKind.Utc), + }; + var now = DateTime.UtcNow; + return utc > now + (futureTolerance ?? TimeSpan.FromMinutes(5)) ? now : utc; + } } diff --git a/tests/food-market.UnitTests/BusinessTimeTests.cs b/tests/food-market.UnitTests/BusinessTimeTests.cs new file mode 100644 index 0000000..d017e94 --- /dev/null +++ b/tests/food-market.UnitTests/BusinessTimeTests.cs @@ -0,0 +1,52 @@ +using FluentAssertions; +using foodmarket.Application.Common; +using Xunit; + +namespace foodmarket.UnitTests; + +/// Нормализация даты документа к UTC и защита от «будущих» времён +/// (корень бага «Последние продажи» показывали время вперёд). +public class BusinessTimeTests +{ + [Fact] + public void Utc_date_in_past_is_unchanged() + { + var past = DateTime.UtcNow.AddHours(-3); + BusinessTime.ToInstantUtc(past).Should().BeCloseTo(past, TimeSpan.FromSeconds(1)); + } + + [Fact] + public void Future_date_is_clamped_to_now() + { + var future = DateTime.UtcNow.AddHours(3); + var result = BusinessTime.ToInstantUtc(future); + result.Should().BeOnOrBefore(DateTime.UtcNow.AddSeconds(1)); + result.Should().BeCloseTo(DateTime.UtcNow, TimeSpan.FromMinutes(1), "будущее заменяется на now"); + } + + [Fact] + public void Small_clock_skew_within_tolerance_is_kept() + { + var slightlyAhead = DateTime.UtcNow.AddMinutes(2); + BusinessTime.ToInstantUtc(slightlyAhead).Should().BeCloseTo(slightlyAhead, TimeSpan.FromSeconds(1), + "небольшой разбег часов (до 5 мин) допускаем"); + } + + [Fact] + public void Local_kind_is_converted_to_utc() + { + var local = DateTime.SpecifyKind(DateTime.Now.AddHours(-1), DateTimeKind.Local); + var result = BusinessTime.ToInstantUtc(local); + result.Kind.Should().Be(DateTimeKind.Utc); + result.Should().BeCloseTo(local.ToUniversalTime(), TimeSpan.FromSeconds(1)); + } + + [Fact] + public void Unspecified_kind_is_treated_as_utc_no_shift() + { + var naive = DateTime.SpecifyKind(DateTime.UtcNow.AddHours(-2), DateTimeKind.Unspecified); + var result = BusinessTime.ToInstantUtc(naive); + result.Kind.Should().Be(DateTimeKind.Utc); + result.Should().BeCloseTo(DateTime.SpecifyKind(naive, DateTimeKind.Utc), TimeSpan.FromSeconds(1)); + } +}