diff --git a/src/food-market.api/Controllers/Pos/PosController.cs b/src/food-market.api/Controllers/Pos/PosController.cs
index 744843e..e8f4d56 100644
--- a/src/food-market.api/Controllers/Pos/PosController.cs
+++ b/src/food-market.api/Controllers/Pos/PosController.cs
@@ -48,6 +48,22 @@ public PosController(AppDbContext db, ITenantContext tenant, IStockService stock
_log = log;
}
+ /// Информация о последней версии кассы для автообновления и для
+ /// ссылки на скачивание в админке. Значения берутся из конфигурации
+ /// (секция Pos); по умолчанию — пустые/плейсхолдеры. Анонимно —
+ /// проверка версии не требует входа.
+ [HttpGet("version")]
+ [AllowAnonymous]
+ public ActionResult 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> 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 ResolveStoreAsync(Guid? requested, CancellationToken ct)
+ private Task ResolveStoreAsync(Guid? requested, CancellationToken ct)
+ => ResolveStoreAsync(requested, null, ct);
+
+ private async Task 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);
diff --git a/src/food-market.api/appsettings.json b/src/food-market.api/appsettings.json
index cf24a07..cc442ee 100644
--- a/src/food-market.api/appsettings.json
+++ b/src/food-market.api/appsettings.json
@@ -35,5 +35,12 @@
"https://admin.food-market.kz"
]
},
+ "Pos": {
+ "LatestVersion": "0.0.0",
+ "DownloadWindows": "",
+ "DownloadLinux": "",
+ "ReleaseNotes": "",
+ "Mandatory": false
+ },
"AllowedHosts": "*"
}
diff --git a/src/food-market.shared/Pos/V1/SyncDtos.cs b/src/food-market.shared/Pos/V1/SyncDtos.cs
index 9fd9811..5c9c4c2 100644
--- a/src/food-market.shared/Pos/V1/SyncDtos.cs
+++ b/src/food-market.shared/Pos/V1/SyncDtos.cs
@@ -106,6 +106,18 @@ public record PosSaleDto
public required decimal PaidCard { get; init; }
public required IReadOnlyList Lines { get; init; }
public string? Notes { get; init; }
+
+ // --- Привязка к смене/точке (добавлено, обратносовместимо с v1: nullable).
+ /// Точка продаж, к которой привязана касса. Если задана — сервер
+ /// проставит её в чек и возьмёт склад точки по умолчанию.
+ public Guid? RetailPointId { get; init; }
+ /// Открытая смена кассира. Нужна, чтобы чек попал в Z-отчёт и в
+ /// материализацию денег смены (MoneyService агрегирует продажи по CashShiftId).
+ public Guid? CashShiftId { get; init; }
+ /// true — это возврат покупателю (движение +остаток, наличные к выдаче).
+ public bool IsReturn { get; init; }
+ /// Чек-основание для возврата по чеку (если возврат по номеру/QR).
+ public Guid? ReferenceSaleId { get; init; }
}
public record PosSaleLineDto
@@ -135,6 +147,16 @@ public record PosSaleBatchDto
/// — продажи, которые отклонены (mismatch цен, отсутствие
/// товара, нехватка остатка для проведения). POS должен показать кассиру и
/// решить (перепробить с новой ценой, списать как акт расхождения).
+/// Информация о последней версии кассы (автообновление + ссылка в админке).
+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; }
diff --git a/tests/food-market.IntegrationTests/PosSyncTests.cs b/tests/food-market.IntegrationTests/PosSyncTests.cs
index 0941d1d..20dc519 100644
--- a/tests/food-market.IntegrationTests/PosSyncTests.cs
+++ b/tests/food-market.IntegrationTests/PosSyncTests.cs
@@ -220,6 +220,90 @@ public async Task Tenant_isolation_pos_sync()
syncB!.Products.Should().NotContain(p => p.Id.ToString() == pA);
}
+ /// Чек с кассы, пробитый в открытую смену, должен попасть в Z-отчёт
+ /// смены (MoneyService агрегирует продажи по CashShiftId) — именно эту
+ /// привязку касса теперь шлёт в RetailPointId/CashShiftId.
+ [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())!.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();
+ body.GetProperty("expectedCash").GetDecimal().Should().Be(6_000m, "продажа кассы учтена в смене");
+ body.GetProperty("difference").GetDecimal().Should().Be(0m);
+ }
+
+ /// Возврат покупателю (IsReturn) возвращает товар на склад (+остаток),
+ /// а не списывает его.
+ [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())!.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()).GetProperty("id").GetString();
+ (await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
+ }
+
+ private static async Task 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(),