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:
parent
1324ffd04f
commit
f3a177a121
|
|
@ -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 на исходных строках (защита
|
||||
// от повторного возврата той же позиции). Насыщаем по строкам продукта.
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
52
tests/food-market.UnitTests/BusinessTimeTests.cs
Normal file
52
tests/food-market.UnitTests/BusinessTimeTests.cs
Normal 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));
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue