feat(pos-api): привязка чеков кассы к смене/точке + возвраты + версия

- PosSaleDto: CashShiftId/RetailPointId/IsReturn/ReferenceSaleId (nullable,
  обратносовместимо с v1). PosController проставляет их в RetailSale, резолвит
  склад по точке продаж, проверяет что смена открыта. Без этого чеки кассы не
  попадали в Z-отчёт/материализацию денег смены (MoneyService агрегирует продажи
  по CashShiftId).
- Возврат покупателю (IsReturn): движение +остаток (CustomerReturn), без
  pre-flight на остаток; наличные к выдаче, учитывается в смене.
- GET /api/pos/v1/version (anon): версия + ссылки на сборки из конфигурации Pos
  (автообновление кассы + ссылка в админке).
- Интеграционные тесты: продажа в смене попадает в expected cash при закрытии;
  возврат возвращает остаток (9/9 PosSyncTests зелёные, Testcontainers).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-08 04:58:05 +05:00
parent 6752601ed8
commit 606b81b4d9
4 changed files with 179 additions and 19 deletions

View file

@ -48,6 +48,22 @@ public PosController(AppDbContext db, ITenantContext tenant, IStockService stock
_log = log;
}
/// <summary>Информация о последней версии кассы для автообновления и для
/// ссылки на скачивание в админке. Значения берутся из конфигурации
/// (секция <c>Pos</c>); по умолчанию — пустые/плейсхолдеры. Анонимно —
/// проверка версии не требует входа.</summary>
[HttpGet("version")]
[AllowAnonymous]
public ActionResult<PosVersionDto> Version([FromServices] IConfiguration cfg)
=> Ok(new PosVersionDto
{
Version = cfg["Pos:LatestVersion"] ?? "0.0.0",
WindowsUrl = cfg["Pos:DownloadWindows"],
LinuxUrl = cfg["Pos:DownloadLinux"],
Notes = cfg["Pos:ReleaseNotes"],
Mandatory = bool.TryParse(cfg["Pos:Mandatory"], out var m) && m,
});
[HttpGet("sync")]
public async Task<ActionResult<PosSyncResponse>> Sync(
[FromQuery] DateTime? since,
@ -167,7 +183,9 @@ public PosController(AppDbContext db, ITenantContext tenant, IStockService stock
return BadRequest(new { error = "Идемпотентный ключ не указан.", field = "idempotencyKey" });
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
var resolvedStore = await ResolveStoreAsync(storeId, ct);
// Склад: если у продаж указана точка продаж — берём её склад; иначе storeId/главный.
var pointFromSales = batch.Sales.Select(s => s.RetailPointId).FirstOrDefault(p => p is not null);
var resolvedStore = await ResolveStoreAsync(storeId, pointFromSales, ct);
if (resolvedStore is null)
return BadRequest(new { error = "Магазин не найден или не принадлежит организации.", field = "storeId" });
@ -275,20 +293,32 @@ public PosController(AppDbContext db, ITenantContext tenant, IStockService stock
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)
// Возврат покупателю добавляет остаток, поэтому pre-flight не нужен; для
// продажи проверяем остаток до создания RetailSale, чтобы зря не дёргать БД.
if (!src.IsReturn)
{
stocks.TryGetValue(x.ProductId, out var avail);
if (avail < x.Qty)
throw new PosSaleRejectedException(
$"Недостаточный остаток для товара {x.ProductId}.", "productId");
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");
}
}
// Привязка к смене: если указана — проверяем, что она открыта и наша.
if (src.CashShiftId is Guid shiftId)
{
var shiftOk = await _db.CashShifts.AsNoTracking()
.AnyAsync(s => s.Id == shiftId && s.Status == Domain.Money.CashShiftStatus.Open, ct);
if (!shiftOk)
throw new PosSaleRejectedException("Смена не найдена или закрыта.", "cashShiftId");
}
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
@ -309,6 +339,10 @@ public PosController(AppDbContext db, ITenantContext tenant, IStockService stock
CurrencyId = currencyId,
CustomerId = src.CustomerId,
CashierUserId = src.CashierUserId,
RetailPointId = src.RetailPointId,
CashShiftId = src.CashShiftId,
IsReturn = src.IsReturn,
ReferenceSaleId = src.ReferenceSaleId,
Payment = (PaymentMethod)src.Payment,
PaidCash = R(src.PaidCash),
PaidCard = R(src.PaidCard),
@ -342,15 +376,18 @@ public PosController(AppDbContext db, ITenantContext tenant, IStockService stock
_db.RetailSales.Add(sale);
// Проведение: -Quantity StockMovement RetailSale.
// Проведение: продажа списывает (-Quantity, RetailSale), возврат
// покупателя возвращает товар на склад (+Quantity, CustomerReturn).
var movementType = src.IsReturn ? MovementType.CustomerReturn : MovementType.RetailSale;
var documentType = src.IsReturn ? "retail-return" : "retail-sale";
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",
Quantity: src.IsReturn ? l.Quantity : -l.Quantity,
Type: movementType,
DocumentType: documentType,
DocumentId: sale.Id,
DocumentNumber: sale.Number,
UnitCost: l.UnitPrice,
@ -374,13 +411,23 @@ public PosController(AppDbContext db, ITenantContext tenant, IStockService stock
return (sale.Id, sale.Number);
}
private async Task<Guid?> ResolveStoreAsync(Guid? requested, CancellationToken ct)
private Task<Guid?> ResolveStoreAsync(Guid? requested, CancellationToken ct)
=> ResolveStoreAsync(requested, null, ct);
private async Task<Guid?> ResolveStoreAsync(Guid? requested, Guid? retailPointId, CancellationToken ct)
{
if (requested is { } id)
{
var ok = await _db.Stores.AsNoTracking().AnyAsync(s => s.Id == id, ct);
return ok ? id : null;
}
// Если касса привязана к точке продаж — берём её склад.
if (retailPointId is { } rp)
{
var store = await _db.RetailPoints.AsNoTracking()
.Where(r => r.Id == rp).Select(r => (Guid?)r.StoreId).FirstOrDefaultAsync(ct);
if (store is not null) return store;
}
// Дефолт — главный склад организации.
var main = await _db.Stores.AsNoTracking()
.Where(s => s.IsMain).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct);

View file

@ -35,5 +35,12 @@
"https://admin.food-market.kz"
]
},
"Pos": {
"LatestVersion": "0.0.0",
"DownloadWindows": "",
"DownloadLinux": "",
"ReleaseNotes": "",
"Mandatory": false
},
"AllowedHosts": "*"
}

View file

@ -106,6 +106,18 @@ public record PosSaleDto
public required decimal PaidCard { get; init; }
public required IReadOnlyList<PosSaleLineDto> Lines { get; init; }
public string? Notes { get; init; }
// --- Привязка к смене/точке (добавлено, обратносовместимо с v1: nullable).
/// <summary>Точка продаж, к которой привязана касса. Если задана — сервер
/// проставит её в чек и возьмёт склад точки по умолчанию.</summary>
public Guid? RetailPointId { get; init; }
/// <summary>Открытая смена кассира. Нужна, чтобы чек попал в Z-отчёт и в
/// материализацию денег смены (MoneyService агрегирует продажи по CashShiftId).</summary>
public Guid? CashShiftId { get; init; }
/// <summary>true — это возврат покупателю (движение +остаток, наличные к выдаче).</summary>
public bool IsReturn { get; init; }
/// <summary>Чек-основание для возврата по чеку (если возврат по номеру/QR).</summary>
public Guid? ReferenceSaleId { get; init; }
}
public record PosSaleLineDto
@ -135,6 +147,16 @@ public record PosSaleBatchDto
/// <see cref="Failed"/> — продажи, которые отклонены (mismatch цен, отсутствие
/// товара, нехватка остатка для проведения). POS должен показать кассиру и
/// решить (перепробить с новой ценой, списать как акт расхождения).</summary>
/// <summary>Информация о последней версии кассы (автообновление + ссылка в админке).</summary>
public record PosVersionDto
{
public required string Version { get; init; }
public string? WindowsUrl { get; init; }
public string? LinuxUrl { get; init; }
public string? Notes { get; init; }
public bool Mandatory { get; init; }
}
public record PosSaleBatchResponse
{
public required Guid IdempotencyKey { get; init; }

View file

@ -220,6 +220,90 @@ public async Task Tenant_isolation_pos_sync()
syncB!.Products.Should().NotContain(p => p.Id.ToString() == pA);
}
/// <summary>Чек с кассы, пробитый в открытую смену, должен попасть в Z-отчёт
/// смены (MoneyService агрегирует продажи по CashShiftId) — именно эту
/// привязку касса теперь шлёт в RetailPointId/CashShiftId.</summary>
[Fact]
public async Task Pos_sale_in_shift_counts_in_close_expected_cash()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"pos-shift-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 500m, RandomBarcode());
await SeedStockAsync(api, refs, p1, 10m);
var retailPoint = await api.CreateRetailPointAsync(refs.StoreId, $"Точка-{Guid.NewGuid():N}");
var cash = await api.CreateMoneyAccountAsync("Касса точки", 1, refs.CurrencyId, refs.StoreId, 0m);
var shiftId = await OpenShiftAsync(api, retailPoint, cash, openingFloat: 5_000m);
// Касса пробивает чек на 2×500 наличными, привязывая к смене и точке.
var batch = new PosSaleBatchDto
{
IdempotencyKey = Guid.NewGuid(),
Sales = new[] { MakeSale(p1, qty: 2m, price: 500m) with
{
RetailPointId = Guid.Parse(retailPoint), CashShiftId = Guid.Parse(shiftId),
} },
};
using var post = await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch);
post.IsSuccessStatusCode.Should().BeTrue($"{(int)post.StatusCode}: {await post.Content.ReadAsStringAsync()}");
(await post.Content.ReadFromJsonAsync<PosSaleBatchResponse>())!.Accepted.Should().HaveCount(1);
// Закрываем смену: expected = 5 000 (размен) + 1 000 (продажа нал) = 6 000.
using var close = await api.Http.PostAsJsonAsync($"/api/money/shifts/{shiftId}/close", new { actualCash = 6_000m, notes = (string?)null });
close.EnsureSuccessStatusCode();
var body = await close.Content.ReadFromJsonAsync<JsonElement>();
body.GetProperty("expectedCash").GetDecimal().Should().Be(6_000m, "продажа кассы учтена в смене");
body.GetProperty("difference").GetDecimal().Should().Be(0m);
}
/// <summary>Возврат покупателю (IsReturn) возвращает товар на склад (+остаток),
/// а не списывает его.</summary>
[Fact]
public async Task Pos_return_adds_stock_back()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"pos-ret-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 300m, RandomBarcode());
await SeedStockAsync(api, refs, p1, 10m);
var batch = new PosSaleBatchDto
{
IdempotencyKey = Guid.NewGuid(),
Sales = new[] { MakeSale(p1, qty: 3m, price: 300m) with { IsReturn = true } },
};
using var post = await api.Http.PostAsJsonAsync("/api/pos/v1/sales", batch);
post.IsSuccessStatusCode.Should().BeTrue($"{(int)post.StatusCode}: {await post.Content.ReadAsStringAsync()}");
(await post.Content.ReadFromJsonAsync<PosSaleBatchResponse>())!.Accepted.Should().HaveCount(1);
// Был 10, возврат 3 → 13 (возврат добавил остаток).
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(13m);
}
private static async Task SeedStockAsync(ApiActor api, ApiActor.Refs refs, string productId, decimal qty)
{
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, quantity = qty, 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();
}
private static async Task<string> OpenShiftAsync(ApiActor api, string retailPointId, string cashAccountId, decimal openingFloat)
{
var resp = await api.Http.PostAsJsonAsync("/api/money/shifts/open", new
{
retailPointId, cashAccountId, acquiringAccountId = (string?)null, openingFloat,
});
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode) throw new InvalidOperationException($"OpenShift failed {(int)resp.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.GetProperty("id").GetString()!;
}
private static PosSaleDto MakeSale(string productId, decimal qty, decimal price) => new()
{
ClientSaleId = Guid.NewGuid(),