Domain (foodmarket.Domain.Sales):
- RetailSale: Number "ПР-{yyyy}-{NNNNNN}", Date, Status (Draft/Posted),
Store/RetailPoint/Customer/Currency, Subtotal/DiscountTotal/Total,
Payment (Cash/Card/BankTransfer/Bonus/Mixed) + PaidCash/PaidCard split,
CashierUserId, Notes, Lines.
- RetailSaleLine: ProductId, Quantity, UnitPrice, Discount, LineTotal,
VatPercent (snapshot), SortOrder.
- PaymentMethod enum.
EF: retail_sales + retail_sale_lines, unique index (tenant,Number),
indexes by date/status/cashier. Migration Phase2c_RetailSale.
API /api/sales/retail (Authorize):
- GET list with filters status/store/from/to/search.
- GET {id} with lines joined to products + units, customer/retail-point
names resolved.
- POST create draft (lines optional, totals computed server-side).
- PUT update — replaces lines wholesale; rejected if Posted.
- DELETE — drafts only.
- POST {id}/post — creates -qty StockMovements via IStockService for each
line (decreasing stock), Type=RetailSale; flips to Posted, stamps PostedAt.
- POST {id}/unpost — reverses with +qty movements tagged "retail-sale-reversal".
- Auto-numbering scoped per tenant + year.
Web:
- types: RetailSaleStatus, PaymentMethod, RetailSaleListRow, RetailSaleLineDto,
RetailSaleDto.
- /sales/retail list (number, date+time, status badge, store, cashier point,
customer (or "аноним"), payment method, line count, total).
- /sales/retail/new + /:id edit page mirrors Supply edit page UX:
sticky top bar (Back / Save / Post / Unpost / Delete), reqs grid with
date/store/customer/currency/payment/paid-cash/paid-card, lines table
with inline qty/price/discount + Subtotal/Discount/К оплате footer.
- ProductPicker reused. On line add, picks retail price from product's
prices list (matches "розн" in priceTypeName) or first.
- Sidebar new group "Продажи" → "Розничные чеки" (ShoppingCart).
Posting cycle ready: Supply (+stock) → ... → RetailSale (-stock).
В Stock и Движения видно текущее состояние и историю.
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
302 lines
12 KiB
C#
302 lines
12 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);
|
||
|
||
[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.Symbol,
|
||
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);
|
||
}
|
||
}
|