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 безопасен.
371 lines
16 KiB
C#
371 lines
16 KiB
C#
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);
|
||
}
|
||
}
|