food-market/src/food-market.api/Controllers/Pos/PosController.cs
nns 9ba1edb236
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
feat(stock): разрешение продажи в минус + предупреждения об отрицательных остатках
Решение владельца 08.10: продавать/списывать в минус РАЗРЕШЕНО, но с постоянным
предупреждением и требованием провести инвентаризацию.

- Снята блокировка «недостаточно остатка» (409 oversell): розница, касса (POS-синк),
  опт-отгрузка, списание, перемещение, возврат поставщику, отмена приёмки. Деньги
  (минус счёта) — по-прежнему запрещены (другое правило).
- NegativeStockAlert (сущность + миграция Phase39): создаётся/обновляется при уходе
  остатка < 0 (единая точка — StockService), авто-закрытие при возврате ≥ 0. Частичный
  уникальный индекс: один открытый алерт на (org, store, product). Причина: тип/номер
  документа/автор/время.
- API /api/negative-stock: список (с подсказкой «возможно, не проведена приёмка П-…»),
  /count (для баннеров), /recount (черновик инвентаризации по минусам склада). Отчёт
  «Остатки» — фильтр negativeOnly.
- Веб: постоянный баннер «N товаров в минусе — нужна инвентаризация» (сворачивается,
  крестиком не закрыть) + «Пересчитать эти товары»; в остатках — красная пометка и
  фильтр «Только минусы».
- Тесты: integration 2/2 (минус разрешён → алерт → recount → авто-закрытие; фильтр);
  full-chain шаг 9/13 переписаны (oversell/отмена приёмки → проведено + предупреждение).

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-09 02:18:29 +05:00

522 lines
26 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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&amp;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;
}
/// <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,
});
/// <summary>Чек-основание для возврата: поиск проведённого (не-возвратного)
/// чека по номеру/QR + позиции с количеством, доступным к возврату
/// (продано − уже возвращено). Для экрана возврата на кассе.</summary>
[HttpGet("sales/by-number/{number}")]
public async Task<ActionResult<PosReturnLookupDto>> SaleByNumber(string number, CancellationToken ct)
{
var n = (number ?? "").Trim();
if (n.Length == 0) return NotFound();
var sale = await _db.RetailSales.AsNoTracking()
.Where(s => s.Number == n && !s.IsReturn && s.Status == RetailSaleStatus.Posted)
.Select(s => new { s.Id, s.Number, s.Date, s.Payment })
.FirstOrDefaultAsync(ct);
if (sale is null)
return NotFound(new { error = "Чек не найден или не проведён.", field = "number" });
var lines = await (from l in _db.RetailSaleLines.AsNoTracking()
join p in _db.Products.AsNoTracking() on l.ProductId equals p.Id
join u in _db.UnitsOfMeasure.AsNoTracking() on p.UnitOfMeasureId equals u.Id
where l.RetailSaleId == sale.Id
orderby l.SortOrder
select new PosReturnLineDto
{
ProductId = l.ProductId, Name = p.Name, UnitCode = u.Code,
UnitPrice = l.UnitPrice, SoldQty = l.Quantity,
ReturnedQty = l.QtyReturned, ReturnableQty = l.Quantity - l.QtyReturned,
}).ToListAsync(ct);
return Ok(new PosReturnLookupDto
{
SaleId = sale.Id, Number = sale.Number, Date = sale.Date,
Payment = (int)sale.Payment, Lines = lines,
});
}
[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,
IsWeighed = x.p.Packaging == Domain.Catalog.Packaging.Weight,
VatPercent = x.p.Vat,
VatEnabled = x.p.VatEnabled,
IsMarked = x.p.IsMarked,
// Product не имеет IsArchived в текущей доменной модели; всегда false.
// Когда добавим архив — заполняется здесь, без правки контракта.
IsArchived = false,
IsFavorite = x.p.IsFavorite,
FavoriteOrder = x.p.FavoriteOrder,
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.");
// Склад: если у продаж указана точка продаж — берём её склад; иначе 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" });
// 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");
// Продажа в минус РАЗРЕШЕНА (решение владельца 08.10) — pre-flight по остатку
// убран; минус отражается предупреждениями (NegativeStockAlert). Для возврата
// по чеку оставляем защиту от over-return (нельзя вернуть больше, чем продано).
if (src.IsReturn && src.ReferenceSaleId is Guid refIdCheck)
{
// Возврат по чеку: нельзя вернуть больше, чем продано минус уже
// возвращено (QtyReturned на исходных строках). Защита от over-return.
var refLines = await _db.RetailSaleLines.AsNoTracking()
.Where(l => l.RetailSaleId == refIdCheck).ToListAsync(ct);
if (refLines.Count == 0)
throw new PosSaleRejectedException("Исходный чек не найден.", "referenceSaleId");
var remainingByProduct = refLines.GroupBy(l => l.ProductId)
.ToDictionary(g => g.Key, g => g.Sum(x => x.Quantity) - g.Sum(x => x.QtyReturned));
foreach (var g in src.Lines.GroupBy(l => l.ProductId))
{
if (!remainingByProduct.TryGetValue(g.Key, out var remaining))
throw new PosSaleRejectedException($"Товар {g.Key} не из исходного чека.", "productId");
if (g.Sum(x => x.Quantity) > remaining)
throw new PosSaleRejectedException(
$"Нельзя вернуть больше, чем доступно (к возврату {remaining}).", "quantity");
}
}
// Привязка к смене: если указана — проверяем, что она открыта и наша.
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);
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,
RetailPointId = src.RetailPointId,
CashShiftId = src.CashShiftId,
IsReturn = src.IsReturn,
ReferenceSaleId = src.ReferenceSaleId,
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, 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: src.IsReturn ? l.Quantity : -l.Quantity,
Type: movementType,
DocumentType: documentType,
DocumentId: sale.Id,
DocumentNumber: sale.Number,
UnitCost: l.UnitPrice,
OccurredAt: src.OccurredAt), ct);
}
// Возврат по чеку: инкрементим QtyReturned на исходных строках (защита
// от повторного возврата той же позиции). Насыщаем по строкам продукта.
if (src.IsReturn && src.ReferenceSaleId is Guid refIdInc)
{
var refLines = await _db.RetailSaleLines
.Where(l => l.RetailSaleId == refIdInc).ToListAsync(ct);
var takenByProduct = src.Lines.GroupBy(x => x.ProductId)
.ToDictionary(g => g.Key, g => g.Sum(x => x.Quantity));
foreach (var rl in refLines.OrderBy(l => l.SortOrder))
{
if (!takenByProduct.TryGetValue(rl.ProductId, out var taken) || taken <= 0) continue;
var take = Math.Min(taken, rl.Quantity - rl.QtyReturned);
if (take <= 0) continue;
rl.QtyReturned += take;
takenByProduct[rl.ProductId] = taken - take;
}
}
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 попробует в следующем батче.
foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementError("retail-sale", "number_conflict");
throw new PosSaleRejectedException("Конфликт номера чека, повторите.", "number");
}
foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementPosted("retail-sale");
return (sale.Id, sale.Number);
}
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);
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;
}