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;
|
||||
}
|
||||
|
||||
/// <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);
|
||||
|
|
|
|||
|
|
@ -35,5 +35,12 @@
|
|||
"https://admin.food-market.kz"
|
||||
]
|
||||
},
|
||||
"Pos": {
|
||||
"LatestVersion": "0.0.0",
|
||||
"DownloadWindows": "",
|
||||
"DownloadLinux": "",
|
||||
"ReleaseNotes": "",
|
||||
"Mandatory": false
|
||||
},
|
||||
"AllowedHosts": "*"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
Loading…
Reference in a new issue