fix(sales): дата чека в UTC + защита от «будущих» времён

«Последние продажи»/Z-отчёт показывали времена вперёд, потому что дата чека
бралась от клиента (input.Date / POS OccurredAt) без нормализации: наивная
локальная дата или спешащие часы кассы давали будущий инстант. Добавил
BusinessTime.ToInstantUtc: Kind→UTC и clamp будущего (>5 мин) к now — чек не
может быть в будущем. Применено в рознице (create) и в приёме чеков с кассы
(движение берёт тот же инстант). Юнит-тесты на нормализацию/clamp.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-09 09:58:34 +05:00
parent 1324ffd04f
commit f3a177a121
4 changed files with 81 additions and 5 deletions

View file

@ -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 на исходных строках (защита
// от повторного возврата той же позиции). Насыщаем по строкам продукта.

View file

@ -328,7 +328,10 @@ public async Task<ActionResult<RetailSaleDto>> 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<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput
var sale = new RetailSale
{
Number = number,
Date = input.Date,
Date = saleDateUtc,
Status = RetailSaleStatus.Draft,
StoreId = input.StoreId,
RetailPointId = input.RetailPointId,

View file

@ -33,4 +33,22 @@ public static DateTime StartOfMonthUtc(DateTime instantUtc)
/// <summary>Местная дата (без времени) для группировки «по дням» в отчётах.</summary>
public static DateTime LocalDate(DateTime instantUtc) =>
(DateTime.SpecifyKind(instantUtc, DateTimeKind.Utc) + Offset).Date;
/// <summary>Нормализует дату документа от клиента к UTC-инстанту и защищает от
/// «будущих» времён. Kind=Utc — как есть; Local — в UTC; Unspecified (наивная,
/// без 'Z') — трактуем как UTC (без сдвига). Если результат оказался в будущем
/// больше допуска (клиентские часы спешат / наивная локаль дала сдвиг вперёд) —
/// заменяем на текущий момент: чек/документ не может быть в будущем, иначе ленты
/// («Последние продажи», Z-отчёт, список продаж) показывают будущие времена.</summary>
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;
}
}

View file

@ -0,0 +1,52 @@
using FluentAssertions;
using foodmarket.Application.Common;
using Xunit;
namespace foodmarket.UnitTests;
/// <summary>Нормализация даты документа к UTC и защита от «будущих» времён
/// (корень бага «Последние продажи» показывали время вперёд).</summary>
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));
}
}