food-market/src/food-market.api/Controllers/Sales/RetailSalesController.cs
nns 0d3ef81f72 feat(s11): ОФД-scaffolding — IFiscalProvider + 4 провайдера + UI/тесты
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>
2026-06-07 02:27:17 +05:00

1080 lines
55 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.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);
}
}