feat(pos-api): GET /sync и POST /sales с двойной идемпотентностью (P1-12b)
Endpoints: - GET /api/pos/v1/sync?since=ISO&storeId=Guid - выгрузка изменений (Products / Prices / Stocks / Counterparties) после reference time; Stocks - всегда полный снимок на момент ответа (POS нужен актуальный остаток на полке). - POST /api/pos/v1/sales - батч продаж с idempotency. Двойная идемпотентность: 1. Batch-level: PosBatchAck (новая таблица, unique idx по OrgId+Key) - повтор того же батча возвращает кешированный ответ. При параллельном race ловим 23505 на уникальном индексе и тоже возвращаем кеш. 2. Per-sale: ClientSaleId записывается в RetailSale.Notes как prefix "pos:GUID32". Перед созданием продажи проверяем что такой маркер ещё не встречался - если есть, возвращаем существующую продажу. Это спасает и при разных batch-ключах с пересекающимися ClientSaleId. Pre-flight: проверка остатка ДО создания черновика - sale, которая не влезает в полку, попадает в Failed, остальные в батче проводятся. Domain: PosBatchAck (TenantEntity), миграция Phase7a_PosBatchAcks (jsonb для ResponseJson, unique idx). Контракты v1 из food-market.shared. Тесты: 7 интеграционных - полная sync, дельта по since, POST батч списывает stock, replay того же батча no-duplicates, ClientSaleId через разные batch-keys тоже no-duplicates, недостача попадает в Failed, tenant-изоляция. Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
parent
1d45f44447
commit
640c8d9c22
413
src/food-market.api/Controllers/Pos/PosController.cs
Normal file
413
src/food-market.api/Controllers/Pos/PosController.cs
Normal file
|
|
@ -0,0 +1,413 @@
|
|||
using System.Text.Json;
|
||||
using foodmarket.Application.Common.Tenancy;
|
||||
using foodmarket.Application.Inventory;
|
||||
using foodmarket.Domain.Inventory;
|
||||
using foodmarket.Domain.Sales;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
using foodmarket.Shared.Pos.V1;
|
||||
using Microsoft.AspNetCore.Authorization;
|
||||
using Microsoft.AspNetCore.Mvc;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace foodmarket.Api.Controllers.Pos;
|
||||
|
||||
/// <summary>API синхронизации с оффлайн-кассами (food-market.pos, WPF/Windows).
|
||||
///
|
||||
/// Эндпоинты:
|
||||
/// • <c>GET /api/pos/v1/sync?since=ISO8601&storeId=…</c> — выгрузка изменений
|
||||
/// с указанной reference time. Возвращает товары, цены, остатки на момент
|
||||
/// ответа, контрагентов и список архивированных товаров.
|
||||
/// • <c>POST /api/pos/v1/sales</c> — приём батча продаж от кассы.
|
||||
/// <see cref="PosSaleBatchDto.IdempotencyKey"/> + per-sale ClientSaleId —
|
||||
/// двойная идемпотентность: повтор того же батча возвращает тот же
|
||||
/// результат без дублей в БД.
|
||||
///
|
||||
/// Multi-tenant: запросы идут под обычным OpenIddict JWT с claim
|
||||
/// <c>org_id</c>. Дополнительно POS привязан к одному магазину через
|
||||
/// <c>storeId</c> query/body — контроллер валидирует, что store
|
||||
/// принадлежит организации.
|
||||
///
|
||||
/// Версионирование: префикс v1 в URL зафиксирован, добавления новых
|
||||
/// необязательных полей в DTO не ломают v1; для breaking changes —
|
||||
/// рядом будет v2.</summary>
|
||||
[ApiController]
|
||||
[Authorize]
|
||||
[Route("api/pos/v1")]
|
||||
public class PosController : ControllerBase
|
||||
{
|
||||
private readonly AppDbContext _db;
|
||||
private readonly ITenantContext _tenant;
|
||||
private readonly IStockService _stock;
|
||||
private readonly ILogger<PosController> _log;
|
||||
|
||||
public PosController(AppDbContext db, ITenantContext tenant, IStockService stock, ILogger<PosController> log)
|
||||
{
|
||||
_db = db;
|
||||
_tenant = tenant;
|
||||
_stock = stock;
|
||||
_log = log;
|
||||
}
|
||||
|
||||
[HttpGet("sync")]
|
||||
public async Task<ActionResult<PosSyncResponse>> Sync(
|
||||
[FromQuery] DateTime? since,
|
||||
[FromQuery] Guid? storeId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
|
||||
var resolvedStore = await ResolveStoreAsync(storeId, ct);
|
||||
if (resolvedStore is null)
|
||||
return BadRequest(new { error = "Магазин не найден или не принадлежит организации.", field = "storeId" });
|
||||
|
||||
var threshold = since ?? DateTime.MinValue.ToUniversalTime();
|
||||
var serverTime = DateTime.UtcNow;
|
||||
|
||||
// Товары: дельта по UpdatedAt/CreatedAt. У записи Entity.UpdatedAt
|
||||
// может быть null если её ни разу не правили после CreatedAt; берём max.
|
||||
var products = await (from p in _db.Products.AsNoTracking()
|
||||
join u in _db.UnitsOfMeasure.AsNoTracking() on p.UnitOfMeasureId equals u.Id
|
||||
let stamp = p.UpdatedAt ?? p.CreatedAt
|
||||
where stamp > threshold
|
||||
select new { p, u, stamp })
|
||||
.ToListAsync(ct);
|
||||
|
||||
var productIds = products.Select(x => x.p.Id).ToList();
|
||||
var barcodes = await _db.ProductBarcodes.AsNoTracking()
|
||||
.Where(b => productIds.Contains(b.ProductId))
|
||||
.OrderByDescending(b => b.IsPrimary)
|
||||
.Select(b => new { b.ProductId, b.Code })
|
||||
.ToListAsync(ct);
|
||||
var barcodesByProduct = barcodes.GroupBy(b => b.ProductId)
|
||||
.ToDictionary(g => g.Key, g => (IReadOnlyList<string>)g.Select(x => x.Code).ToList());
|
||||
|
||||
var productDtos = products.Select(x => new ProductSyncDto
|
||||
{
|
||||
Id = x.p.Id,
|
||||
Name = x.p.Name,
|
||||
Article = x.p.Article,
|
||||
Barcodes = barcodesByProduct.TryGetValue(x.p.Id, out var b) ? b : Array.Empty<string>(),
|
||||
UnitCode = x.u.Code,
|
||||
Packaging = (int)x.p.Packaging,
|
||||
VatPercent = x.p.Vat,
|
||||
VatEnabled = x.p.VatEnabled,
|
||||
IsMarked = x.p.IsMarked,
|
||||
// Product не имеет IsArchived в текущей доменной модели; всегда false.
|
||||
// Когда добавим архив — заполняется здесь, без правки контракта.
|
||||
IsArchived = false,
|
||||
UpdatedAt = x.stamp,
|
||||
}).ToList();
|
||||
|
||||
// Цены: всё что изменилось после since. PriceType подтягиваем чтобы
|
||||
// POS мог выбрать «системный».
|
||||
var prices = await (from pp in _db.ProductPrices.AsNoTracking()
|
||||
join pt in _db.PriceTypes.AsNoTracking() on pp.PriceTypeId equals pt.Id
|
||||
join cu in _db.Currencies.AsNoTracking() on pp.CurrencyId equals cu.Id
|
||||
let stamp = pp.UpdatedAt ?? pp.CreatedAt
|
||||
where stamp > threshold
|
||||
select new PriceSyncDto
|
||||
{
|
||||
ProductId = pp.ProductId,
|
||||
PriceTypeId = pt.Id,
|
||||
PriceTypeName = pt.Name,
|
||||
IsSystem = pt.IsSystem,
|
||||
Amount = pp.Amount,
|
||||
CurrencyCode = cu.Code,
|
||||
UpdatedAt = stamp,
|
||||
}).ToListAsync(ct);
|
||||
|
||||
// Остатки: всегда полный снимок на момент ответа. since-фильтр не
|
||||
// применяется к остаткам — кассе всегда нужен актуальный stock.
|
||||
var stocks = await (from s in _db.Stocks.AsNoTracking()
|
||||
where s.StoreId == resolvedStore.Value && s.Quantity != 0m
|
||||
select new StockSyncDto
|
||||
{
|
||||
ProductId = s.ProductId,
|
||||
StoreId = s.StoreId,
|
||||
Quantity = s.Quantity,
|
||||
AsOf = serverTime,
|
||||
}).ToListAsync(ct);
|
||||
|
||||
// Контрагенты-покупатели. Поставщиков на POS не шлём.
|
||||
var counterparties = await (from c in _db.Counterparties.AsNoTracking()
|
||||
let stamp = c.UpdatedAt ?? c.CreatedAt
|
||||
where stamp > threshold
|
||||
select new CounterpartySyncDto
|
||||
{
|
||||
Id = c.Id,
|
||||
Name = c.Name,
|
||||
Phone = c.Phone,
|
||||
Email = c.Email,
|
||||
Bin = c.Bin,
|
||||
Iin = c.Iin,
|
||||
UpdatedAt = stamp,
|
||||
}).ToListAsync(ct);
|
||||
|
||||
// DeletedProductIds: в текущей модели Product hard-delete'ятся; для
|
||||
// soft-delete заведём отдельный feed; пока пустой массив.
|
||||
return Ok(new PosSyncResponse
|
||||
{
|
||||
ServerTime = serverTime,
|
||||
Products = productDtos,
|
||||
Prices = prices,
|
||||
Stocks = stocks,
|
||||
Counterparties = counterparties,
|
||||
DeletedProductIds = Array.Empty<Guid>(),
|
||||
});
|
||||
}
|
||||
|
||||
[HttpPost("sales")]
|
||||
public async Task<ActionResult<PosSaleBatchResponse>> AcceptSales(
|
||||
[FromBody] PosSaleBatchDto batch,
|
||||
[FromQuery] Guid? storeId,
|
||||
CancellationToken ct)
|
||||
{
|
||||
if (batch is null || batch.Sales is null)
|
||||
return BadRequest(new { error = "Пустое тело батча.", field = "batch" });
|
||||
if (batch.IdempotencyKey == Guid.Empty)
|
||||
return BadRequest(new { error = "Идемпотентный ключ не указан.", field = "idempotencyKey" });
|
||||
|
||||
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
|
||||
var resolvedStore = await ResolveStoreAsync(storeId, ct);
|
||||
if (resolvedStore is null)
|
||||
return BadRequest(new { error = "Магазин не найден или не принадлежит организации.", field = "storeId" });
|
||||
|
||||
// 1) Идемпотентность на уровне батча. Сначала проверяем cache.
|
||||
var existing = await _db.PosBatchAcks.AsNoTracking()
|
||||
.FirstOrDefaultAsync(a => a.IdempotencyKey == batch.IdempotencyKey, ct);
|
||||
if (existing is not null)
|
||||
{
|
||||
var prev = JsonSerializer.Deserialize<PosSaleBatchResponse>(existing.ResponseJson)!;
|
||||
return Ok(prev with { ReplayedFromCache = true });
|
||||
}
|
||||
|
||||
// 2) Идемпотентность на уровне отдельной продажи: ищем уже созданные
|
||||
// RetailSale, у которых Notes начинается с маркера ClientSaleId.
|
||||
// (Альтернативой было бы добавить отдельную колонку ClientSaleId; для
|
||||
// минимизации миграций используем Notes-префикс — он уже nullable.)
|
||||
var clientSaleIds = batch.Sales.Select(s => s.ClientSaleId).Distinct().ToList();
|
||||
var markers = clientSaleIds.Select(g => $"pos:{g:N}").ToList();
|
||||
var alreadyByMarker = await _db.RetailSales.AsNoTracking()
|
||||
.Where(s => s.Notes != null && markers.Contains(s.Notes.Substring(0, Math.Min(36, s.Notes.Length))))
|
||||
.Select(s => new { s.Id, s.Number, s.Notes })
|
||||
.ToListAsync(ct);
|
||||
// Возвращает строки где Notes начинается с маркера; восстанавливаем csid.
|
||||
var alreadyByClient = alreadyByMarker
|
||||
.Where(x => x.Notes!.StartsWith("pos:"))
|
||||
.ToDictionary(
|
||||
x => Guid.ParseExact(x.Notes!.Substring(4, 32), "N"),
|
||||
x => (Id: x.Id, Number: x.Number));
|
||||
|
||||
var accepted = new List<PosSaleAcceptedDto>();
|
||||
var failed = new List<PosSaleFailedDto>();
|
||||
|
||||
foreach (var sale in batch.Sales)
|
||||
{
|
||||
if (alreadyByClient.TryGetValue(sale.ClientSaleId, out var prior))
|
||||
{
|
||||
accepted.Add(new PosSaleAcceptedDto
|
||||
{
|
||||
ClientSaleId = sale.ClientSaleId,
|
||||
ServerSaleId = prior.Id,
|
||||
ServerSaleNumber = prior.Number,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
try
|
||||
{
|
||||
var (serverSale, serverNumber) = await CreateAndPostSaleAsync(
|
||||
sale, resolvedStore.Value, ct);
|
||||
accepted.Add(new PosSaleAcceptedDto
|
||||
{
|
||||
ClientSaleId = sale.ClientSaleId,
|
||||
ServerSaleId = serverSale,
|
||||
ServerSaleNumber = serverNumber,
|
||||
});
|
||||
}
|
||||
catch (PosSaleRejectedException ex)
|
||||
{
|
||||
failed.Add(new PosSaleFailedDto
|
||||
{
|
||||
ClientSaleId = sale.ClientSaleId,
|
||||
Error = ex.Message,
|
||||
Field = ex.Field,
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
// 3) Записываем ack под уникальным ключом. Если параллельный POS-запрос
|
||||
// успел нас обогнать — словим 23505 и вернём его ack.
|
||||
var response = new PosSaleBatchResponse
|
||||
{
|
||||
IdempotencyKey = batch.IdempotencyKey,
|
||||
Accepted = accepted,
|
||||
Failed = failed,
|
||||
ReplayedFromCache = false,
|
||||
};
|
||||
var ack = new PosBatchAck
|
||||
{
|
||||
IdempotencyKey = batch.IdempotencyKey,
|
||||
ResponseJson = JsonSerializer.Serialize(response),
|
||||
};
|
||||
_db.PosBatchAcks.Add(ack);
|
||||
try
|
||||
{
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23505")
|
||||
{
|
||||
// Конфликт по уникальному индексу — параллельный запрос успел
|
||||
// сохранить ack. Возвращаем тот ack как replay.
|
||||
var other = await _db.PosBatchAcks.AsNoTracking()
|
||||
.FirstAsync(a => a.IdempotencyKey == batch.IdempotencyKey, ct);
|
||||
var prev = JsonSerializer.Deserialize<PosSaleBatchResponse>(other.ResponseJson)!;
|
||||
return Ok(prev with { ReplayedFromCache = true });
|
||||
}
|
||||
return Ok(response);
|
||||
}
|
||||
|
||||
/// <summary>Создаёт RetailSale + проводит, возвращая (id, number).
|
||||
/// Pre-flight: проверка остатков. При неудаче кидает <see cref="PosSaleRejectedException"/>,
|
||||
/// который ловится наверху и формирует Failed-запись батч-ответа.</summary>
|
||||
private async Task<(Guid Id, string Number)> CreateAndPostSaleAsync(
|
||||
PosSaleDto src, Guid storeId, CancellationToken ct)
|
||||
{
|
||||
if (src.Lines.Count == 0)
|
||||
throw new PosSaleRejectedException("Пустой чек.", "lines");
|
||||
|
||||
// Pre-flight на остатки. Сделано до создания RetailSale чтобы зря не
|
||||
// дёргать БД при пустой полке.
|
||||
var byProduct = src.Lines.GroupBy(l => l.ProductId)
|
||||
.Select(g => new { ProductId = g.Key, Qty = g.Sum(x => x.Quantity) }).ToList();
|
||||
var productIds = byProduct.Select(x => x.ProductId).ToList();
|
||||
var stocks = await _db.Stocks
|
||||
.Where(s => s.StoreId == storeId && productIds.Contains(s.ProductId))
|
||||
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
|
||||
foreach (var x in byProduct)
|
||||
{
|
||||
stocks.TryGetValue(x.ProductId, out var avail);
|
||||
if (avail < x.Qty)
|
||||
throw new PosSaleRejectedException(
|
||||
$"Недостаточный остаток для товара {x.ProductId}.", "productId");
|
||||
}
|
||||
|
||||
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
|
||||
decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero);
|
||||
|
||||
var currencyId = await _db.Currencies.AsNoTracking()
|
||||
.Where(c => c.Code == "KZT").Select(c => c.Id).FirstOrDefaultAsync(ct);
|
||||
if (currencyId == Guid.Empty)
|
||||
throw new PosSaleRejectedException("Не найдена валюта KZT.", "currency");
|
||||
|
||||
var number = await GenerateNumberAsync(src.OccurredAt, ct);
|
||||
var sale = new RetailSale
|
||||
{
|
||||
Number = number,
|
||||
Date = src.OccurredAt,
|
||||
Status = RetailSaleStatus.Draft,
|
||||
StoreId = storeId,
|
||||
CurrencyId = currencyId,
|
||||
CustomerId = src.CustomerId,
|
||||
CashierUserId = src.CashierUserId,
|
||||
Payment = (PaymentMethod)src.Payment,
|
||||
PaidCash = R(src.PaidCash),
|
||||
PaidCard = R(src.PaidCard),
|
||||
// Маркер для per-sale идемпотентности (см. AcceptSales).
|
||||
Notes = $"pos:{src.ClientSaleId:N}" + (string.IsNullOrEmpty(src.Notes) ? "" : "\n" + src.Notes),
|
||||
};
|
||||
var order = 0;
|
||||
decimal subtotal = 0m, discountTotal = 0m;
|
||||
foreach (var l in src.Lines)
|
||||
{
|
||||
var price = R(l.UnitPrice);
|
||||
var disc = R(l.Discount);
|
||||
var lineTotal = l.Quantity * price - disc;
|
||||
_db.RetailSaleLines.Add(new RetailSaleLine
|
||||
{
|
||||
RetailSaleId = sale.Id,
|
||||
ProductId = l.ProductId,
|
||||
Quantity = l.Quantity,
|
||||
UnitPrice = price,
|
||||
Discount = disc,
|
||||
LineTotal = lineTotal,
|
||||
VatPercent = l.VatPercent,
|
||||
SortOrder = order++,
|
||||
});
|
||||
subtotal += l.Quantity * price;
|
||||
discountTotal += disc;
|
||||
}
|
||||
sale.Subtotal = subtotal;
|
||||
sale.DiscountTotal = discountTotal;
|
||||
sale.Total = subtotal - discountTotal;
|
||||
|
||||
_db.RetailSales.Add(sale);
|
||||
|
||||
// Проведение: -Quantity StockMovement RetailSale.
|
||||
foreach (var l in src.Lines)
|
||||
{
|
||||
await _stock.ApplyMovementAsync(new StockMovementDraft(
|
||||
ProductId: l.ProductId,
|
||||
StoreId: storeId,
|
||||
Quantity: -l.Quantity,
|
||||
Type: MovementType.RetailSale,
|
||||
DocumentType: "retail-sale",
|
||||
DocumentId: sale.Id,
|
||||
DocumentNumber: sale.Number,
|
||||
UnitCost: l.UnitPrice,
|
||||
OccurredAt: src.OccurredAt), ct);
|
||||
}
|
||||
sale.Status = RetailSaleStatus.Posted;
|
||||
sale.PostedAt = DateTime.UtcNow;
|
||||
try
|
||||
{
|
||||
await _db.SaveChangesAsync(ct);
|
||||
}
|
||||
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23505")
|
||||
{
|
||||
// Уникальный конфликт по (OrgId, Number) — крайне редкий race на
|
||||
// GenerateNumberAsync; пересоздавать номер сложно без транзакции,
|
||||
// поэтому отказываем и POS попробует в следующем батче.
|
||||
throw new PosSaleRejectedException("Конфликт номера чека, повторите.", "number");
|
||||
}
|
||||
return (sale.Id, sale.Number);
|
||||
}
|
||||
|
||||
private async Task<Guid?> ResolveStoreAsync(Guid? requested, CancellationToken ct)
|
||||
{
|
||||
if (requested is { } id)
|
||||
{
|
||||
var ok = await _db.Stores.AsNoTracking().AnyAsync(s => s.Id == id, ct);
|
||||
return ok ? id : null;
|
||||
}
|
||||
// Дефолт — главный склад организации.
|
||||
var main = await _db.Stores.AsNoTracking()
|
||||
.Where(s => s.IsMain).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct);
|
||||
if (main is not null) return main;
|
||||
// Иначе — любой активный.
|
||||
return await _db.Stores.AsNoTracking()
|
||||
.Where(s => s.IsActive).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct);
|
||||
}
|
||||
|
||||
private async Task<string> GenerateNumberAsync(DateTime date, CancellationToken ct)
|
||||
{
|
||||
var prefix = $"ПР-{date.Year}-";
|
||||
var lastNumber = await _db.RetailSales
|
||||
.Where(s => s.Number.StartsWith(prefix))
|
||||
.OrderByDescending(s => s.Number)
|
||||
.Select(s => s.Number)
|
||||
.FirstOrDefaultAsync(ct);
|
||||
var seq = 1;
|
||||
if (lastNumber is not null && int.TryParse(lastNumber[prefix.Length..], out var last))
|
||||
seq = last + 1;
|
||||
return $"{prefix}{seq:D6}";
|
||||
}
|
||||
}
|
||||
|
||||
/// <summary>Внутреннее исключение для отказа в продаже на уровне отдельного
|
||||
/// чека: контроллер ловит и формирует строку Failed.</summary>
|
||||
internal sealed class PosSaleRejectedException : Exception
|
||||
{
|
||||
public string? Field { get; }
|
||||
public PosSaleRejectedException(string message, string? field) : base(message)
|
||||
=> Field = field;
|
||||
}
|
||||
26
src/food-market.domain/Sales/PosBatchAck.cs
Normal file
26
src/food-market.domain/Sales/PosBatchAck.cs
Normal file
|
|
@ -0,0 +1,26 @@
|
|||
using foodmarket.Domain.Common;
|
||||
|
||||
namespace foodmarket.Domain.Sales;
|
||||
|
||||
/// <summary>Кеш ответов на батчи продаж от POS-касс для идемпотентности.
|
||||
///
|
||||
/// POS присылает <c>POST /api/pos/sales</c> с <c>IdempotencyKey</c> в теле;
|
||||
/// при сетевой ошибке POS повторяет тот же батч с тем же ключом, и сервер
|
||||
/// должен вернуть прежний результат БЕЗ повторного создания продаж в БД.
|
||||
/// Реализуется через эту таблицу: вставка строки `(OrgId, Key)` через
|
||||
/// unique-index — выигрыш гонки делает запрос обработчиком, проигравшие
|
||||
/// читают <c>ResponseJson</c>.
|
||||
///
|
||||
/// <c>OrganizationId</c> наследуется через <see cref="TenantEntity"/> и
|
||||
/// query-filter ограничивает выборку — POS чужой орги не может прочитать
|
||||
/// чужой ack даже с угаданным ключом. Дополнительно ack TTL'ятся фоновым
|
||||
/// cleanup'ом (Hangfire) — 30 дней.</summary>
|
||||
public class PosBatchAck : TenantEntity
|
||||
{
|
||||
/// <summary>Idempotency-ключ, присланный POS. Уникален в рамках организации.</summary>
|
||||
public Guid IdempotencyKey { get; set; }
|
||||
|
||||
/// <summary>Сериализованный ответ (PosSaleBatchResponse без поля
|
||||
/// ReplayedFromCache — это поле выставляется true при репроигрывании).</summary>
|
||||
public string ResponseJson { get; set; } = "";
|
||||
}
|
||||
|
|
@ -61,6 +61,7 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
|
|||
|
||||
public DbSet<RetailSale> RetailSales => Set<RetailSale>();
|
||||
public DbSet<RetailSaleLine> RetailSaleLines => Set<RetailSaleLine>();
|
||||
public DbSet<PosBatchAck> PosBatchAcks => Set<PosBatchAck>();
|
||||
|
||||
public DbSet<EmployeeRole> EmployeeRoles => Set<EmployeeRole>();
|
||||
public DbSet<Employee> Employees => Set<Employee>();
|
||||
|
|
|
|||
|
|
@ -47,5 +47,17 @@ public static void ConfigureSales(this ModelBuilder b)
|
|||
e.HasOne(x => x.Product).WithMany().HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Restrict);
|
||||
e.HasIndex(x => new { x.OrganizationId, x.ProductId });
|
||||
});
|
||||
|
||||
b.Entity<PosBatchAck>(e =>
|
||||
{
|
||||
e.ToTable("pos_batch_acks");
|
||||
e.Property(x => x.IdempotencyKey).IsRequired();
|
||||
e.Property(x => x.ResponseJson).HasColumnType("jsonb").IsRequired();
|
||||
// Уникальный индекс — гарантия идемпотентности на уровне БД:
|
||||
// вторая параллельная попытка вставить тот же ключ упадёт 23505,
|
||||
// контроллер прочитает уже сохранённый ResponseJson и вернёт его.
|
||||
e.HasIndex(x => new { x.OrganizationId, x.IdempotencyKey }).IsUnique();
|
||||
e.HasIndex(x => x.CreatedAt); // для фонового cleanup'а старых acks
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,43 @@
|
|||
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
using foodmarket.Infrastructure.Persistence;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace foodmarket.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <summary>Phase7a — pos_batch_acks для идемпотентности /api/pos/sales.
|
||||
///
|
||||
/// Хранит сериализованный ответ по (OrgId, IdempotencyKey). Уникальный
|
||||
/// индекс — guarantee против двойного создания продаж при ретрае POS'ом.
|
||||
/// JSONB-колонка ResponseJson: ответ воссоздаётся атомарно из БД, а не
|
||||
/// перестраивается заново.</summary>
|
||||
[DbContext(typeof(AppDbContext))]
|
||||
[Migration("20260528100000_Phase7a_PosBatchAcks")]
|
||||
public partial class Phase7a_PosBatchAcks : Migration
|
||||
{
|
||||
protected override void Up(MigrationBuilder b)
|
||||
{
|
||||
b.Sql(@"
|
||||
CREATE TABLE IF NOT EXISTS public.pos_batch_acks (
|
||||
""Id"" uuid PRIMARY KEY,
|
||||
""OrganizationId"" uuid NOT NULL,
|
||||
""IdempotencyKey"" uuid NOT NULL,
|
||||
""ResponseJson"" jsonb NOT NULL,
|
||||
""CreatedAt"" timestamp with time zone NOT NULL,
|
||||
""UpdatedAt"" timestamp with time zone
|
||||
);
|
||||
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS ""IX_pos_batch_acks_OrganizationId_IdempotencyKey""
|
||||
ON public.pos_batch_acks (""OrganizationId"", ""IdempotencyKey"");
|
||||
CREATE INDEX IF NOT EXISTS ""IX_pos_batch_acks_CreatedAt""
|
||||
ON public.pos_batch_acks (""CreatedAt"");
|
||||
");
|
||||
}
|
||||
|
||||
protected override void Down(MigrationBuilder b)
|
||||
{
|
||||
b.Sql(@"DROP TABLE IF EXISTS public.pos_batch_acks;");
|
||||
}
|
||||
}
|
||||
}
|
||||
237
tests/food-market.IntegrationTests/PosSyncTests.cs
Normal file
237
tests/food-market.IntegrationTests/PosSyncTests.cs
Normal file
|
|
@ -0,0 +1,237 @@
|
|||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using foodmarket.IntegrationTests.Support;
|
||||
using foodmarket.Shared.Pos.V1;
|
||||
using Xunit;
|
||||
|
||||
namespace foodmarket.IntegrationTests;
|
||||
|
||||
[Collection(ApiCollection.Name)]
|
||||
public class PosSyncTests
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
public PosSyncTests(ApiFactory factory) => _factory = factory;
|
||||
private static string RandomBarcode()
|
||||
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_returns_products_prices_stocks()
|
||||
{
|
||||
var api = new ApiActor(_factory.CreateClient());
|
||||
await api.SignupAndLoginAsync($"pos-sync-{Guid.NewGuid():N}");
|
||||
var refs = await api.LoadRefsAsync();
|
||||
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 250m, RandomBarcode());
|
||||
|
||||
// Подкормим остаток через Enter, чтобы было что синхронизировать.
|
||||
var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
|
||||
{
|
||||
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
|
||||
notes = "seed", lines = new[] { new { productId = p1, quantity = 5m, unitCost = 100m } },
|
||||
});
|
||||
enter.EnsureSuccessStatusCode();
|
||||
var eid = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||||
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
|
||||
|
||||
// POS sync без since → полная выгрузка.
|
||||
using var resp = await api.Http.GetAsync("/api/pos/v1/sync");
|
||||
resp.EnsureSuccessStatusCode();
|
||||
var sync = await resp.Content.ReadFromJsonAsync<PosSyncResponse>();
|
||||
sync!.Products.Should().Contain(p => p.Id.ToString() == p1);
|
||||
sync.Prices.Should().Contain(pr => pr.ProductId.ToString() == p1 && pr.Amount == 250m);
|
||||
sync.Stocks.Should().Contain(s => s.ProductId.ToString() == p1 && s.Quantity == 5m);
|
||||
sync.ServerTime.Should().BeBefore(DateTime.UtcNow.AddSeconds(5));
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Sync_with_since_returns_only_delta()
|
||||
{
|
||||
var api = new ApiActor(_factory.CreateClient());
|
||||
await api.SignupAndLoginAsync($"pos-delta-{Guid.NewGuid():N}");
|
||||
var refs = await api.LoadRefsAsync();
|
||||
var pOld = await api.CreateProductAsync(refs, $"OLD-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||||
|
||||
// Маркер времени между двумя продуктами.
|
||||
await Task.Delay(1100);
|
||||
var marker = DateTime.UtcNow;
|
||||
await Task.Delay(1100);
|
||||
|
||||
var pNew = await api.CreateProductAsync(refs, $"NEW-{Guid.NewGuid():N}", 200m, RandomBarcode());
|
||||
|
||||
using var resp = await api.Http.GetAsync($"/api/pos/v1/sync?since={Uri.EscapeDataString(marker.ToString("o"))}");
|
||||
resp.EnsureSuccessStatusCode();
|
||||
var sync = await resp.Content.ReadFromJsonAsync<PosSyncResponse>();
|
||||
sync!.Products.Should().Contain(p => p.Id.ToString() == pNew);
|
||||
sync.Products.Should().NotContain(p => p.Id.ToString() == pOld);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Post_sales_batch_creates_retail_sales_and_decrements_stock()
|
||||
{
|
||||
var api = new ApiActor(_factory.CreateClient());
|
||||
await api.SignupAndLoginAsync($"pos-sale-{Guid.NewGuid():N}");
|
||||
var refs = await api.LoadRefsAsync();
|
||||
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||||
|
||||
var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
|
||||
{
|
||||
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
|
||||
notes = "seed", lines = new[] { new { productId = p1, quantity = 20m, unitCost = 50m } },
|
||||
});
|
||||
enter.EnsureSuccessStatusCode();
|
||||
var eid = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||||
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
|
||||
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(20m);
|
||||
|
||||
var batch = new PosSaleBatchDto
|
||||
{
|
||||
IdempotencyKey = Guid.NewGuid(),
|
||||
Sales = new[]
|
||||
{
|
||||
MakeSale(p1, qty: 2m, price: 100m),
|
||||
MakeSale(p1, qty: 3m, price: 100m),
|
||||
MakeSale(p1, qty: 1m, price: 100m),
|
||||
MakeSale(p1, qty: 4m, price: 100m),
|
||||
MakeSale(p1, qty: 1m, price: 100m),
|
||||
},
|
||||
};
|
||||
using var post = await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch);
|
||||
post.IsSuccessStatusCode.Should().BeTrue($"POST вернул {(int)post.StatusCode}: {await post.Content.ReadAsStringAsync()}");
|
||||
var resp = await post.Content.ReadFromJsonAsync<PosSaleBatchResponse>();
|
||||
resp!.Accepted.Should().HaveCount(5);
|
||||
resp.Failed.Should().BeEmpty();
|
||||
resp.ReplayedFromCache.Should().BeFalse();
|
||||
|
||||
// 20 − (2+3+1+4+1) = 9 на складе.
|
||||
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(9m);
|
||||
}
|
||||
|
||||
/// <summary>Ключевой тест задачи: повтор того же батча → no duplicates,
|
||||
/// тот же ответ, остаток не уменьшается дважды.</summary>
|
||||
[Fact]
|
||||
public async Task Replay_same_batch_returns_cached_response_no_duplicates()
|
||||
{
|
||||
var api = new ApiActor(_factory.CreateClient());
|
||||
await api.SignupAndLoginAsync($"pos-idem-{Guid.NewGuid():N}");
|
||||
var refs = await api.LoadRefsAsync();
|
||||
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||||
var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
|
||||
{
|
||||
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
|
||||
notes = "seed", lines = new[] { new { productId = p1, quantity = 10m, unitCost = 50m } },
|
||||
});
|
||||
enter.EnsureSuccessStatusCode();
|
||||
var eid = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||||
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
|
||||
|
||||
var batch = new PosSaleBatchDto
|
||||
{
|
||||
IdempotencyKey = Guid.NewGuid(),
|
||||
Sales = new[]
|
||||
{
|
||||
MakeSale(p1, qty: 3m, price: 100m),
|
||||
MakeSale(p1, qty: 2m, price: 100m),
|
||||
},
|
||||
};
|
||||
|
||||
using var first = await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch);
|
||||
first.EnsureSuccessStatusCode();
|
||||
var r1 = await first.Content.ReadFromJsonAsync<PosSaleBatchResponse>();
|
||||
r1!.Accepted.Should().HaveCount(2);
|
||||
r1.ReplayedFromCache.Should().BeFalse();
|
||||
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(5m);
|
||||
|
||||
using var second = await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch);
|
||||
second.EnsureSuccessStatusCode();
|
||||
var r2 = await second.Content.ReadFromJsonAsync<PosSaleBatchResponse>();
|
||||
r2!.Accepted.Should().HaveCount(2);
|
||||
r2.ReplayedFromCache.Should().BeTrue();
|
||||
// ServerSaleId должны совпасть — это тот же чек, что и в первом ответе.
|
||||
r2.Accepted.Select(a => a.ServerSaleId).Should().BeEquivalentTo(r1.Accepted.Select(a => a.ServerSaleId));
|
||||
|
||||
// Stock не списался дважды.
|
||||
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(5m);
|
||||
}
|
||||
|
||||
/// <summary>Per-sale ClientSaleId идемпотентность: если ту же продажу
|
||||
/// прислали в РАЗНЫХ батчах (с разными idempotency-key'ями), сервер всё
|
||||
/// равно вернёт ссылку на ранее созданный чек, не создавая дубль.</summary>
|
||||
[Fact]
|
||||
public async Task ClientSaleId_idempotency_across_batches()
|
||||
{
|
||||
var api = new ApiActor(_factory.CreateClient());
|
||||
await api.SignupAndLoginAsync($"pos-csid-{Guid.NewGuid():N}");
|
||||
var refs = await api.LoadRefsAsync();
|
||||
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||||
var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
|
||||
{
|
||||
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
|
||||
notes = "seed", lines = new[] { new { productId = p1, quantity = 10m, unitCost = 50m } },
|
||||
});
|
||||
enter.EnsureSuccessStatusCode();
|
||||
var eid = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||||
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
|
||||
|
||||
var sharedSale = MakeSale(p1, qty: 4m, price: 100m);
|
||||
var batch1 = new PosSaleBatchDto { IdempotencyKey = Guid.NewGuid(), Sales = new[] { sharedSale } };
|
||||
var batch2 = new PosSaleBatchDto { IdempotencyKey = Guid.NewGuid(), Sales = new[] { sharedSale } };
|
||||
|
||||
var r1 = await (await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch1)).Content.ReadFromJsonAsync<PosSaleBatchResponse>();
|
||||
var r2 = await (await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch2)).Content.ReadFromJsonAsync<PosSaleBatchResponse>();
|
||||
r1!.Accepted.Should().HaveCount(1);
|
||||
r2!.Accepted.Should().HaveCount(1);
|
||||
r2.Accepted[0].ServerSaleId.Should().Be(r1.Accepted[0].ServerSaleId);
|
||||
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(6m, "товар списался 1 раз");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Insufficient_stock_goes_to_failed_not_accepted()
|
||||
{
|
||||
var api = new ApiActor(_factory.CreateClient());
|
||||
await api.SignupAndLoginAsync($"pos-short-{Guid.NewGuid():N}");
|
||||
var refs = await api.LoadRefsAsync();
|
||||
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||||
// Не подкармливаем — остаток 0.
|
||||
|
||||
var batch = new PosSaleBatchDto
|
||||
{
|
||||
IdempotencyKey = Guid.NewGuid(),
|
||||
Sales = new[] { MakeSale(p1, qty: 3m, price: 100m) },
|
||||
};
|
||||
using var post = await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch);
|
||||
post.EnsureSuccessStatusCode();
|
||||
var resp = await post.Content.ReadFromJsonAsync<PosSaleBatchResponse>();
|
||||
resp!.Accepted.Should().BeEmpty();
|
||||
resp.Failed.Should().HaveCount(1);
|
||||
resp.Failed[0].Error.Should().Contain("Недостаточный остаток");
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task Tenant_isolation_pos_sync()
|
||||
{
|
||||
var a = new ApiActor(_factory.CreateClient());
|
||||
var b = new ApiActor(_factory.CreateClient());
|
||||
await a.SignupAndLoginAsync($"pos-iso-a-{Guid.NewGuid():N}");
|
||||
await b.SignupAndLoginAsync($"pos-iso-b-{Guid.NewGuid():N}");
|
||||
var refsA = await a.LoadRefsAsync();
|
||||
var pA = await a.CreateProductAsync(refsA, $"PA-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||||
|
||||
var syncB = await (await b.Http.GetAsync("/api/pos/v1/sync")).Content.ReadFromJsonAsync<PosSyncResponse>();
|
||||
syncB!.Products.Should().NotContain(p => p.Id.ToString() == pA);
|
||||
}
|
||||
|
||||
private static PosSaleDto MakeSale(string productId, decimal qty, decimal price) => new()
|
||||
{
|
||||
ClientSaleId = Guid.NewGuid(),
|
||||
OccurredAt = DateTime.UtcNow,
|
||||
Payment = 0, PaidCash = qty * price, PaidCard = 0m,
|
||||
Lines = new[]
|
||||
{
|
||||
new PosSaleLineDto
|
||||
{
|
||||
ProductId = Guid.Parse(productId), Quantity = qty,
|
||||
UnitPrice = price, Discount = 0m, VatPercent = 12m,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
Loading…
Reference in a new issue