Sprint 11 — каркас для интеграции с операторами фискальных данных РК.
Реальные ApiKey'и появятся у user'а позже; задача — построить такой
фрейм, чтобы подключение оператора сводилось к вписыванию кредов в UI
без правок кода/деплоя.
Что сделано:
- IFiscalProvider (Application/Common/Fiscal) + FiscalResult,
FiscalProviderKind (None/Mock/Webkassa/Kassa24/OfdSolo),
IFiscalProviderFactory, FiscalNotConfiguredException.
- 4 реализации в Infrastructure/Fiscal:
• MockFiscalProvider — фейк MOCK-<8hex> через 300мс, идемпотентный
по Sale.Id (используется dev/stage и интеграционными тестами);
• WebkassaProvider — полный HTTP-pipeline Authorize→Check, парсинг
JSON-ответа, NDS-в-ставке, retry-safe через ExternalCheckNumber;
• Kassa24Provider / OfdSoloProvider — скелет с тем же контрактом,
RegisterAsync бросает FiscalNotConfiguredException (нужны
спецификации API от user'а, NDA-only).
- Миграция Phase11a: 5 колонок в retail_sales (FiscalNumber, QrCode,
Url, ProviderTxId, ProviderKind) + 5 в organizations (FiscalProvider
NOT NULL default 0, ApiKey/Secret encrypted, CashboxUniqueNumber,
ApiBaseUrl). Default 0 = обратная совместимость, существующие чеки
и продажи без фискализации работают как раньше.
- RetailSalesController.Post — TryFiscalizeAsync после commit'а
stock-транзакции. Best-effort: сетевые/HTTP-ошибки логируются, чек
остаётся проведённым. Идемпотентность по IsNullOrEmpty(FiscalNumber).
- OrgFiscalSettingsController: GET/PUT настройки + GET /providers
(опции для select'а) + POST /test-send (фейк-чек к выбранному
провайдеру, не сохраняет в БД).
- UI: FiscalSection в OrganizationSettingsPage с password-input'ами
для ApiKey/Secret (шифруются DataProtection.purpose=foodmarket.fiscal,
в GET — только has-* флаги), спец-значение "__clear__" для снятия,
кнопка «Тестовая отправка».
- Тесты: 11 unit (Mock 5 + Webkassa payload 6) + 3 integration
(Mock сохраняет FiscalNumber, test-send даёт MOCK-номер, None
не фискализует).
- docs/ofd-integration.md — гид с архитектурой, шагами подключения
Webkassa (полный pap), TODO для Касса24/ОФД-Соло, безопасностью
кредов, retry-сценариями.
Все 68 unit + 8 integration в Fiscal/Loyalty/RetailOversell — зелёные.
Web vite build — зелёный.
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
1080 lines
55 KiB
C#
1080 lines
55 KiB
C#
using System.ComponentModel.DataAnnotations;
|
||
using foodmarket.Application.Common;
|
||
using foodmarket.Application.Inventory;
|
||
using foodmarket.Domain.Inventory;
|
||
using foodmarket.Domain.Sales;
|
||
using foodmarket.Infrastructure.Persistence;
|
||
using foodmarket.Api.Infrastructure.Authorization;
|
||
using Microsoft.AspNetCore.Authorization;
|
||
using Microsoft.AspNetCore.Mvc;
|
||
using Microsoft.EntityFrameworkCore;
|
||
|
||
namespace foodmarket.Api.Controllers.Sales;
|
||
|
||
[ApiController]
|
||
[Authorize]
|
||
[Route("api/sales/retail")]
|
||
public class RetailSalesController : ControllerBase
|
||
{
|
||
private readonly AppDbContext _db;
|
||
private readonly IStockService _stock;
|
||
private readonly ILogger<RetailSalesController> _log;
|
||
private readonly foodmarket.Api.Realtime.INotificationsPublisher _notify;
|
||
private readonly foodmarket.Application.Common.Fiscal.IFiscalProviderFactory _fiscal;
|
||
|
||
public RetailSalesController(AppDbContext db, IStockService stock, ILogger<RetailSalesController> log,
|
||
foodmarket.Api.Realtime.INotificationsPublisher notify,
|
||
foodmarket.Application.Common.Fiscal.IFiscalProviderFactory fiscal)
|
||
{
|
||
_db = db;
|
||
_stock = stock;
|
||
_log = log;
|
||
_notify = notify;
|
||
_fiscal = fiscal;
|
||
}
|
||
|
||
public record RetailSaleListRow(
|
||
Guid Id, string Number, DateTime Date, RetailSaleStatus Status,
|
||
Guid StoreId, string StoreName,
|
||
Guid? RetailPointId, string? RetailPointName,
|
||
Guid? CustomerId, string? CustomerName,
|
||
Guid CurrencyId, string CurrencyCode,
|
||
decimal Total, PaymentMethod Payment, int LineCount,
|
||
DateTime? PostedAt,
|
||
bool IsReturn, Guid? ReferenceSaleId, string? ReferenceSaleNumber);
|
||
|
||
public record RetailSaleLineDto(
|
||
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, string? UnitSymbol,
|
||
decimal Quantity, decimal UnitPrice, decimal Discount, decimal LineTotal, decimal VatPercent, int SortOrder,
|
||
decimal QtyReturned);
|
||
|
||
public record RetailSaleDto(
|
||
Guid Id, string Number, DateTime Date, RetailSaleStatus Status,
|
||
Guid StoreId, string StoreName,
|
||
Guid? RetailPointId, string? RetailPointName,
|
||
Guid? CustomerId, string? CustomerName,
|
||
Guid CurrencyId, string CurrencyCode,
|
||
decimal Subtotal, decimal DiscountTotal, decimal Total,
|
||
PaymentMethod Payment, decimal PaidCash, decimal PaidCard,
|
||
string? Notes, DateTime? PostedAt,
|
||
bool IsReturn, Guid? ReferenceSaleId, string? ReferenceSaleNumber,
|
||
IReadOnlyList<RetailSaleLineDto> Lines,
|
||
// Sprint 9: лояльность + промокод. Все поля snapshot'ы (промокод/карта
|
||
// могут быть удалены или переименованы — чек хранит то что было).
|
||
Guid? LoyaltyCardId = null,
|
||
decimal LoyaltyBonusApplied = 0m,
|
||
decimal LoyaltyPointsAccrued = 0m,
|
||
Guid? PromotionId = null,
|
||
string? PromotionCode = null,
|
||
decimal PromotionDiscount = 0m,
|
||
// Sprint 11: ОФД-снапшоты. Null до фискализации / при провайдере None.
|
||
string? FiscalNumber = null,
|
||
string? FiscalQrCode = null,
|
||
string? FiscalUrl = null,
|
||
int? FiscalProviderKind = null);
|
||
|
||
public record RetailSaleLineInput(
|
||
Guid ProductId,
|
||
[Range(0, 1e10)] decimal Quantity,
|
||
[Range(0, 1e10)] decimal UnitPrice,
|
||
[Range(0, 1e10)] decimal Discount,
|
||
[Range(0, 100)] decimal VatPercent);
|
||
public record RetailSaleInput(
|
||
DateTime Date, Guid StoreId, Guid? RetailPointId, Guid? CustomerId, Guid CurrencyId,
|
||
PaymentMethod Payment,
|
||
[Range(0, 1e10)] decimal PaidCash,
|
||
[Range(0, 1e10)] decimal PaidCard,
|
||
string? Notes,
|
||
IReadOnlyList<RetailSaleLineInput> Lines,
|
||
bool IsReturn = false,
|
||
Guid? ReferenceSaleId = null,
|
||
/// <summary>Sprint 9: номер карты лояльности. Если задан — сервер
|
||
/// сматчит на active-карту, применит программу (скидка %, фикс или
|
||
/// начисление баллов), запишет snapshot в LoyaltyCardId / LoyaltyBonusApplied /
|
||
/// LoyaltyPointsAccrued.</summary>
|
||
string? LoyaltyCardNumber = null,
|
||
/// <summary>Sprint 9: код промокода. Если задан — сервер найдёт active-
|
||
/// promotion с этим кодом, проверит период/MinSaleAmount/Scope и
|
||
/// применит скидку (PromotionDiscount/PromotionId/PromotionCode).</summary>
|
||
string? PromotionCode = null);
|
||
|
||
public record SalesStatsBucket(DateTime Bucket, decimal Revenue, int Transactions);
|
||
|
||
public record SalesStatsResponse(
|
||
decimal RevenueToday,
|
||
decimal RevenueThisMonth,
|
||
decimal RevenuePrevMonth,
|
||
int TransactionsToday,
|
||
int TransactionsThisMonth,
|
||
decimal AvgTicketThisMonth,
|
||
IReadOnlyList<SalesStatsBucket> Series,
|
||
decimal RevenueThisWeek,
|
||
int TransactionsThisWeek);
|
||
|
||
/// <summary>Aggregated sales metrics + daily series for the dashboard.
|
||
/// Series buckets are days; defaults to last 30 days.</summary>
|
||
[HttpGet("stats")]
|
||
public async Task<ActionResult<SalesStatsResponse>> Stats(
|
||
[FromQuery] int days = 30,
|
||
CancellationToken ct = default)
|
||
{
|
||
var nowUtc = DateTime.UtcNow;
|
||
var todayStart = new DateTime(nowUtc.Year, nowUtc.Month, nowUtc.Day, 0, 0, 0, DateTimeKind.Utc);
|
||
var monthStart = new DateTime(nowUtc.Year, nowUtc.Month, 1, 0, 0, 0, DateTimeKind.Utc);
|
||
var prevMonthStart = monthStart.AddMonths(-1);
|
||
var seriesStart = todayStart.AddDays(-(days - 1));
|
||
// Неделя: начиная с понедельника текущей UTC-недели. Без сложной локали —
|
||
// на дашборде это «скользящие 7 дней» в первом приближении.
|
||
var weekStart = todayStart.AddDays(-(((int)todayStart.DayOfWeek + 6) % 7));
|
||
|
||
var posted = _db.RetailSales.AsNoTracking().Where(s => s.Status == RetailSaleStatus.Posted);
|
||
|
||
var today = await posted.Where(s => s.Date >= todayStart && s.Date < todayStart.AddDays(1))
|
||
.GroupBy(_ => 1)
|
||
.Select(g => new { Sum = g.Sum(s => s.Total), Count = g.Count() })
|
||
.FirstOrDefaultAsync(ct);
|
||
|
||
var thisMonth = await posted.Where(s => s.Date >= monthStart)
|
||
.GroupBy(_ => 1)
|
||
.Select(g => new { Sum = g.Sum(s => s.Total), Count = g.Count() })
|
||
.FirstOrDefaultAsync(ct);
|
||
|
||
var thisWeek = await posted.Where(s => s.Date >= weekStart)
|
||
.GroupBy(_ => 1)
|
||
.Select(g => new { Sum = g.Sum(s => s.Total), Count = g.Count() })
|
||
.FirstOrDefaultAsync(ct);
|
||
|
||
var prevMonth = await posted.Where(s => s.Date >= prevMonthStart && s.Date < monthStart)
|
||
.GroupBy(_ => 1)
|
||
.Select(g => new { Sum = g.Sum(s => s.Total) })
|
||
.FirstOrDefaultAsync(ct);
|
||
|
||
var rawSeries = await posted.Where(s => s.Date >= seriesStart)
|
||
.GroupBy(s => s.Date.Date)
|
||
.Select(g => new { Day = g.Key, Revenue = g.Sum(s => s.Total), Tx = g.Count() })
|
||
.ToListAsync(ct);
|
||
|
||
// Fill missing days with zeros so the chart line is continuous.
|
||
var byDay = rawSeries.ToDictionary(x => x.Day, x => x);
|
||
var series = Enumerable.Range(0, days)
|
||
.Select(i => seriesStart.AddDays(i).Date)
|
||
.Select(d => byDay.TryGetValue(d, out var v)
|
||
? new SalesStatsBucket(d, v.Revenue, v.Tx)
|
||
: new SalesStatsBucket(d, 0m, 0))
|
||
.ToList();
|
||
|
||
var thisMonthSum = thisMonth?.Sum ?? 0m;
|
||
var thisMonthCount = thisMonth?.Count ?? 0;
|
||
var avgTicket = thisMonthCount == 0 ? 0m : thisMonthSum / thisMonthCount;
|
||
|
||
return new SalesStatsResponse(
|
||
RevenueToday: today?.Sum ?? 0m,
|
||
RevenueThisMonth: thisMonthSum,
|
||
RevenuePrevMonth: prevMonth?.Sum ?? 0m,
|
||
TransactionsToday: today?.Count ?? 0,
|
||
TransactionsThisMonth: thisMonthCount,
|
||
AvgTicketThisMonth: avgTicket,
|
||
Series: series,
|
||
RevenueThisWeek: thisWeek?.Sum ?? 0m,
|
||
TransactionsThisWeek: thisWeek?.Count ?? 0);
|
||
}
|
||
|
||
[HttpGet]
|
||
public async Task<ActionResult<PagedResult<RetailSaleListRow>>> List(
|
||
[FromQuery] PagedRequest req,
|
||
[FromQuery] RetailSaleStatus? status,
|
||
[FromQuery] Guid? storeId,
|
||
[FromQuery] DateTime? from,
|
||
[FromQuery] DateTime? to,
|
||
CancellationToken ct)
|
||
{
|
||
var q = from s in _db.RetailSales.AsNoTracking()
|
||
join st in _db.Stores on s.StoreId equals st.Id
|
||
join cu in _db.Currencies on s.CurrencyId equals cu.Id
|
||
select new { s, st, cu };
|
||
|
||
if (status is not null) q = q.Where(x => x.s.Status == status);
|
||
if (storeId is not null) q = q.Where(x => x.s.StoreId == storeId);
|
||
if (from is not null) q = q.Where(x => x.s.Date >= from);
|
||
if (to is not null) q = q.Where(x => x.s.Date < to);
|
||
if (!string.IsNullOrWhiteSpace(req.Search))
|
||
{
|
||
var s = req.Search.Trim().ToLower();
|
||
q = q.Where(x => x.s.Number.ToLower().Contains(s));
|
||
}
|
||
|
||
var total = await q.CountAsync(ct);
|
||
q = (req.Sort, req.Desc) switch
|
||
{
|
||
("number", false) => q.OrderBy(x => x.s.Number),
|
||
("number", true) => q.OrderByDescending(x => x.s.Number),
|
||
("store", false) => q.OrderBy(x => x.st.Name).ThenByDescending(x => x.s.Date),
|
||
("store", true) => q.OrderByDescending(x => x.st.Name).ThenByDescending(x => x.s.Date),
|
||
("status", false) => q.OrderBy(x => x.s.Status).ThenByDescending(x => x.s.Date),
|
||
("status", true) => q.OrderByDescending(x => x.s.Status).ThenByDescending(x => x.s.Date),
|
||
("total", false) => q.OrderBy(x => x.s.Total).ThenByDescending(x => x.s.Date),
|
||
("total", true) => q.OrderByDescending(x => x.s.Total).ThenByDescending(x => x.s.Date),
|
||
("date", false) => q.OrderBy(x => x.s.Date).ThenBy(x => x.s.Number),
|
||
_ => q.OrderByDescending(x => x.s.Date).ThenByDescending(x => x.s.Number),
|
||
};
|
||
var items = await q
|
||
.Skip(req.Skip).Take(req.Take)
|
||
.Select(x => new RetailSaleListRow(
|
||
x.s.Id, x.s.Number, x.s.Date, x.s.Status,
|
||
x.st.Id, x.st.Name,
|
||
x.s.RetailPointId,
|
||
x.s.RetailPointId == null ? null : _db.RetailPoints.Where(rp => rp.Id == x.s.RetailPointId).Select(rp => rp.Name).FirstOrDefault(),
|
||
x.s.CustomerId,
|
||
x.s.CustomerId == null ? null : _db.Counterparties.Where(c => c.Id == x.s.CustomerId).Select(c => c.Name).FirstOrDefault(),
|
||
x.cu.Id, x.cu.Code,
|
||
x.s.Total, x.s.Payment,
|
||
x.s.Lines.Count,
|
||
x.s.PostedAt,
|
||
x.s.IsReturn, x.s.ReferenceSaleId,
|
||
x.s.ReferenceSaleId == null ? null : _db.RetailSales.Where(rs => rs.Id == x.s.ReferenceSaleId).Select(rs => rs.Number).FirstOrDefault()))
|
||
.ToListAsync(ct);
|
||
|
||
return new PagedResult<RetailSaleListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||
}
|
||
|
||
[HttpGet("{id:guid}")]
|
||
public async Task<ActionResult<RetailSaleDto>> Get(Guid id, CancellationToken ct)
|
||
{
|
||
var dto = await GetInternal(id, ct);
|
||
return dto is null ? NotFound() : Ok(dto);
|
||
}
|
||
|
||
[HttpPost, RequiresPermission("RetailSalesOperate")]
|
||
public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput input, CancellationToken ct)
|
||
{
|
||
if (RequiredGuid.FirstMissing(
|
||
(nameof(input.StoreId), input.StoreId),
|
||
(nameof(input.CurrencyId), input.CurrencyId)) is { } missing)
|
||
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
|
||
if (input.Lines is null || input.Lines.Count == 0)
|
||
return BadRequest(new { error = "Чек должен содержать хотя бы одну позицию." });
|
||
var number = await GenerateNumberAsync(input.Date, ct);
|
||
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
|
||
decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero);
|
||
// Если возврат привязан к чеку — валидация что reference существует и проведён.
|
||
if (input.IsReturn && input.ReferenceSaleId is { } refId)
|
||
{
|
||
var refSale = await _db.RetailSales.AsNoTracking()
|
||
.FirstOrDefaultAsync(s => s.Id == refId && !s.IsReturn, ct);
|
||
if (refSale is null)
|
||
return BadRequest(new { error = "Исходный чек не найден или сам является возвратом.", field = "referenceSaleId" });
|
||
if (refSale.Status != RetailSaleStatus.Posted)
|
||
return BadRequest(new { error = "Можно возвращать только из проведённого чека.", field = "referenceSaleId" });
|
||
}
|
||
|
||
var sale = new RetailSale
|
||
{
|
||
Number = number,
|
||
Date = input.Date,
|
||
Status = RetailSaleStatus.Draft,
|
||
StoreId = input.StoreId,
|
||
RetailPointId = input.RetailPointId,
|
||
CustomerId = input.CustomerId,
|
||
CurrencyId = input.CurrencyId,
|
||
Payment = input.Payment,
|
||
PaidCash = R(input.PaidCash),
|
||
PaidCard = R(input.PaidCard),
|
||
Notes = input.Notes,
|
||
IsReturn = input.IsReturn,
|
||
ReferenceSaleId = input.IsReturn ? input.ReferenceSaleId : null,
|
||
};
|
||
ApplyLines(sale, input.Lines, allowFractional);
|
||
// Sprint 9: лояльность + промокод. Возвращает 400 если код невалидный.
|
||
if (await ApplyLoyaltyAndPromotionAsync(sale, input, allowFractional, ct) is { } loyErr)
|
||
return loyErr;
|
||
_db.RetailSales.Add(sale);
|
||
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
|
||
var dto = await GetInternal(sale.Id, ct);
|
||
return CreatedAtAction(nameof(Get), new { id = sale.Id }, dto);
|
||
}
|
||
|
||
/// <summary>Применяет программу лояльности и промокод к чеку.
|
||
/// - LoyaltyCardNumber: lookup карты по номеру, проверка active/!blocked/
|
||
/// program.IsActive, проверка MinSubtotal, расчёт скидки или баллов.
|
||
/// - PromotionCode: lookup promotion, проверка периода/IsActive/MinSaleAmount/
|
||
/// scope, расчёт скидки.
|
||
///
|
||
/// Total чека пересчитываем в конце: Subtotal - DiscountTotal - LoyaltyBonusApplied -
|
||
/// PromotionDiscount. Округляем по allowFractional. Если sale.IsReturn=true —
|
||
/// лояльность/промо игнорируем (возврат не накручивает баллы).</summary>
|
||
private async Task<ActionResult?> ApplyLoyaltyAndPromotionAsync(
|
||
RetailSale sale, RetailSaleInput input, bool allowFractional, CancellationToken ct)
|
||
{
|
||
decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero);
|
||
|
||
if (sale.IsReturn)
|
||
{
|
||
sale.Total = R(sale.Subtotal - sale.DiscountTotal);
|
||
return null;
|
||
}
|
||
|
||
decimal subtotalAfterLineDiscount = sale.Subtotal - sale.DiscountTotal;
|
||
|
||
// ── Loyalty ────────────────────────────────────────────────────────
|
||
if (!string.IsNullOrWhiteSpace(input.LoyaltyCardNumber))
|
||
{
|
||
var num = input.LoyaltyCardNumber.Trim();
|
||
var card = await _db.LoyaltyCards
|
||
.Include(c => c.Program)
|
||
.FirstOrDefaultAsync(c => c.CardNumber == num, ct);
|
||
if (card is null)
|
||
return BadRequest(new { error = $"Карта лояльности «{num}» не найдена.", field = "loyaltyCardNumber" });
|
||
if (card.IsBlocked)
|
||
return BadRequest(new { error = "Карта заблокирована.", field = "loyaltyCardNumber" });
|
||
if (card.Program is null || !card.Program.IsActive)
|
||
return BadRequest(new { error = "Программа лояльности отключена.", field = "loyaltyCardNumber" });
|
||
if (subtotalAfterLineDiscount < card.Program.MinSubtotal)
|
||
return BadRequest(new
|
||
{
|
||
error = $"Чек на {subtotalAfterLineDiscount:N0} меньше минимума программы {card.Program.MinSubtotal:N0} ₸.",
|
||
field = "loyaltyCardNumber",
|
||
});
|
||
sale.LoyaltyCardId = card.Id;
|
||
switch (card.Program.Type)
|
||
{
|
||
case foodmarket.Domain.Sales.LoyaltyProgramType.Percentage:
|
||
sale.LoyaltyBonusApplied = R(subtotalAfterLineDiscount * card.Program.Rate / 100m);
|
||
sale.LoyaltyPointsAccrued = 0m;
|
||
break;
|
||
case foodmarket.Domain.Sales.LoyaltyProgramType.FixedAmount:
|
||
sale.LoyaltyBonusApplied = Math.Min(R(card.Program.Rate), subtotalAfterLineDiscount);
|
||
sale.LoyaltyPointsAccrued = 0m;
|
||
break;
|
||
case foodmarket.Domain.Sales.LoyaltyProgramType.PointsAccrual:
|
||
sale.LoyaltyBonusApplied = 0m;
|
||
sale.LoyaltyPointsAccrued = R(subtotalAfterLineDiscount * card.Program.Rate / 100m);
|
||
break;
|
||
}
|
||
}
|
||
|
||
// ── Promotion ──────────────────────────────────────────────────────
|
||
if (!string.IsNullOrWhiteSpace(input.PromotionCode))
|
||
{
|
||
var code = input.PromotionCode.Trim();
|
||
var now = DateTime.UtcNow;
|
||
var promo = await _db.Promotions
|
||
.FirstOrDefaultAsync(p => p.Code == code && p.IsActive
|
||
&& p.StartsAt <= now && (p.EndsAt == null || p.EndsAt > now), ct);
|
||
if (promo is null)
|
||
return BadRequest(new { error = $"Промокод «{code}» не активен или не найден.", field = "promotionCode" });
|
||
if (subtotalAfterLineDiscount < promo.MinSaleAmount)
|
||
return BadRequest(new
|
||
{
|
||
error = $"Минимум для промокода {promo.MinSaleAmount:N0} ₸, у вас {subtotalAfterLineDiscount:N0} ₸.",
|
||
field = "promotionCode",
|
||
});
|
||
|
||
// Расчёт «применимой» части чека для Scope=ProductGroups/Products.
|
||
// Используем input.Lines, потому что sale.Lines на этом этапе ещё
|
||
// пустой (мы добавляли строки прямо в _db.RetailSaleLines, не через
|
||
// nav-collection — см. ApplyLines). Линейный discount учитываем.
|
||
decimal LineTotal(RetailSaleLineInput l) => l.Quantity * l.UnitPrice - l.Discount;
|
||
decimal matchingSubtotal;
|
||
if (promo.Scope == foodmarket.Domain.Sales.PromotionScope.All)
|
||
{
|
||
matchingSubtotal = subtotalAfterLineDiscount;
|
||
}
|
||
else if (promo.Scope == foodmarket.Domain.Sales.PromotionScope.Products)
|
||
{
|
||
var pids = promo.ProductIds.ToHashSet();
|
||
matchingSubtotal = input.Lines.Where(l => pids.Contains(l.ProductId)).Sum(LineTotal);
|
||
}
|
||
else // ProductGroups
|
||
{
|
||
var gids = promo.ProductGroupIds.ToHashSet();
|
||
var prodIds = input.Lines.Select(l => l.ProductId).Distinct().ToList();
|
||
var productGroupMap = await _db.Products.IgnoreQueryFilters()
|
||
.Where(p => prodIds.Contains(p.Id))
|
||
.Select(p => new { p.Id, GroupId = p.ProductGroupId })
|
||
.ToDictionaryAsync(x => x.Id, x => x.GroupId, ct);
|
||
matchingSubtotal = input.Lines
|
||
.Where(l => productGroupMap.TryGetValue(l.ProductId, out var gid) && gids.Contains(gid))
|
||
.Sum(LineTotal);
|
||
}
|
||
if (matchingSubtotal <= 0)
|
||
return BadRequest(new { error = "Нет позиций, подходящих под промокод.", field = "promotionCode" });
|
||
|
||
sale.PromotionId = promo.Id;
|
||
sale.PromotionCode = code;
|
||
sale.PromotionDiscount = promo.Type == foodmarket.Domain.Sales.PromotionType.Percent
|
||
? R(matchingSubtotal * promo.Value / 100m)
|
||
: Math.Min(R(promo.Value), matchingSubtotal);
|
||
}
|
||
|
||
// ── Финальный Total ─────────────────────────────────────────────────
|
||
var total = sale.Subtotal - sale.DiscountTotal - sale.LoyaltyBonusApplied - sale.PromotionDiscount;
|
||
sale.Total = R(Math.Max(0, total));
|
||
return null;
|
||
}
|
||
|
||
/// <summary>SaveChanges + перехват PostgresException 23503 (FK violation).
|
||
/// Возвращает 400 с указанием поля если FK не сошёлся (например, StoreId
|
||
/// или RetailPointId указывают на несуществующую запись) — это лучше
|
||
/// чем 500.</summary>
|
||
private async Task<ActionResult?> SaveOrFkErrorAsync(CancellationToken ct)
|
||
{
|
||
try
|
||
{
|
||
await _db.SaveChangesAsync(ct);
|
||
return null;
|
||
}
|
||
catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23503")
|
||
{
|
||
var name = pg.ConstraintName ?? "";
|
||
string field = name.Contains("Store") ? "storeId"
|
||
: name.Contains("RetailPoint") ? "retailPointId"
|
||
: name.Contains("Customer") ? "customerId"
|
||
: name.Contains("Currency") ? "currencyId"
|
||
: name.Contains("Product") ? "productId"
|
||
: "(unknown)";
|
||
return BadRequest(new { error = $"Связанная запись не найдена: {field}.", field, constraint = name });
|
||
}
|
||
}
|
||
|
||
[HttpPut("{id:guid}"), RequiresPermission("RetailSalesOperate")]
|
||
public async Task<IActionResult> Update(Guid id, [FromBody] RetailSaleInput input, CancellationToken ct)
|
||
{
|
||
if (RequiredGuid.FirstMissing(
|
||
(nameof(input.StoreId), input.StoreId),
|
||
(nameof(input.CurrencyId), input.CurrencyId)) is { } missing)
|
||
return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing });
|
||
// Загружаем sale БЕЗ Include(Lines): иначе вылавливается баг EF8,
|
||
// когда после ExecuteDelete+Add EF путается со state'ом строк и кидает
|
||
// DbUpdateConcurrency. Старые строки удаляем хирургически через
|
||
// ExecuteDelete (минует трекер), новые — через отдельный AddRange.
|
||
var sale = await _db.RetailSales.FirstOrDefaultAsync(s => s.Id == id, ct);
|
||
if (sale is null) return NotFound();
|
||
if (sale.Status != RetailSaleStatus.Draft)
|
||
return Conflict(new { error = "Только черновик может быть изменён." });
|
||
|
||
var allowFractional = await _db.Organizations.Select(o => o.AllowFractionalPrices).FirstOrDefaultAsync(ct);
|
||
decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero);
|
||
sale.Date = input.Date;
|
||
sale.StoreId = input.StoreId;
|
||
sale.RetailPointId = input.RetailPointId;
|
||
sale.CustomerId = input.CustomerId;
|
||
sale.CurrencyId = input.CurrencyId;
|
||
sale.Payment = input.Payment;
|
||
sale.PaidCash = R(input.PaidCash);
|
||
sale.PaidCard = R(input.PaidCard);
|
||
sale.Notes = input.Notes;
|
||
|
||
await _db.RetailSaleLines.Where(l => l.RetailSaleId == sale.Id).ExecuteDeleteAsync(ct);
|
||
ApplyLines(sale, input.Lines, allowFractional);
|
||
// Sprint 9: пересчёт лояльности/промо при обновлении draft'a.
|
||
// Сбрасываем старые значения, чтобы Apply пересчитал с чистого листа.
|
||
sale.LoyaltyCardId = null; sale.LoyaltyBonusApplied = 0m; sale.LoyaltyPointsAccrued = 0m;
|
||
sale.PromotionId = null; sale.PromotionCode = null; sale.PromotionDiscount = 0m;
|
||
if (await ApplyLoyaltyAndPromotionAsync(sale, input, allowFractional, ct) is { } loyErr) return loyErr;
|
||
|
||
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
|
||
return NoContent();
|
||
}
|
||
|
||
[HttpDelete("{id:guid}"), RequiresPermission("RetailSalesRefund")]
|
||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||
{
|
||
var sale = await _db.RetailSales.FirstOrDefaultAsync(s => s.Id == id, ct);
|
||
if (sale is null) return NotFound();
|
||
if (sale.Status != RetailSaleStatus.Draft)
|
||
return Conflict(new { error = "Нельзя удалить проведённый чек." });
|
||
_db.RetailSales.Remove(sale);
|
||
await _db.SaveChangesAsync(ct);
|
||
return NoContent();
|
||
}
|
||
|
||
[HttpPost("{id:guid}/post"), RequiresPermission("RetailSalesOperate")]
|
||
public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
||
{
|
||
var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
||
if (sale is null) return NotFound();
|
||
if (sale.Status == RetailSaleStatus.Posted) return Conflict(new { error = "Чек уже проведён." });
|
||
if (sale.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести пустой чек." });
|
||
|
||
// Для возврата — отдельная ветка обработки (см. PostReturnAsync ниже).
|
||
if (sale.IsReturn) return await PostReturnAsync(sale, ct);
|
||
|
||
// Валидация платежа: сумма наличных + по карте должна покрывать Total.
|
||
// Сдача — нормально (PaidCash > Total), недоплата — нет. Иначе касса
|
||
// может «провести» чек на 5000 ₸, не получив с покупателя ни тенге.
|
||
// Округление до 2 знаков защищает от floating-point дрейфа.
|
||
// Правило вынесено в RetailPaymentValidator (юнит-тест). paid/due также
|
||
// считаем локально для текста ошибки — округление совпадает с валидатором.
|
||
if (!foodmarket.Application.Sales.RetailPaymentValidator.IsSufficient(
|
||
sale.PaidCash, sale.PaidCard, sale.Total))
|
||
{
|
||
var paid = decimal.Round(sale.PaidCash + sale.PaidCard, 2);
|
||
var due = decimal.Round(sale.Total, 2);
|
||
return BadRequest(new
|
||
{
|
||
error = $"Сумма оплаты {paid} меньше итога {due}. Доплатите или измените позиции чека.",
|
||
field = "PaidCash",
|
||
});
|
||
}
|
||
|
||
// Транзакция Serializable: чтение остатков + запись stock_movements +
|
||
// апдейт stocks под одной блокировкой. Это защищает от race condition
|
||
// когда два кассира одновременно постят чеки на один и тот же товар:
|
||
// без транзакции оба бы прочли «на складе 5», списали по 3, и в итоге
|
||
// получили бы −1. Serializable заставляет вторую транзакцию подождать
|
||
// или откатиться при конфликте.
|
||
await using var tx = await _db.Database.BeginTransactionAsync(
|
||
System.Data.IsolationLevel.Serializable, ct);
|
||
|
||
// Если в одном чеке один и тот же продукт встречается несколько раз,
|
||
// нужно сравнить с остатком СУММУ всех строк, а не каждую отдельно.
|
||
var requested = sale.Lines
|
||
.GroupBy(l => l.ProductId)
|
||
.Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Quantity) })
|
||
.ToList();
|
||
|
||
var productIds = requested.Select(r => r.ProductId).ToList();
|
||
var stocksByProduct = await _db.Stocks
|
||
.Where(s => s.StoreId == sale.StoreId && productIds.Contains(s.ProductId))
|
||
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
|
||
|
||
var insufficient = new List<object>();
|
||
foreach (var r in requested)
|
||
{
|
||
stocksByProduct.TryGetValue(r.ProductId, out var available);
|
||
if (available < r.Quantity)
|
||
{
|
||
var name = await _db.Products
|
||
.Where(p => p.Id == r.ProductId)
|
||
.Select(p => p.Name)
|
||
.FirstOrDefaultAsync(ct);
|
||
insufficient.Add(new
|
||
{
|
||
productId = r.ProductId,
|
||
productName = name,
|
||
qty = r.Quantity,
|
||
available,
|
||
});
|
||
}
|
||
}
|
||
|
||
if (insufficient.Count > 0)
|
||
{
|
||
return Conflict(new
|
||
{
|
||
error = "Недостаточно остатка для проведения чека.",
|
||
lines = insufficient,
|
||
});
|
||
}
|
||
|
||
foreach (var line in sale.Lines)
|
||
{
|
||
await _stock.ApplyMovementAsync(new StockMovementDraft(
|
||
ProductId: line.ProductId,
|
||
StoreId: sale.StoreId,
|
||
Quantity: -line.Quantity, // negative: товар уходит со склада
|
||
Type: MovementType.RetailSale,
|
||
DocumentType: "retail-sale",
|
||
DocumentId: sale.Id,
|
||
DocumentNumber: sale.Number,
|
||
UnitCost: line.UnitPrice,
|
||
OccurredAt: sale.Date), ct);
|
||
}
|
||
|
||
sale.Status = RetailSaleStatus.Posted;
|
||
sale.PostedAt = DateTime.UtcNow;
|
||
// Sprint 9: начисляем баллы на карту (для PointsAccrual-программ).
|
||
// Делаем внутри той же транзакции, чтобы при rollback'е баллы не
|
||
// оказались списанными в воздух.
|
||
if (sale.LoyaltyCardId is { } cardId && sale.LoyaltyPointsAccrued > 0m)
|
||
{
|
||
var card = await _db.LoyaltyCards.FirstOrDefaultAsync(c => c.Id == cardId, ct);
|
||
if (card is not null)
|
||
{
|
||
card.Balance += sale.LoyaltyPointsAccrued;
|
||
card.UpdatedAt = DateTime.UtcNow;
|
||
}
|
||
}
|
||
await _db.SaveChangesAsync(ct);
|
||
await tx.CommitAsync(ct);
|
||
foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementPosted("retail-sale");
|
||
_log.LogInformation(
|
||
"RetailSale posted: {SaleNumber} store={StoreId} payment={Payment} lines={LinesCount} total={Total}",
|
||
sale.Number, sale.StoreId, sale.Payment, sale.Lines.Count, sale.Total);
|
||
|
||
// ── Sprint 11: фискализация чека у ОФД-оператора ─────────────────
|
||
// Делаем ПОСЛЕ commit'а stock-транзакции — фискальный RPC может
|
||
// занимать секунды (Webkassa в час пик), удерживать всю серию
|
||
// блокировок ради этого нельзя. Если оператор недоступен, чек
|
||
// остаётся проведённым (Posted=true), а фискальный номер просто
|
||
// пуст — это допустимо (можно перепровести post вручную).
|
||
await TryFiscalizeAsync(sale, ct);
|
||
|
||
// SignalR-уведомление в группу org. Кассирa берём из CashierId если
|
||
// есть Employee.Name по UserId, иначе из User.Email (короткая часть до @).
|
||
try
|
||
{
|
||
string? cashier = null;
|
||
var meSub = User.FindFirst(OpenIddict.Abstractions.OpenIddictConstants.Claims.Subject)?.Value;
|
||
if (Guid.TryParse(meSub, out var uid))
|
||
{
|
||
cashier = await _db.Employees.IgnoreQueryFilters()
|
||
.Where(e => e.UserId == uid && e.OrganizationId == sale.OrganizationId)
|
||
.Select(e => (e.LastName + " " + e.FirstName).Trim())
|
||
.FirstOrDefaultAsync(ct);
|
||
}
|
||
await _notify.PublishAsync(sale.OrganizationId,
|
||
foodmarket.Api.Realtime.NotificationEvents.SalePosted,
|
||
new foodmarket.Api.Realtime.SalePostedPayload(
|
||
sale.Id, sale.Number, sale.Total, sale.StoreId,
|
||
cashier, sale.RetailPointId, sale.PostedAt!.Value));
|
||
// LowStock: после списания, если по какому-то товару остаток ушёл
|
||
// ниже MinStock, уведомим. MinStock=null → не уведомляем.
|
||
await NotifyLowStockAfterSaleAsync(sale, ct);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Notification — best-effort: не должна валить транзакцию (она уже
|
||
// закоммичена). Логируем и идём дальше.
|
||
_log.LogWarning(ex, "SignalR notify failed for sale {SaleId}", sale.Id);
|
||
}
|
||
|
||
return NoContent();
|
||
}
|
||
|
||
private async Task NotifyLowStockAfterSaleAsync(RetailSale sale, CancellationToken ct)
|
||
{
|
||
var productIds = sale.Lines.Select(l => l.ProductId).Distinct().ToList();
|
||
if (productIds.Count == 0) return;
|
||
var infos = await (from p in _db.Products.IgnoreQueryFilters()
|
||
join s in _db.Stocks.IgnoreQueryFilters() on p.Id equals s.ProductId
|
||
where p.OrganizationId == sale.OrganizationId
|
||
&& productIds.Contains(p.Id)
|
||
&& s.StoreId == sale.StoreId
|
||
&& p.MinStock != null
|
||
&& s.Quantity < p.MinStock
|
||
select new { p.Id, p.Name, p.MinStock, s.Quantity, s.StoreId })
|
||
.ToListAsync(ct);
|
||
if (infos.Count == 0) return;
|
||
var storeName = await _db.Stores.IgnoreQueryFilters()
|
||
.Where(st => st.Id == sale.StoreId)
|
||
.Select(st => st.Name).FirstOrDefaultAsync(ct);
|
||
foreach (var i in infos)
|
||
{
|
||
await _notify.PublishAsync(sale.OrganizationId,
|
||
foodmarket.Api.Realtime.NotificationEvents.LowStock,
|
||
new foodmarket.Api.Realtime.LowStockPayload(
|
||
i.Id, i.Name, i.StoreId, storeName, i.Quantity, i.MinStock ?? 0m));
|
||
}
|
||
}
|
||
|
||
/// <summary>Sprint 11: вызвать ОФД-провайдера (если выбран) и сохранить
|
||
/// фискальный номер/QR на чек. Best-effort: любая ошибка проглатывается
|
||
/// и логируется — чек остаётся проведённым даже без фискализации
|
||
/// (оператор может быть временно недоступен, retry — отдельная история).
|
||
///
|
||
/// Идемпотентность: если на чеке уже есть FiscalNumber, повторно не
|
||
/// зовём. Это покрывает случай ручного re-post'а через unpost→post.</summary>
|
||
private async Task TryFiscalizeAsync(RetailSale sale, CancellationToken ct)
|
||
{
|
||
if (!string.IsNullOrEmpty(sale.FiscalNumber)) return;
|
||
foodmarket.Application.Common.Fiscal.IFiscalProvider? provider;
|
||
try
|
||
{
|
||
provider = await _fiscal.ResolveAsync(ct);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
_log.LogWarning(ex, "Fiscal: фабрика провайдеров упала для чека {SaleNumber}", sale.Number);
|
||
return;
|
||
}
|
||
if (provider is null) return; // None — фискализация отключена
|
||
|
||
try
|
||
{
|
||
// Подгружаем продукт для PositionName (Webkassa требует имя в
|
||
// payload'е). Include на этом этапе чтобы EF не дёргал N+1.
|
||
await _db.Entry(sale).Collection(s => s.Lines).Query()
|
||
.Include(l => l.Product).LoadAsync(ct);
|
||
|
||
var result = await provider.RegisterAsync(sale, ct);
|
||
sale.FiscalNumber = result.FiscalNumber;
|
||
sale.FiscalQrCode = result.FiscalQrCode;
|
||
sale.FiscalUrl = result.FiscalUrl;
|
||
sale.FiscalProviderTxId = result.ProviderTxId;
|
||
sale.FiscalProviderKind = (int)provider.Kind;
|
||
await _db.SaveChangesAsync(ct);
|
||
_log.LogInformation(
|
||
"Fiscal: чек {SaleNumber} зарегистрирован у {Provider} → {FiscalNumber}",
|
||
sale.Number, provider.Kind, result.FiscalNumber);
|
||
}
|
||
catch (foodmarket.Application.Common.Fiscal.FiscalNotConfiguredException ex)
|
||
{
|
||
// Конфиг неполный — это валидная диагностика, не алерт.
|
||
_log.LogWarning(
|
||
"Fiscal: провайдер {Provider} не настроен для чека {SaleNumber}: {Message}",
|
||
provider.Kind, sale.Number, ex.Message);
|
||
}
|
||
catch (Exception ex)
|
||
{
|
||
// Сетевые/HTTP-ошибки — записываем warning. Алерт можно навесить
|
||
// на счётчик AppMetrics в Sprint 12+, когда будут реальные данные.
|
||
_log.LogWarning(ex,
|
||
"Fiscal: провайдер {Provider} вернул ошибку для чека {SaleNumber}",
|
||
provider.Kind, sale.Number);
|
||
}
|
||
}
|
||
|
||
[HttpPost("{id:guid}/unpost"), RequiresPermission("RetailSalesRefund")]
|
||
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
|
||
{
|
||
var sale = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
||
if (sale is null) return NotFound();
|
||
if (sale.Status != RetailSaleStatus.Posted) return Conflict(new { error = "Чек не проведён." });
|
||
|
||
// Если у этого чека-продажи есть проведённые возвраты — нельзя отменить
|
||
// оригинал, пока возвраты не отменены/удалены (иначе QtyReturned оказался
|
||
// бы посчитан против несуществующего больше чека).
|
||
if (!sale.IsReturn)
|
||
{
|
||
var hasReturns = await _db.RetailSales.AnyAsync(
|
||
r => r.ReferenceSaleId == sale.Id && r.IsReturn && r.Status == RetailSaleStatus.Posted, ct);
|
||
if (hasReturns)
|
||
return Conflict(new { error = "У чека есть проведённые возвраты — сначала отмени их." });
|
||
}
|
||
|
||
if (sale.IsReturn) return await UnpostReturnAsync(sale, ct);
|
||
|
||
foreach (var line in sale.Lines)
|
||
{
|
||
await _stock.ApplyMovementAsync(new StockMovementDraft(
|
||
ProductId: line.ProductId,
|
||
StoreId: sale.StoreId,
|
||
Quantity: +line.Quantity, // reverse — return stock
|
||
Type: MovementType.RetailSale,
|
||
DocumentType: "retail-sale-reversal",
|
||
DocumentId: sale.Id,
|
||
DocumentNumber: sale.Number,
|
||
UnitCost: line.UnitPrice,
|
||
OccurredAt: DateTime.UtcNow,
|
||
Notes: $"Отмена чека {sale.Number}"), ct);
|
||
}
|
||
|
||
sale.Status = RetailSaleStatus.Draft;
|
||
sale.PostedAt = null;
|
||
await _db.SaveChangesAsync(ct);
|
||
return NoContent();
|
||
}
|
||
|
||
/// <summary>POST /create-return — копирует строки проведённого чека в новый
|
||
/// Draft с IsReturn=true и ReferenceSaleId. Изначально quantity берётся как
|
||
/// (line.Quantity - line.QtyReturned) — оставшееся к возврату. Пользователь
|
||
/// потом может уменьшить/удалить позиции через обычный PUT.</summary>
|
||
[HttpPost("{id:guid}/create-return"), RequiresPermission("RetailSalesRefund")]
|
||
public async Task<ActionResult<RetailSaleDto>> CreateReturnFrom(Guid id, CancellationToken ct)
|
||
{
|
||
var src = await _db.RetailSales.Include(s => s.Lines).FirstOrDefaultAsync(s => s.Id == id, ct);
|
||
if (src is null) return NotFound();
|
||
if (src.IsReturn) return BadRequest(new { error = "Нельзя создать возврат с возврата." });
|
||
if (src.Status != RetailSaleStatus.Posted) return BadRequest(new { error = "Сначала проведи исходный чек." });
|
||
|
||
var remainingByLine = src.Lines
|
||
.Select(l => new { l, remaining = l.Quantity - l.QtyReturned })
|
||
.Where(x => x.remaining > 0)
|
||
.ToList();
|
||
if (remainingByLine.Count == 0)
|
||
return BadRequest(new { error = "Этот чек уже полностью возвращён." });
|
||
|
||
var number = await GenerateNumberAsync(src.Date, ct);
|
||
var ret = new RetailSale
|
||
{
|
||
Number = number,
|
||
Date = DateTime.UtcNow,
|
||
Status = RetailSaleStatus.Draft,
|
||
StoreId = src.StoreId,
|
||
RetailPointId = src.RetailPointId,
|
||
CustomerId = src.CustomerId,
|
||
CurrencyId = src.CurrencyId,
|
||
Payment = src.Payment,
|
||
PaidCash = 0,
|
||
PaidCard = 0,
|
||
Notes = $"Возврат по чеку {src.Number}",
|
||
IsReturn = true,
|
||
ReferenceSaleId = src.Id,
|
||
};
|
||
var order = 0;
|
||
decimal subtotal = 0, discountTotal = 0;
|
||
foreach (var x in remainingByLine)
|
||
{
|
||
var qty = x.remaining;
|
||
var lineTotal = qty * x.l.UnitPrice - x.l.Discount;
|
||
ret.Lines.Add(new RetailSaleLine
|
||
{
|
||
ProductId = x.l.ProductId,
|
||
Quantity = qty,
|
||
UnitPrice = x.l.UnitPrice,
|
||
Discount = 0, // discount проще обнулить и считать «возвращаем по цене продажи»
|
||
LineTotal = qty * x.l.UnitPrice,
|
||
VatPercent = x.l.VatPercent,
|
||
SortOrder = order++,
|
||
});
|
||
subtotal += qty * x.l.UnitPrice;
|
||
}
|
||
ret.Subtotal = subtotal;
|
||
ret.DiscountTotal = discountTotal;
|
||
ret.Total = subtotal - discountTotal;
|
||
|
||
_db.RetailSales.Add(ret);
|
||
await _db.SaveChangesAsync(ct);
|
||
var dto = await GetInternal(ret.Id, ct);
|
||
return CreatedAtAction(nameof(Get), new { id = ret.Id }, dto);
|
||
}
|
||
|
||
/// <summary>Post return: ВОЗВРАЩАЕТ товар на склад (CustomerReturn +Quantity),
|
||
/// инкрементит QtyReturned на исходной строке (защита от over-return).</summary>
|
||
private async Task<IActionResult> PostReturnAsync(RetailSale sale, CancellationToken ct)
|
||
{
|
||
// Если есть reference — валидация over-return: для каждой строки возврата
|
||
// суммарное Quantity по продукту ≤ исходное (Quantity - QtyReturned).
|
||
if (sale.ReferenceSaleId is { } refId)
|
||
{
|
||
var refLines = await _db.RetailSaleLines
|
||
.Where(l => l.RetailSaleId == refId).ToListAsync(ct);
|
||
var refByProduct = refLines
|
||
.GroupBy(l => l.ProductId)
|
||
.ToDictionary(g => g.Key, g => new { Sold = g.Sum(x => x.Quantity), Returned = g.Sum(x => x.QtyReturned) });
|
||
var conflicts = new List<object>();
|
||
var returnByProduct = sale.Lines.GroupBy(l => l.ProductId)
|
||
.ToDictionary(g => g.Key, g => g.Sum(x => x.Quantity));
|
||
foreach (var (productId, requested) in returnByProduct)
|
||
{
|
||
if (!refByProduct.TryGetValue(productId, out var rb))
|
||
{
|
||
conflicts.Add(new { productId, error = "товар не из исходного чека" });
|
||
continue;
|
||
}
|
||
var remaining = rb.Sold - rb.Returned;
|
||
if (requested > remaining)
|
||
{
|
||
var name = await _db.Products.Where(p => p.Id == productId).Select(p => p.Name).FirstOrDefaultAsync(ct);
|
||
conflicts.Add(new { productId, productName = name, requested, remaining });
|
||
}
|
||
}
|
||
if (conflicts.Count > 0)
|
||
{
|
||
return Conflict(new
|
||
{
|
||
error = "Превышено количество доступное к возврату по этому чеку.",
|
||
lines = conflicts,
|
||
});
|
||
}
|
||
}
|
||
|
||
await using var tx = await _db.Database.BeginTransactionAsync(
|
||
System.Data.IsolationLevel.Serializable, ct);
|
||
|
||
foreach (var line in sale.Lines)
|
||
{
|
||
await _stock.ApplyMovementAsync(new StockMovementDraft(
|
||
ProductId: line.ProductId,
|
||
StoreId: sale.StoreId,
|
||
Quantity: +line.Quantity, // товар возвращается на склад
|
||
Type: MovementType.CustomerReturn,
|
||
DocumentType: "customer-return",
|
||
DocumentId: sale.Id,
|
||
DocumentNumber: sale.Number,
|
||
UnitCost: line.UnitPrice,
|
||
OccurredAt: sale.Date), ct);
|
||
}
|
||
|
||
// Инкрементим QtyReturned на исходных строках (для защиты следующих возвратов).
|
||
if (sale.ReferenceSaleId is { } refId2)
|
||
{
|
||
var refLines = await _db.RetailSaleLines
|
||
.Where(l => l.RetailSaleId == refId2).ToListAsync(ct);
|
||
foreach (var rl in refLines)
|
||
{
|
||
var taken = sale.Lines.Where(x => x.ProductId == rl.ProductId).Sum(x => x.Quantity);
|
||
if (taken > 0)
|
||
{
|
||
// Если в исходном чеке у одного товара несколько строк — распределим
|
||
// увеличение QtyReturned последовательно: насыщаем по line.Quantity.
|
||
var available = rl.Quantity - rl.QtyReturned;
|
||
var take = Math.Min(taken, available);
|
||
rl.QtyReturned += take;
|
||
taken -= take;
|
||
}
|
||
}
|
||
}
|
||
|
||
sale.Status = RetailSaleStatus.Posted;
|
||
sale.PostedAt = DateTime.UtcNow;
|
||
try
|
||
{
|
||
await _db.SaveChangesAsync(ct);
|
||
await tx.CommitAsync(ct);
|
||
}
|
||
catch (Exception ex) when (IsSerializationConflict(ex))
|
||
{
|
||
return Conflict(new { error = "Документ проводится параллельно. Повторите попытку." });
|
||
}
|
||
return NoContent();
|
||
}
|
||
|
||
private async Task<IActionResult> UnpostReturnAsync(RetailSale sale, CancellationToken ct)
|
||
{
|
||
await using var tx = await _db.Database.BeginTransactionAsync(
|
||
System.Data.IsolationLevel.Serializable, ct);
|
||
|
||
foreach (var line in sale.Lines)
|
||
{
|
||
await _stock.ApplyMovementAsync(new StockMovementDraft(
|
||
ProductId: line.ProductId,
|
||
StoreId: sale.StoreId,
|
||
Quantity: -line.Quantity,
|
||
Type: MovementType.CustomerReturn,
|
||
DocumentType: "customer-return-reversal",
|
||
DocumentId: sale.Id,
|
||
DocumentNumber: sale.Number,
|
||
UnitCost: line.UnitPrice,
|
||
OccurredAt: DateTime.UtcNow,
|
||
Notes: $"Отмена возврата {sale.Number}"), ct);
|
||
}
|
||
|
||
if (sale.ReferenceSaleId is { } refId)
|
||
{
|
||
var refLines = await _db.RetailSaleLines
|
||
.Where(l => l.RetailSaleId == refId).ToListAsync(ct);
|
||
foreach (var rl in refLines)
|
||
{
|
||
var taken = sale.Lines.Where(x => x.ProductId == rl.ProductId).Sum(x => x.Quantity);
|
||
if (taken > 0)
|
||
{
|
||
var giveBack = Math.Min(taken, rl.QtyReturned);
|
||
rl.QtyReturned -= giveBack;
|
||
taken -= giveBack;
|
||
}
|
||
}
|
||
}
|
||
|
||
sale.Status = RetailSaleStatus.Draft;
|
||
sale.PostedAt = null;
|
||
try
|
||
{
|
||
await _db.SaveChangesAsync(ct);
|
||
await tx.CommitAsync(ct);
|
||
}
|
||
catch (Exception ex) when (IsSerializationConflict(ex))
|
||
{
|
||
return Conflict(new { error = "Документ обрабатывается параллельно. Повторите попытку." });
|
||
}
|
||
return NoContent();
|
||
}
|
||
|
||
private static bool IsSerializationConflict(Exception ex)
|
||
{
|
||
for (Exception? e = ex; e is not null; e = e.InnerException)
|
||
{
|
||
if (e is System.Data.Common.DbException { SqlState: "40001" or "40P01" })
|
||
return true;
|
||
}
|
||
return false;
|
||
}
|
||
|
||
private void ApplyLines(RetailSale sale, IReadOnlyList<RetailSaleLineInput> input, bool allowFractional)
|
||
{
|
||
decimal R(decimal v) => allowFractional ? v : Math.Round(v, 0, MidpointRounding.AwayFromZero);
|
||
var order = 0;
|
||
decimal subtotal = 0, discountTotal = 0;
|
||
// Добавляем строки напрямую в DbSet, а не через nav `sale.Lines.Add`.
|
||
// На пути через nav EF8 в некоторых случаях помечает новую сущность
|
||
// как Modified (а не Added), потому что Id уже задан клиентом
|
||
// (Guid.NewGuid в Entity ctor) и связь child→parent через
|
||
// collection-navigation запутывает change-detector. Прямой Add в DbSet
|
||
// снимает неоднозначность.
|
||
foreach (var l in input)
|
||
{
|
||
var unitPrice = R(l.UnitPrice);
|
||
var discount = R(l.Discount);
|
||
var lineTotal = l.Quantity * unitPrice - discount;
|
||
_db.RetailSaleLines.Add(new RetailSaleLine
|
||
{
|
||
RetailSaleId = sale.Id,
|
||
ProductId = l.ProductId,
|
||
Quantity = l.Quantity,
|
||
UnitPrice = unitPrice,
|
||
Discount = discount,
|
||
LineTotal = lineTotal,
|
||
VatPercent = l.VatPercent,
|
||
SortOrder = order++,
|
||
});
|
||
subtotal += l.Quantity * unitPrice;
|
||
discountTotal += discount;
|
||
}
|
||
sale.Subtotal = subtotal;
|
||
sale.DiscountTotal = discountTotal;
|
||
sale.Total = subtotal - discountTotal;
|
||
}
|
||
|
||
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}";
|
||
}
|
||
|
||
private async Task<RetailSaleDto?> GetInternal(Guid id, CancellationToken ct)
|
||
{
|
||
var row = await (from s in _db.RetailSales.AsNoTracking()
|
||
join st in _db.Stores on s.StoreId equals st.Id
|
||
join cu in _db.Currencies on s.CurrencyId equals cu.Id
|
||
where s.Id == id
|
||
select new { s, st, cu }).FirstOrDefaultAsync(ct);
|
||
if (row is null) return null;
|
||
|
||
string? rpName = row.s.RetailPointId is null ? null
|
||
: await _db.RetailPoints.Where(r => r.Id == row.s.RetailPointId).Select(r => r.Name).FirstOrDefaultAsync(ct);
|
||
string? cName = row.s.CustomerId is null ? null
|
||
: await _db.Counterparties.Where(c => c.Id == row.s.CustomerId).Select(c => c.Name).FirstOrDefaultAsync(ct);
|
||
|
||
var lines = await (from l in _db.RetailSaleLines.AsNoTracking()
|
||
join p in _db.Products on l.ProductId equals p.Id
|
||
join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id
|
||
where l.RetailSaleId == id
|
||
orderby l.SortOrder
|
||
select new RetailSaleLineDto(
|
||
l.Id, l.ProductId, p.Name, p.Article, u.Name,
|
||
l.Quantity, l.UnitPrice, l.Discount, l.LineTotal, l.VatPercent, l.SortOrder,
|
||
l.QtyReturned))
|
||
.ToListAsync(ct);
|
||
|
||
string? refNumber = row.s.ReferenceSaleId is null ? null
|
||
: await _db.RetailSales.Where(rs => rs.Id == row.s.ReferenceSaleId)
|
||
.Select(rs => rs.Number).FirstOrDefaultAsync(ct);
|
||
|
||
return new RetailSaleDto(
|
||
row.s.Id, row.s.Number, row.s.Date, row.s.Status,
|
||
row.st.Id, row.st.Name,
|
||
row.s.RetailPointId, rpName,
|
||
row.s.CustomerId, cName,
|
||
row.cu.Id, row.cu.Code,
|
||
row.s.Subtotal, row.s.DiscountTotal, row.s.Total,
|
||
row.s.Payment, row.s.PaidCash, row.s.PaidCard,
|
||
row.s.Notes, row.s.PostedAt,
|
||
row.s.IsReturn, row.s.ReferenceSaleId, refNumber,
|
||
lines,
|
||
// Sprint 9
|
||
row.s.LoyaltyCardId, row.s.LoyaltyBonusApplied, row.s.LoyaltyPointsAccrued,
|
||
row.s.PromotionId, row.s.PromotionCode, row.s.PromotionDiscount,
|
||
// Sprint 11
|
||
row.s.FiscalNumber, row.s.FiscalQrCode, row.s.FiscalUrl, row.s.FiscalProviderKind);
|
||
}
|
||
}
|