food-market/src/food-market.api/Controllers/Sales/RetailSalesController.cs
nurdotnet 654a8ba87d feat: strict OtherSystem schema — реплика потерянного f7087e9
Main расходился с БД стейджа (Phase2c3_MsStrict в history, но код ещё ссылался на VatRate etc.) — деплой ломался. Реплицирую удаление сущностей вручную, чтобы код совпадал с таблицами.

Убрано (нет в OtherSystem — не выдумываем):
- Domain: VatRate сущность целиком; Counterparty.Kind + enum CounterpartyKind; Store.Kind + enum StoreKind; Product.IsAlcohol; UnitOfMeasure.Symbol/DecimalPlaces/IsBase.
- EF: DbSet<VatRate>, ConfigureVatRate, Product.VatRate navigation, индекс Counterparty.Kind.
- DTO/Input: соответствующие поля и VatRateDto/Input.
- API: VatRatesController удалён; references в Products/Counterparties/Stores/UoM/Supplies/Retail/Stock.

Добавлено как в OtherSystem:
- Product.Vat (int) + Product.VatEnabled — OtherSystem держит НДС числом на товаре.
- KZ default VAT 16% — applied в сидерах и в OtherSystemImportService когда товар не принёс свой vat.

OtherSystemImportService:
- ResolveKind убран; CompanyType=entrepreneur→Individual (как и было).
- VatRates lookup → прямой p.Vat ?? 16 + p.Vat > 0 для VatEnabled.
- baseUnit ищется по code="796" вместо IsBase.

Web:
- types.ts: убраны CounterpartyKind/StoreKind/VatRate/Product.vatRateId/vatPercent/isAlcohol/UoM.symbol/decimalPlaces/isBase; добавлено Product.vat/vatEnabled; унифицировано unitSymbol→unitName.
- VatRatesPage удалён, роут из App.tsx тоже.
- CounterpartiesPage/StoresPage/UnitsOfMeasurePage: убраны соответствующие поля в формах.
- ProductEditPage: select "Ставка НДС" теперь с фиксированными 0/10/12/16/20 + чекбокс VatEnabled.
- Stock/RetailSale/Supply pages: unitSymbol → unitName.

deploy-stage unguarded — теперь код соответствует DB, авто-deploy безопасен.
2026-04-23 17:32:02 +05:00

371 lines
16 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 foodmarket.Application.Common;
using foodmarket.Application.Inventory;
using foodmarket.Domain.Inventory;
using foodmarket.Domain.Sales;
using foodmarket.Infrastructure.Persistence;
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;
public RetailSalesController(AppDbContext db, IStockService stock)
{
_db = db;
_stock = stock;
}
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);
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);
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,
IReadOnlyList<RetailSaleLineDto> Lines);
public record RetailSaleLineInput(Guid ProductId, decimal Quantity, decimal UnitPrice, decimal Discount, decimal VatPercent);
public record RetailSaleInput(
DateTime Date, Guid StoreId, Guid? RetailPointId, Guid? CustomerId, Guid CurrencyId,
PaymentMethod Payment, decimal PaidCash, decimal PaidCard,
string? Notes,
IReadOnlyList<RetailSaleLineInput> Lines);
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);
/// <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));
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 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);
}
[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);
var items = await q
.OrderByDescending(x => x.s.Date).ThenByDescending(x => x.s.Number)
.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))
.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, Authorize(Roles = "Admin,Manager,Cashier")]
public async Task<ActionResult<RetailSaleDto>> Create([FromBody] RetailSaleInput input, CancellationToken ct)
{
var number = await GenerateNumberAsync(input.Date, ct);
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 = input.PaidCash,
PaidCard = input.PaidCard,
Notes = input.Notes,
};
ApplyLines(sale, input.Lines);
_db.RetailSales.Add(sale);
await _db.SaveChangesAsync(ct);
var dto = await GetInternal(sale.Id, ct);
return CreatedAtAction(nameof(Get), new { id = sale.Id }, dto);
}
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,Cashier")]
public async Task<IActionResult> Update(Guid id, [FromBody] RetailSaleInput input, 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.Draft)
return Conflict(new { error = "Только черновик может быть изменён." });
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 = input.PaidCash;
sale.PaidCard = input.PaidCard;
sale.Notes = input.Notes;
_db.RetailSaleLines.RemoveRange(sale.Lines);
sale.Lines.Clear();
ApplyLines(sale, input.Lines);
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,Manager")]
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"), Authorize(Roles = "Admin,Manager,Cashier")]
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 = "Нельзя провести пустой чек." });
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;
await _db.SaveChangesAsync(ct);
return NoContent();
}
[HttpPost("{id:guid}/unpost"), Authorize(Roles = "Admin,Manager")]
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 = "Чек не проведён." });
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();
}
private static void ApplyLines(RetailSale sale, IReadOnlyList<RetailSaleLineInput> input)
{
var order = 0;
decimal subtotal = 0, discountTotal = 0;
foreach (var l in input)
{
var lineTotal = l.Quantity * l.UnitPrice - l.Discount;
sale.Lines.Add(new RetailSaleLine
{
ProductId = l.ProductId,
Quantity = l.Quantity,
UnitPrice = l.UnitPrice,
Discount = l.Discount,
LineTotal = lineTotal,
VatPercent = l.VatPercent,
SortOrder = order++,
});
subtotal += l.Quantity * l.UnitPrice;
discountTotal += l.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))
.ToListAsync(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,
lines);
}
}