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)
|
if (currencyId == Guid.Empty)
|
||||||
throw new PosSaleRejectedException("Не найдена валюта KZT.", "currency");
|
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
|
var sale = new RetailSale
|
||||||
{
|
{
|
||||||
Number = number,
|
Number = number,
|
||||||
Date = src.OccurredAt,
|
Date = occurredAtUtc,
|
||||||
Status = RetailSaleStatus.Draft,
|
Status = RetailSaleStatus.Draft,
|
||||||
StoreId = storeId,
|
StoreId = storeId,
|
||||||
CurrencyId = currencyId,
|
CurrencyId = currencyId,
|
||||||
|
|
@ -498,7 +501,7 @@ orderby l.SortOrder
|
||||||
DocumentId: sale.Id,
|
DocumentId: sale.Id,
|
||||||
DocumentNumber: sale.Number,
|
DocumentNumber: sale.Number,
|
||||||
UnitCost: l.UnitPrice,
|
UnitCost: l.UnitPrice,
|
||||||
OccurredAt: src.OccurredAt), ct);
|
OccurredAt: occurredAtUtc), ct);
|
||||||
}
|
}
|
||||||
// Возврат по чеку: инкрементим QtyReturned на исходных строках (защита
|
// Возврат по чеку: инкрементим QtyReturned на исходных строках (защита
|
||||||
// от повторного возврата той же позиции). Насыщаем по строкам продукта.
|
// от повторного возврата той же позиции). Насыщаем по строкам продукта.
|
||||||
|
|
|
||||||
|
|
@ -328,7 +328,10 @@ public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput
|
||||||
return BadRequest(new { error = $"У товара «{noPrice}» нет цены продажи. Укажите цену в карточке или введите вручную.", code = "no-sale-price" });
|
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);
|
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
|
||||||
decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero);
|
decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero);
|
||||||
// Если возврат привязан к чеку — валидация что reference существует и проведён.
|
// Если возврат привязан к чеку — валидация что reference существует и проведён.
|
||||||
|
|
@ -355,7 +358,7 @@ public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput
|
||||||
var sale = new RetailSale
|
var sale = new RetailSale
|
||||||
{
|
{
|
||||||
Number = number,
|
Number = number,
|
||||||
Date = input.Date,
|
Date = saleDateUtc,
|
||||||
Status = RetailSaleStatus.Draft,
|
Status = RetailSaleStatus.Draft,
|
||||||
StoreId = input.StoreId,
|
StoreId = input.StoreId,
|
||||||
RetailPointId = input.RetailPointId,
|
RetailPointId = input.RetailPointId,
|
||||||
|
|
|
||||||
|
|
@ -33,4 +33,22 @@ public static DateTime StartOfMonthUtc(DateTime instantUtc)
|
||||||
/// <summary>Местная дата (без времени) для группировки «по дням» в отчётах.</summary>
|
/// <summary>Местная дата (без времени) для группировки «по дням» в отчётах.</summary>
|
||||||
public static DateTime LocalDate(DateTime instantUtc) =>
|
public static DateTime LocalDate(DateTime instantUtc) =>
|
||||||
(DateTime.SpecifyKind(instantUtc, DateTimeKind.Utc) + Offset).Date;
|
(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