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:
parent
6752601ed8
commit
606b81b4d9
|
|
@ -48,6 +48,22 @@ public PosController(AppDbContext db, ITenantContext tenant, IStockService stock
|
||||||
_log = log;
|
_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")]
|
[HttpGet("sync")]
|
||||||
public async Task<ActionResult<PosSyncResponse>> Sync(
|
public async Task<ActionResult<PosSyncResponse>> Sync(
|
||||||
[FromQuery] DateTime? since,
|
[FromQuery] DateTime? since,
|
||||||
|
|
@ -167,7 +183,9 @@ public PosController(AppDbContext db, ITenantContext tenant, IStockService stock
|
||||||
return BadRequest(new { error = "Идемпотентный ключ не указан.", field = "idempotencyKey" });
|
return BadRequest(new { error = "Идемпотентный ключ не указан.", field = "idempotencyKey" });
|
||||||
|
|
||||||
var orgId = _tenant.OrganizationId ?? throw new InvalidOperationException("No tenant.");
|
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)
|
if (resolvedStore is null)
|
||||||
return BadRequest(new { error = "Магазин не найден или не принадлежит организации.", field = "storeId" });
|
return BadRequest(new { error = "Магазин не найден или не принадлежит организации.", field = "storeId" });
|
||||||
|
|
||||||
|
|
@ -275,20 +293,32 @@ public PosController(AppDbContext db, ITenantContext tenant, IStockService stock
|
||||||
if (src.Lines.Count == 0)
|
if (src.Lines.Count == 0)
|
||||||
throw new PosSaleRejectedException("Пустой чек.", "lines");
|
throw new PosSaleRejectedException("Пустой чек.", "lines");
|
||||||
|
|
||||||
// Pre-flight на остатки. Сделано до создания RetailSale чтобы зря не
|
// Возврат покупателю добавляет остаток, поэтому pre-flight не нужен; для
|
||||||
// дёргать БД при пустой полке.
|
// продажи проверяем остаток до создания RetailSale, чтобы зря не дёргать БД.
|
||||||
var byProduct = src.Lines.GroupBy(l => l.ProductId)
|
if (!src.IsReturn)
|
||||||
.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);
|
var byProduct = src.Lines.GroupBy(l => l.ProductId)
|
||||||
if (avail < x.Qty)
|
.Select(g => new { ProductId = g.Key, Qty = g.Sum(x => x.Quantity) }).ToList();
|
||||||
throw new PosSaleRejectedException(
|
var productIds = byProduct.Select(x => x.ProductId).ToList();
|
||||||
$"Недостаточный остаток для товара {x.ProductId}.", "productId");
|
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);
|
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,
|
CurrencyId = currencyId,
|
||||||
CustomerId = src.CustomerId,
|
CustomerId = src.CustomerId,
|
||||||
CashierUserId = src.CashierUserId,
|
CashierUserId = src.CashierUserId,
|
||||||
|
RetailPointId = src.RetailPointId,
|
||||||
|
CashShiftId = src.CashShiftId,
|
||||||
|
IsReturn = src.IsReturn,
|
||||||
|
ReferenceSaleId = src.ReferenceSaleId,
|
||||||
Payment = (PaymentMethod)src.Payment,
|
Payment = (PaymentMethod)src.Payment,
|
||||||
PaidCash = R(src.PaidCash),
|
PaidCash = R(src.PaidCash),
|
||||||
PaidCard = R(src.PaidCard),
|
PaidCard = R(src.PaidCard),
|
||||||
|
|
@ -342,15 +376,18 @@ public PosController(AppDbContext db, ITenantContext tenant, IStockService stock
|
||||||
|
|
||||||
_db.RetailSales.Add(sale);
|
_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)
|
foreach (var l in src.Lines)
|
||||||
{
|
{
|
||||||
await _stock.ApplyMovementAsync(new StockMovementDraft(
|
await _stock.ApplyMovementAsync(new StockMovementDraft(
|
||||||
ProductId: l.ProductId,
|
ProductId: l.ProductId,
|
||||||
StoreId: storeId,
|
StoreId: storeId,
|
||||||
Quantity: -l.Quantity,
|
Quantity: src.IsReturn ? l.Quantity : -l.Quantity,
|
||||||
Type: MovementType.RetailSale,
|
Type: movementType,
|
||||||
DocumentType: "retail-sale",
|
DocumentType: documentType,
|
||||||
DocumentId: sale.Id,
|
DocumentId: sale.Id,
|
||||||
DocumentNumber: sale.Number,
|
DocumentNumber: sale.Number,
|
||||||
UnitCost: l.UnitPrice,
|
UnitCost: l.UnitPrice,
|
||||||
|
|
@ -374,13 +411,23 @@ public PosController(AppDbContext db, ITenantContext tenant, IStockService stock
|
||||||
return (sale.Id, sale.Number);
|
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)
|
if (requested is { } id)
|
||||||
{
|
{
|
||||||
var ok = await _db.Stores.AsNoTracking().AnyAsync(s => s.Id == id, ct);
|
var ok = await _db.Stores.AsNoTracking().AnyAsync(s => s.Id == id, ct);
|
||||||
return ok ? id : null;
|
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()
|
var main = await _db.Stores.AsNoTracking()
|
||||||
.Where(s => s.IsMain).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct);
|
.Where(s => s.IsMain).Select(s => (Guid?)s.Id).FirstOrDefaultAsync(ct);
|
||||||
|
|
|
||||||
|
|
@ -35,5 +35,12 @@
|
||||||
"https://admin.food-market.kz"
|
"https://admin.food-market.kz"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"Pos": {
|
||||||
|
"LatestVersion": "0.0.0",
|
||||||
|
"DownloadWindows": "",
|
||||||
|
"DownloadLinux": "",
|
||||||
|
"ReleaseNotes": "",
|
||||||
|
"Mandatory": false
|
||||||
|
},
|
||||||
"AllowedHosts": "*"
|
"AllowedHosts": "*"
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -106,6 +106,18 @@ public record PosSaleDto
|
||||||
public required decimal PaidCard { get; init; }
|
public required decimal PaidCard { get; init; }
|
||||||
public required IReadOnlyList<PosSaleLineDto> Lines { get; init; }
|
public required IReadOnlyList<PosSaleLineDto> Lines { get; init; }
|
||||||
public string? Notes { 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
|
public record PosSaleLineDto
|
||||||
|
|
@ -135,6 +147,16 @@ public record PosSaleBatchDto
|
||||||
/// <see cref="Failed"/> — продажи, которые отклонены (mismatch цен, отсутствие
|
/// <see cref="Failed"/> — продажи, которые отклонены (mismatch цен, отсутствие
|
||||||
/// товара, нехватка остатка для проведения). POS должен показать кассиру и
|
/// товара, нехватка остатка для проведения). POS должен показать кассиру и
|
||||||
/// решить (перепробить с новой ценой, списать как акт расхождения).</summary>
|
/// решить (перепробить с новой ценой, списать как акт расхождения).</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 record PosSaleBatchResponse
|
||||||
{
|
{
|
||||||
public required Guid IdempotencyKey { get; init; }
|
public required Guid IdempotencyKey { get; init; }
|
||||||
|
|
|
||||||
|
|
@ -220,6 +220,90 @@ public async Task Tenant_isolation_pos_sync()
|
||||||
syncB!.Products.Should().NotContain(p => p.Id.ToString() == pA);
|
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()
|
private static PosSaleDto MakeSale(string productId, decimal qty, decimal price) => new()
|
||||||
{
|
{
|
||||||
ClientSaleId = Guid.NewGuid(),
|
ClientSaleId = Guid.NewGuid(),
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue