phase2c: RetailSale document — посты в stock как минусовые движения
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>
This commit is contained in:
parent
01f99cfff3
commit
1c108b88a4
301
src/food-market.api/Controllers/Sales/RetailSalesController.cs
Normal file
301
src/food-market.api/Controllers/Sales/RetailSalesController.cs
Normal file
|
|
@ -0,0 +1,301 @@
|
|||
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);
|
||||
}
|
||||
}
|
||||
70
src/food-market.domain/Sales/RetailSale.cs
Normal file
70
src/food-market.domain/Sales/RetailSale.cs
Normal file
|
|
@ -0,0 +1,70 @@
|
|||
using foodmarket.Domain.Catalog;
|
||||
using foodmarket.Domain.Common;
|
||||
|
||||
namespace foodmarket.Domain.Sales;
|
||||
|
||||
public enum RetailSaleStatus
|
||||
{
|
||||
Draft = 0,
|
||||
Posted = 1,
|
||||
}
|
||||
|
||||
public enum PaymentMethod
|
||||
{
|
||||
Cash = 0,
|
||||
Card = 1,
|
||||
BankTransfer = 2,
|
||||
Bonus = 3,
|
||||
Mixed = 99,
|
||||
}
|
||||
|
||||
public class RetailSale : TenantEntity
|
||||
{
|
||||
public string Number { get; set; } = "";
|
||||
public DateTime Date { get; set; } = DateTime.UtcNow;
|
||||
public RetailSaleStatus Status { get; set; } = RetailSaleStatus.Draft;
|
||||
|
||||
public Guid StoreId { get; set; }
|
||||
public Store Store { get; set; } = null!;
|
||||
|
||||
public Guid? RetailPointId { get; set; }
|
||||
public RetailPoint? RetailPoint { get; set; }
|
||||
|
||||
public Guid? CustomerId { get; set; }
|
||||
public Counterparty? Customer { get; set; }
|
||||
|
||||
public Guid? CashierUserId { get; set; }
|
||||
|
||||
public Guid CurrencyId { get; set; }
|
||||
public Currency Currency { get; set; } = null!;
|
||||
|
||||
public decimal Subtotal { get; set; } // sum of LineTotal before discount
|
||||
public decimal DiscountTotal { get; set; }
|
||||
public decimal Total { get; set; } // = Subtotal - DiscountTotal
|
||||
|
||||
public PaymentMethod Payment { get; set; } = PaymentMethod.Cash;
|
||||
public decimal PaidCash { get; set; }
|
||||
public decimal PaidCard { get; set; }
|
||||
|
||||
public string? Notes { get; set; }
|
||||
public DateTime? PostedAt { get; set; }
|
||||
public Guid? PostedByUserId { get; set; }
|
||||
|
||||
public ICollection<RetailSaleLine> Lines { get; set; } = new List<RetailSaleLine>();
|
||||
}
|
||||
|
||||
public class RetailSaleLine : TenantEntity
|
||||
{
|
||||
public Guid RetailSaleId { get; set; }
|
||||
public RetailSale RetailSale { get; set; } = null!;
|
||||
|
||||
public Guid ProductId { get; set; }
|
||||
public Product Product { get; set; } = null!;
|
||||
|
||||
public decimal Quantity { get; set; }
|
||||
public decimal UnitPrice { get; set; }
|
||||
public decimal Discount { get; set; }
|
||||
public decimal LineTotal { get; set; } // = Quantity * UnitPrice - Discount
|
||||
public decimal VatPercent { get; set; } // snapshot
|
||||
public int SortOrder { get; set; }
|
||||
}
|
||||
|
|
@ -3,6 +3,7 @@
|
|||
using foodmarket.Domain.Common;
|
||||
using foodmarket.Domain.Inventory;
|
||||
using foodmarket.Domain.Purchases;
|
||||
using foodmarket.Domain.Sales;
|
||||
using foodmarket.Infrastructure.Identity;
|
||||
using foodmarket.Domain.Organizations;
|
||||
using foodmarket.Infrastructure.Persistence.Configurations;
|
||||
|
|
@ -43,6 +44,9 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
|
|||
public DbSet<Supply> Supplies => Set<Supply>();
|
||||
public DbSet<SupplyLine> SupplyLines => Set<SupplyLine>();
|
||||
|
||||
public DbSet<RetailSale> RetailSales => Set<RetailSale>();
|
||||
public DbSet<RetailSaleLine> RetailSaleLines => Set<RetailSaleLine>();
|
||||
|
||||
protected override void OnModelCreating(ModelBuilder builder)
|
||||
{
|
||||
base.OnModelCreating(builder);
|
||||
|
|
@ -72,6 +76,7 @@ protected override void OnModelCreating(ModelBuilder builder)
|
|||
builder.ConfigureCatalog();
|
||||
builder.ConfigureInventory();
|
||||
builder.ConfigurePurchases();
|
||||
builder.ConfigureSales();
|
||||
|
||||
// Apply multi-tenant query filter to every entity that implements ITenantEntity
|
||||
foreach (var entityType in builder.Model.GetEntityTypes())
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
using foodmarket.Domain.Sales;
|
||||
using Microsoft.EntityFrameworkCore;
|
||||
|
||||
namespace foodmarket.Infrastructure.Persistence.Configurations;
|
||||
|
||||
public static class SalesConfigurations
|
||||
{
|
||||
public static void ConfigureSales(this ModelBuilder b)
|
||||
{
|
||||
b.Entity<RetailSale>(e =>
|
||||
{
|
||||
e.ToTable("retail_sales");
|
||||
e.Property(x => x.Number).HasMaxLength(50).IsRequired();
|
||||
e.Property(x => x.Notes).HasMaxLength(1000);
|
||||
e.Property(x => x.Subtotal).HasPrecision(18, 4);
|
||||
e.Property(x => x.DiscountTotal).HasPrecision(18, 4);
|
||||
e.Property(x => x.Total).HasPrecision(18, 4);
|
||||
e.Property(x => x.PaidCash).HasPrecision(18, 4);
|
||||
e.Property(x => x.PaidCard).HasPrecision(18, 4);
|
||||
|
||||
e.HasOne(x => x.Store).WithMany().HasForeignKey(x => x.StoreId).OnDelete(DeleteBehavior.Restrict);
|
||||
e.HasOne(x => x.RetailPoint).WithMany().HasForeignKey(x => x.RetailPointId).OnDelete(DeleteBehavior.Restrict);
|
||||
e.HasOne(x => x.Customer).WithMany().HasForeignKey(x => x.CustomerId).OnDelete(DeleteBehavior.Restrict);
|
||||
e.HasOne(x => x.Currency).WithMany().HasForeignKey(x => x.CurrencyId).OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
e.HasMany(x => x.Lines).WithOne(l => l.RetailSale).HasForeignKey(l => l.RetailSaleId).OnDelete(DeleteBehavior.Cascade);
|
||||
|
||||
e.HasIndex(x => new { x.OrganizationId, x.Number }).IsUnique();
|
||||
e.HasIndex(x => new { x.OrganizationId, x.Date });
|
||||
e.HasIndex(x => new { x.OrganizationId, x.Status });
|
||||
e.HasIndex(x => new { x.OrganizationId, x.CashierUserId });
|
||||
});
|
||||
|
||||
b.Entity<RetailSaleLine>(e =>
|
||||
{
|
||||
e.ToTable("retail_sale_lines");
|
||||
e.Property(x => x.Quantity).HasPrecision(18, 4);
|
||||
e.Property(x => x.UnitPrice).HasPrecision(18, 4);
|
||||
e.Property(x => x.Discount).HasPrecision(18, 4);
|
||||
e.Property(x => x.LineTotal).HasPrecision(18, 4);
|
||||
e.Property(x => x.VatPercent).HasPrecision(5, 2);
|
||||
|
||||
e.HasOne(x => x.Product).WithMany().HasForeignKey(x => x.ProductId).OnDelete(DeleteBehavior.Restrict);
|
||||
e.HasIndex(x => new { x.OrganizationId, x.ProductId });
|
||||
});
|
||||
}
|
||||
}
|
||||
1921
src/food-market.infrastructure/Persistence/Migrations/20260422110503_Phase2c_RetailSale.Designer.cs
generated
Normal file
1921
src/food-market.infrastructure/Persistence/Migrations/20260422110503_Phase2c_RetailSale.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,191 @@
|
|||
using System;
|
||||
using Microsoft.EntityFrameworkCore.Migrations;
|
||||
|
||||
#nullable disable
|
||||
|
||||
namespace foodmarket.Infrastructure.Persistence.Migrations
|
||||
{
|
||||
/// <inheritdoc />
|
||||
public partial class Phase2c_RetailSale : Migration
|
||||
{
|
||||
/// <inheritdoc />
|
||||
protected override void Up(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.CreateTable(
|
||||
name: "retail_sales",
|
||||
schema: "public",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Number = table.Column<string>(type: "character varying(50)", maxLength: 50, nullable: false),
|
||||
Date = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
Status = table.Column<int>(type: "integer", nullable: false),
|
||||
StoreId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
RetailPointId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
CustomerId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
CashierUserId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
CurrencyId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Subtotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
DiscountTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
Total = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
Payment = table.Column<int>(type: "integer", nullable: false),
|
||||
PaidCash = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
PaidCard = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
Notes = table.Column<string>(type: "character varying(1000)", maxLength: 1000, nullable: true),
|
||||
PostedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
PostedByUserId = table.Column<Guid>(type: "uuid", nullable: true),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
OrganizationId = table.Column<Guid>(type: "uuid", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_retail_sales", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_retail_sales_counterparties_CustomerId",
|
||||
column: x => x.CustomerId,
|
||||
principalSchema: "public",
|
||||
principalTable: "counterparties",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_retail_sales_currencies_CurrencyId",
|
||||
column: x => x.CurrencyId,
|
||||
principalSchema: "public",
|
||||
principalTable: "currencies",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_retail_sales_retail_points_RetailPointId",
|
||||
column: x => x.RetailPointId,
|
||||
principalSchema: "public",
|
||||
principalTable: "retail_points",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_retail_sales_stores_StoreId",
|
||||
column: x => x.StoreId,
|
||||
principalSchema: "public",
|
||||
principalTable: "stores",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateTable(
|
||||
name: "retail_sale_lines",
|
||||
schema: "public",
|
||||
columns: table => new
|
||||
{
|
||||
Id = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
RetailSaleId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
ProductId = table.Column<Guid>(type: "uuid", nullable: false),
|
||||
Quantity = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
UnitPrice = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
Discount = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
LineTotal = table.Column<decimal>(type: "numeric(18,4)", precision: 18, scale: 4, nullable: false),
|
||||
VatPercent = table.Column<decimal>(type: "numeric(5,2)", precision: 5, scale: 2, nullable: false),
|
||||
SortOrder = table.Column<int>(type: "integer", nullable: false),
|
||||
CreatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: false),
|
||||
UpdatedAt = table.Column<DateTime>(type: "timestamp with time zone", nullable: true),
|
||||
OrganizationId = table.Column<Guid>(type: "uuid", nullable: false)
|
||||
},
|
||||
constraints: table =>
|
||||
{
|
||||
table.PrimaryKey("PK_retail_sale_lines", x => x.Id);
|
||||
table.ForeignKey(
|
||||
name: "FK_retail_sale_lines_products_ProductId",
|
||||
column: x => x.ProductId,
|
||||
principalSchema: "public",
|
||||
principalTable: "products",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Restrict);
|
||||
table.ForeignKey(
|
||||
name: "FK_retail_sale_lines_retail_sales_RetailSaleId",
|
||||
column: x => x.RetailSaleId,
|
||||
principalSchema: "public",
|
||||
principalTable: "retail_sales",
|
||||
principalColumn: "Id",
|
||||
onDelete: ReferentialAction.Cascade);
|
||||
});
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_retail_sale_lines_OrganizationId_ProductId",
|
||||
schema: "public",
|
||||
table: "retail_sale_lines",
|
||||
columns: new[] { "OrganizationId", "ProductId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_retail_sale_lines_ProductId",
|
||||
schema: "public",
|
||||
table: "retail_sale_lines",
|
||||
column: "ProductId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_retail_sale_lines_RetailSaleId",
|
||||
schema: "public",
|
||||
table: "retail_sale_lines",
|
||||
column: "RetailSaleId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_retail_sales_CurrencyId",
|
||||
schema: "public",
|
||||
table: "retail_sales",
|
||||
column: "CurrencyId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_retail_sales_CustomerId",
|
||||
schema: "public",
|
||||
table: "retail_sales",
|
||||
column: "CustomerId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_retail_sales_OrganizationId_CashierUserId",
|
||||
schema: "public",
|
||||
table: "retail_sales",
|
||||
columns: new[] { "OrganizationId", "CashierUserId" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_retail_sales_OrganizationId_Date",
|
||||
schema: "public",
|
||||
table: "retail_sales",
|
||||
columns: new[] { "OrganizationId", "Date" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_retail_sales_OrganizationId_Number",
|
||||
schema: "public",
|
||||
table: "retail_sales",
|
||||
columns: new[] { "OrganizationId", "Number" },
|
||||
unique: true);
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_retail_sales_OrganizationId_Status",
|
||||
schema: "public",
|
||||
table: "retail_sales",
|
||||
columns: new[] { "OrganizationId", "Status" });
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_retail_sales_RetailPointId",
|
||||
schema: "public",
|
||||
table: "retail_sales",
|
||||
column: "RetailPointId");
|
||||
|
||||
migrationBuilder.CreateIndex(
|
||||
name: "IX_retail_sales_StoreId",
|
||||
schema: "public",
|
||||
table: "retail_sales",
|
||||
column: "StoreId");
|
||||
}
|
||||
|
||||
/// <inheritdoc />
|
||||
protected override void Down(MigrationBuilder migrationBuilder)
|
||||
{
|
||||
migrationBuilder.DropTable(
|
||||
name: "retail_sale_lines",
|
||||
schema: "public");
|
||||
|
||||
migrationBuilder.DropTable(
|
||||
name: "retail_sales",
|
||||
schema: "public");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -1273,6 +1273,157 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
|||
b.ToTable("supply_lines", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("foodmarket.Domain.Sales.RetailSale", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("CashierUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid>("CurrencyId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("CustomerId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("Date")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<decimal>("DiscountTotal")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<string>("Notes")
|
||||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<string>("Number")
|
||||
.IsRequired()
|
||||
.HasMaxLength(50)
|
||||
.HasColumnType("character varying(50)");
|
||||
|
||||
b.Property<Guid>("OrganizationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<decimal>("PaidCard")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<decimal>("PaidCash")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<int>("Payment")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<DateTime?>("PostedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<Guid?>("PostedByUserId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("RetailPointId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("Status")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<Guid>("StoreId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<decimal>("Subtotal")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<decimal>("Total")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("CurrencyId");
|
||||
|
||||
b.HasIndex("CustomerId");
|
||||
|
||||
b.HasIndex("RetailPointId");
|
||||
|
||||
b.HasIndex("StoreId");
|
||||
|
||||
b.HasIndex("OrganizationId", "CashierUserId");
|
||||
|
||||
b.HasIndex("OrganizationId", "Date");
|
||||
|
||||
b.HasIndex("OrganizationId", "Number")
|
||||
.IsUnique();
|
||||
|
||||
b.HasIndex("OrganizationId", "Status");
|
||||
|
||||
b.ToTable("retail_sales", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("foodmarket.Domain.Sales.RetailSaleLine", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
.ValueGeneratedOnAdd()
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<DateTime>("CreatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<decimal>("Discount")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<decimal>("LineTotal")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<Guid>("OrganizationId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid>("ProductId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<decimal>("Quantity")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<Guid>("RetailSaleId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<int>("SortOrder")
|
||||
.HasColumnType("integer");
|
||||
|
||||
b.Property<decimal>("UnitPrice")
|
||||
.HasPrecision(18, 4)
|
||||
.HasColumnType("numeric(18,4)");
|
||||
|
||||
b.Property<DateTime?>("UpdatedAt")
|
||||
.HasColumnType("timestamp with time zone");
|
||||
|
||||
b.Property<decimal>("VatPercent")
|
||||
.HasPrecision(5, 2)
|
||||
.HasColumnType("numeric(5,2)");
|
||||
|
||||
b.HasKey("Id");
|
||||
|
||||
b.HasIndex("ProductId");
|
||||
|
||||
b.HasIndex("RetailSaleId");
|
||||
|
||||
b.HasIndex("OrganizationId", "ProductId");
|
||||
|
||||
b.ToTable("retail_sale_lines", "public");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("foodmarket.Infrastructure.Identity.Role", b =>
|
||||
{
|
||||
b.Property<Guid>("Id")
|
||||
|
|
@ -1674,6 +1825,58 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
|||
b.Navigation("Supply");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("foodmarket.Domain.Sales.RetailSale", b =>
|
||||
{
|
||||
b.HasOne("foodmarket.Domain.Catalog.Currency", "Currency")
|
||||
.WithMany()
|
||||
.HasForeignKey("CurrencyId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("foodmarket.Domain.Catalog.Counterparty", "Customer")
|
||||
.WithMany()
|
||||
.HasForeignKey("CustomerId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("foodmarket.Domain.Catalog.RetailPoint", "RetailPoint")
|
||||
.WithMany()
|
||||
.HasForeignKey("RetailPointId")
|
||||
.OnDelete(DeleteBehavior.Restrict);
|
||||
|
||||
b.HasOne("foodmarket.Domain.Catalog.Store", "Store")
|
||||
.WithMany()
|
||||
.HasForeignKey("StoreId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Currency");
|
||||
|
||||
b.Navigation("Customer");
|
||||
|
||||
b.Navigation("RetailPoint");
|
||||
|
||||
b.Navigation("Store");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("foodmarket.Domain.Sales.RetailSaleLine", b =>
|
||||
{
|
||||
b.HasOne("foodmarket.Domain.Catalog.Product", "Product")
|
||||
.WithMany()
|
||||
.HasForeignKey("ProductId")
|
||||
.OnDelete(DeleteBehavior.Restrict)
|
||||
.IsRequired();
|
||||
|
||||
b.HasOne("foodmarket.Domain.Sales.RetailSale", "RetailSale")
|
||||
.WithMany("Lines")
|
||||
.HasForeignKey("RetailSaleId")
|
||||
.OnDelete(DeleteBehavior.Cascade)
|
||||
.IsRequired();
|
||||
|
||||
b.Navigation("Product");
|
||||
|
||||
b.Navigation("RetailSale");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b =>
|
||||
{
|
||||
b.Navigation("Authorizations");
|
||||
|
|
@ -1704,6 +1907,11 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
|||
{
|
||||
b.Navigation("Lines");
|
||||
});
|
||||
|
||||
modelBuilder.Entity("foodmarket.Domain.Sales.RetailSale", b =>
|
||||
{
|
||||
b.Navigation("Lines");
|
||||
});
|
||||
#pragma warning restore 612, 618
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,6 +18,8 @@ import { StockPage } from '@/pages/StockPage'
|
|||
import { StockMovementsPage } from '@/pages/StockMovementsPage'
|
||||
import { SuppliesPage } from '@/pages/SuppliesPage'
|
||||
import { SupplyEditPage } from '@/pages/SupplyEditPage'
|
||||
import { RetailSalesPage } from '@/pages/RetailSalesPage'
|
||||
import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage'
|
||||
import { AppLayout } from '@/components/AppLayout'
|
||||
import { ProtectedRoute } from '@/components/ProtectedRoute'
|
||||
|
||||
|
|
@ -56,6 +58,9 @@ export default function App() {
|
|||
<Route path="/purchases/supplies" element={<SuppliesPage />} />
|
||||
<Route path="/purchases/supplies/new" element={<SupplyEditPage />} />
|
||||
<Route path="/purchases/supplies/:id" element={<SupplyEditPage />} />
|
||||
<Route path="/sales/retail" element={<RetailSalesPage />} />
|
||||
<Route path="/sales/retail/new" element={<RetailSaleEditPage />} />
|
||||
<Route path="/sales/retail/:id" element={<RetailSaleEditPage />} />
|
||||
<Route path="/admin/import/moysklad" element={<MoySkladImportPage />} />
|
||||
</Route>
|
||||
</Route>
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import { cn } from '@/lib/utils'
|
|||
import {
|
||||
LayoutDashboard, Package, FolderTree, Ruler, Percent, Tag,
|
||||
Users, Warehouse, Store as StoreIcon, Globe, Coins, LogOut, Download,
|
||||
Boxes, History, TruckIcon,
|
||||
Boxes, History, TruckIcon, ShoppingCart,
|
||||
} from 'lucide-react'
|
||||
import { Logo } from './Logo'
|
||||
|
||||
|
|
@ -43,6 +43,9 @@ const nav = [
|
|||
{ group: 'Закупки', items: [
|
||||
{ to: '/purchases/supplies', icon: TruckIcon, label: 'Приёмки' },
|
||||
]},
|
||||
{ group: 'Продажи', items: [
|
||||
{ to: '/sales/retail', icon: ShoppingCart, label: 'Розничные чеки' },
|
||||
]},
|
||||
{ group: 'Справочники', items: [
|
||||
{ to: '/catalog/countries', icon: Globe, label: 'Страны' },
|
||||
{ to: '/catalog/currencies', icon: Coins, label: 'Валюты' },
|
||||
|
|
|
|||
|
|
@ -97,3 +97,38 @@ export interface SupplyDto {
|
|||
total: number; postedAt: string | null;
|
||||
lines: SupplyLineDto[];
|
||||
}
|
||||
|
||||
export const RetailSaleStatus = { Draft: 0, Posted: 1 } as const
|
||||
export type RetailSaleStatus = (typeof RetailSaleStatus)[keyof typeof RetailSaleStatus]
|
||||
|
||||
export const PaymentMethod = { Cash: 0, Card: 1, BankTransfer: 2, Bonus: 3, Mixed: 99 } as const
|
||||
export type PaymentMethod = (typeof PaymentMethod)[keyof typeof PaymentMethod]
|
||||
|
||||
export interface RetailSaleListRow {
|
||||
id: string; number: string; date: string; status: RetailSaleStatus;
|
||||
storeId: string; storeName: string;
|
||||
retailPointId: string | null; retailPointName: string | null;
|
||||
customerId: string | null; customerName: string | null;
|
||||
currencyId: string; currencyCode: string;
|
||||
total: number; payment: PaymentMethod; lineCount: number;
|
||||
postedAt: string | null;
|
||||
}
|
||||
|
||||
export interface RetailSaleLineDto {
|
||||
id: string | null; productId: string;
|
||||
productName: string | null; productArticle: string | null; unitSymbol: string | null;
|
||||
quantity: number; unitPrice: number; discount: number; lineTotal: number;
|
||||
vatPercent: number; sortOrder: number;
|
||||
}
|
||||
|
||||
export interface RetailSaleDto {
|
||||
id: string; number: string; date: string; status: RetailSaleStatus;
|
||||
storeId: string; storeName: string;
|
||||
retailPointId: string | null; retailPointName: string | null;
|
||||
customerId: string | null; customerName: string | null;
|
||||
currencyId: string; currencyCode: string;
|
||||
subtotal: number; discountTotal: number; total: number;
|
||||
payment: PaymentMethod; paidCash: number; paidCard: number;
|
||||
notes: string | null; postedAt: string | null;
|
||||
lines: RetailSaleLineDto[];
|
||||
}
|
||||
|
|
|
|||
393
src/food-market.web/src/pages/RetailSaleEditPage.tsx
Normal file
393
src/food-market.web/src/pages/RetailSaleEditPage.tsx
Normal file
|
|
@ -0,0 +1,393 @@
|
|||
import { useState, useEffect, type FormEvent, type ReactNode } from 'react'
|
||||
import { useNavigate, useParams, Link } from 'react-router-dom'
|
||||
import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query'
|
||||
import { ArrowLeft, Plus, Trash2, Save, CheckCircle, Undo2 } from 'lucide-react'
|
||||
import { api } from '@/lib/api'
|
||||
import { Button } from '@/components/Button'
|
||||
import { Field, TextInput, TextArea, Select } from '@/components/Field'
|
||||
import { ProductPicker } from '@/components/ProductPicker'
|
||||
import { useStores, useCurrencies, useSuppliers } from '@/lib/useLookups'
|
||||
import { RetailSaleStatus, PaymentMethod, type RetailSaleDto, type Product } from '@/lib/types'
|
||||
|
||||
interface LineRow {
|
||||
id?: string
|
||||
productId: string
|
||||
productName: string
|
||||
productArticle: string | null
|
||||
unitSymbol: string | null
|
||||
quantity: number
|
||||
unitPrice: number
|
||||
discount: number
|
||||
vatPercent: number
|
||||
}
|
||||
|
||||
interface Form {
|
||||
date: string
|
||||
storeId: string
|
||||
retailPointId: string
|
||||
customerId: string
|
||||
currencyId: string
|
||||
payment: PaymentMethod
|
||||
paidCash: number
|
||||
paidCard: number
|
||||
notes: string
|
||||
lines: LineRow[]
|
||||
}
|
||||
|
||||
const todayIso = () => new Date().toISOString().slice(0, 16)
|
||||
|
||||
const empty: Form = {
|
||||
date: todayIso(),
|
||||
storeId: '', retailPointId: '', customerId: '', currencyId: '',
|
||||
payment: PaymentMethod.Cash, paidCash: 0, paidCard: 0,
|
||||
notes: '', lines: [],
|
||||
}
|
||||
|
||||
export function RetailSaleEditPage() {
|
||||
const { id } = useParams<{ id: string }>()
|
||||
const isNew = !id || id === 'new'
|
||||
const navigate = useNavigate()
|
||||
const qc = useQueryClient()
|
||||
|
||||
const stores = useStores()
|
||||
const currencies = useCurrencies()
|
||||
const customers = useSuppliers() // we re-use the suppliers hook (returns all counterparties) — fine for MVP
|
||||
|
||||
const [form, setForm] = useState<Form>(empty)
|
||||
const [pickerOpen, setPickerOpen] = useState(false)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
|
||||
const existing = useQuery({
|
||||
queryKey: ['/api/sales/retail', id],
|
||||
queryFn: async () => (await api.get<RetailSaleDto>(`/api/sales/retail/${id}`)).data,
|
||||
enabled: !isNew,
|
||||
})
|
||||
|
||||
useEffect(() => {
|
||||
if (!isNew && existing.data) {
|
||||
const s = existing.data
|
||||
setForm({
|
||||
date: s.date.slice(0, 16),
|
||||
storeId: s.storeId,
|
||||
retailPointId: s.retailPointId ?? '',
|
||||
customerId: s.customerId ?? '',
|
||||
currencyId: s.currencyId,
|
||||
payment: s.payment,
|
||||
paidCash: s.paidCash,
|
||||
paidCard: s.paidCard,
|
||||
notes: s.notes ?? '',
|
||||
lines: s.lines.map((l) => ({
|
||||
id: l.id ?? undefined,
|
||||
productId: l.productId,
|
||||
productName: l.productName ?? '',
|
||||
productArticle: l.productArticle,
|
||||
unitSymbol: l.unitSymbol,
|
||||
quantity: l.quantity, unitPrice: l.unitPrice, discount: l.discount,
|
||||
vatPercent: l.vatPercent,
|
||||
})),
|
||||
})
|
||||
}
|
||||
}, [isNew, existing.data])
|
||||
|
||||
useEffect(() => {
|
||||
if (isNew) {
|
||||
if (!form.storeId && stores.data?.length) {
|
||||
setForm((f) => ({ ...f, storeId: stores.data!.find((s) => s.isMain)?.id ?? stores.data![0].id }))
|
||||
}
|
||||
if (!form.currencyId && currencies.data?.length) {
|
||||
setForm((f) => ({ ...f, currencyId: currencies.data!.find((c) => c.code === 'KZT')?.id ?? currencies.data![0].id }))
|
||||
}
|
||||
}
|
||||
}, [isNew, stores.data, currencies.data, form.storeId, form.currencyId])
|
||||
|
||||
const isDraft = isNew || existing.data?.status === RetailSaleStatus.Draft
|
||||
const isPosted = existing.data?.status === RetailSaleStatus.Posted
|
||||
|
||||
const lineTotal = (l: LineRow) => l.quantity * l.unitPrice - l.discount
|
||||
const subtotal = form.lines.reduce((s, l) => s + l.quantity * l.unitPrice, 0)
|
||||
const discountTotal = form.lines.reduce((s, l) => s + l.discount, 0)
|
||||
const grandTotal = subtotal - discountTotal
|
||||
|
||||
const save = useMutation({
|
||||
mutationFn: async () => {
|
||||
const payload = {
|
||||
date: new Date(form.date).toISOString(),
|
||||
storeId: form.storeId,
|
||||
retailPointId: form.retailPointId || null,
|
||||
customerId: form.customerId || null,
|
||||
currencyId: form.currencyId,
|
||||
payment: form.payment,
|
||||
paidCash: Number(form.paidCash),
|
||||
paidCard: Number(form.paidCard),
|
||||
notes: form.notes || null,
|
||||
lines: form.lines.map((l) => ({
|
||||
productId: l.productId,
|
||||
quantity: l.quantity,
|
||||
unitPrice: l.unitPrice,
|
||||
discount: l.discount,
|
||||
vatPercent: l.vatPercent,
|
||||
})),
|
||||
}
|
||||
if (isNew) {
|
||||
return (await api.post<RetailSaleDto>('/api/sales/retail', payload)).data
|
||||
}
|
||||
await api.put(`/api/sales/retail/${id}`, payload)
|
||||
return null
|
||||
},
|
||||
onSuccess: (created) => {
|
||||
qc.invalidateQueries({ queryKey: ['/api/sales/retail'] })
|
||||
navigate(created ? `/sales/retail/${created.id}` : `/sales/retail/${id}`)
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
})
|
||||
|
||||
const post = useMutation({
|
||||
mutationFn: async () => { await api.post(`/api/sales/retail/${id}/post`) },
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['/api/sales/retail'] })
|
||||
qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] })
|
||||
qc.invalidateQueries({ queryKey: ['/api/inventory/movements'] })
|
||||
existing.refetch()
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
})
|
||||
|
||||
const unpost = useMutation({
|
||||
mutationFn: async () => { await api.post(`/api/sales/retail/${id}/unpost`) },
|
||||
onSuccess: () => {
|
||||
qc.invalidateQueries({ queryKey: ['/api/sales/retail'] })
|
||||
qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] })
|
||||
qc.invalidateQueries({ queryKey: ['/api/inventory/movements'] })
|
||||
existing.refetch()
|
||||
},
|
||||
onError: (e: Error) => setError(e.message),
|
||||
})
|
||||
|
||||
const remove = useMutation({
|
||||
mutationFn: async () => { await api.delete(`/api/sales/retail/${id}`) },
|
||||
onSuccess: () => navigate('/sales/retail'),
|
||||
onError: (e: Error) => setError(e.message),
|
||||
})
|
||||
|
||||
const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() }
|
||||
|
||||
const addLineFromProduct = (p: Product) => {
|
||||
const retail = p.prices.find((x) => x.priceTypeName?.toLowerCase().includes('розн')) ?? p.prices[0]
|
||||
setForm({
|
||||
...form,
|
||||
lines: [...form.lines, {
|
||||
productId: p.id,
|
||||
productName: p.name,
|
||||
productArticle: p.article,
|
||||
unitSymbol: p.unitSymbol,
|
||||
quantity: 1,
|
||||
unitPrice: retail?.amount ?? 0,
|
||||
discount: 0,
|
||||
vatPercent: p.vatPercent,
|
||||
}],
|
||||
})
|
||||
}
|
||||
const updateLine = (i: number, patch: Partial<LineRow>) =>
|
||||
setForm({ ...form, lines: form.lines.map((l, ix) => ix === i ? { ...l, ...patch } : l) })
|
||||
const removeLine = (i: number) =>
|
||||
setForm({ ...form, lines: form.lines.filter((_, ix) => ix !== i) })
|
||||
|
||||
const canSave = !!form.storeId && !!form.currencyId && isDraft
|
||||
|
||||
return (
|
||||
<form onSubmit={onSubmit} className="flex flex-col h-full">
|
||||
<div className="flex items-center justify-between gap-4 px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">
|
||||
<div className="flex items-center gap-3 min-w-0">
|
||||
<Link to="/sales/retail" className="text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 flex-shrink-0">
|
||||
<ArrowLeft className="w-5 h-5" />
|
||||
</Link>
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate">
|
||||
{isNew ? 'Новый чек' : existing.data?.number ?? 'Чек'}
|
||||
</h1>
|
||||
<p className="text-xs text-slate-500">
|
||||
{isPosted
|
||||
? <span className="text-green-700 inline-flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}</span>
|
||||
: 'Черновик — товар не списывается со склада до проведения'}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex gap-2 flex-shrink-0">
|
||||
{isPosted && (
|
||||
<Button type="button" variant="secondary" onClick={() => unpost.mutate()} disabled={unpost.isPending}>
|
||||
<Undo2 className="w-4 h-4" /> Отменить
|
||||
</Button>
|
||||
)}
|
||||
{isDraft && !isNew && (
|
||||
<Button type="button" variant="danger" size="sm" onClick={() => { if (confirm('Удалить черновик?')) remove.mutate() }}>
|
||||
<Trash2 className="w-4 h-4" /> Удалить
|
||||
</Button>
|
||||
)}
|
||||
{isDraft && (
|
||||
<Button type="submit" variant="secondary" disabled={!canSave || save.isPending}>
|
||||
<Save className="w-4 h-4" /> {save.isPending ? 'Сохраняю…' : 'Сохранить'}
|
||||
</Button>
|
||||
)}
|
||||
{isDraft && !isNew && (
|
||||
<Button type="button" onClick={() => post.mutate()} disabled={post.isPending || form.lines.length === 0}>
|
||||
<CheckCircle className="w-4 h-4" /> {post.isPending ? 'Провожу…' : 'Провести'}
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-auto">
|
||||
<div className="max-w-6xl mx-auto p-6 space-y-5">
|
||||
{error && <div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>}
|
||||
|
||||
<Section title="Реквизиты чека">
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-4 gap-y-3">
|
||||
<Field label="Дата/время">
|
||||
<TextInput type="datetime-local" value={form.date} disabled={isPosted}
|
||||
onChange={(e) => setForm({ ...form, date: e.target.value })} />
|
||||
</Field>
|
||||
<Field label="Магазин *">
|
||||
<Select value={form.storeId} disabled={isPosted}
|
||||
onChange={(e) => setForm({ ...form, storeId: e.target.value })}>
|
||||
<option value="">—</option>
|
||||
{stores.data?.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Валюта *">
|
||||
<Select value={form.currencyId} disabled={isPosted}
|
||||
onChange={(e) => setForm({ ...form, currencyId: e.target.value })}>
|
||||
<option value="">—</option>
|
||||
{currencies.data?.map((c) => <option key={c.id} value={c.id}>{c.code}</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Покупатель (опц.)">
|
||||
<Select value={form.customerId} disabled={isPosted}
|
||||
onChange={(e) => setForm({ ...form, customerId: e.target.value })}>
|
||||
<option value="">— анонимный —</option>
|
||||
{customers.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Способ оплаты">
|
||||
<Select value={form.payment} disabled={isPosted}
|
||||
onChange={(e) => setForm({ ...form, payment: Number(e.target.value) as PaymentMethod })}>
|
||||
<option value={PaymentMethod.Cash}>Наличные</option>
|
||||
<option value={PaymentMethod.Card}>Карта</option>
|
||||
<option value={PaymentMethod.BankTransfer}>Банковский перевод</option>
|
||||
<option value={PaymentMethod.Bonus}>Бонусы</option>
|
||||
<option value={PaymentMethod.Mixed}>Смешанная</option>
|
||||
</Select>
|
||||
</Field>
|
||||
<Field label="Получено наличными">
|
||||
<TextInput type="number" step="0.01" value={form.paidCash} disabled={isPosted}
|
||||
onChange={(e) => setForm({ ...form, paidCash: Number(e.target.value) })} />
|
||||
</Field>
|
||||
<Field label="Получено картой">
|
||||
<TextInput type="number" step="0.01" value={form.paidCard} disabled={isPosted}
|
||||
onChange={(e) => setForm({ ...form, paidCard: Number(e.target.value) })} />
|
||||
</Field>
|
||||
<Field label="Примечание" className="md:col-span-3">
|
||||
<TextArea rows={2} value={form.notes} disabled={isPosted}
|
||||
onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
||||
</Field>
|
||||
</div>
|
||||
</Section>
|
||||
|
||||
<Section
|
||||
title="Позиции"
|
||||
action={!isPosted && (
|
||||
<Button type="button" variant="secondary" size="sm" onClick={() => setPickerOpen(true)}>
|
||||
<Plus className="w-3.5 h-3.5" /> Добавить товар
|
||||
</Button>
|
||||
)}
|
||||
>
|
||||
{form.lines.length === 0 ? (
|
||||
<div className="text-sm text-slate-400 py-4 text-center">Пусто. Добавь хотя бы одну позицию.</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-slate-200 dark:border-slate-700 text-left">
|
||||
<th className="py-2 pr-3 font-medium text-xs uppercase tracking-wide text-slate-500">Товар</th>
|
||||
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[80px]">Ед.</th>
|
||||
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[120px] text-right">Кол-во</th>
|
||||
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[120px] text-right">Цена</th>
|
||||
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[110px] text-right">Скидка</th>
|
||||
<th className="py-2 px-3 font-medium text-xs uppercase tracking-wide text-slate-500 w-[140px] text-right">Сумма</th>
|
||||
<th className="py-2 pl-3 w-[40px]"></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{form.lines.map((l, i) => (
|
||||
<tr key={i} className="border-b border-slate-100 dark:border-slate-800">
|
||||
<td className="py-2 pr-3">
|
||||
<div className="font-medium">{l.productName}</div>
|
||||
{l.productArticle && <div className="text-xs text-slate-400 font-mono">{l.productArticle}</div>}
|
||||
</td>
|
||||
<td className="py-2 px-3 text-slate-500">{l.unitSymbol}</td>
|
||||
<td className="py-2 px-3">
|
||||
<TextInput type="number" step="0.001" disabled={isPosted}
|
||||
className="text-right font-mono" value={l.quantity}
|
||||
onChange={(e) => updateLine(i, { quantity: Number(e.target.value) })} />
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<TextInput type="number" step="0.01" disabled={isPosted}
|
||||
className="text-right font-mono" value={l.unitPrice}
|
||||
onChange={(e) => updateLine(i, { unitPrice: Number(e.target.value) })} />
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<TextInput type="number" step="0.01" disabled={isPosted}
|
||||
className="text-right font-mono" value={l.discount}
|
||||
onChange={(e) => updateLine(i, { discount: Number(e.target.value) })} />
|
||||
</td>
|
||||
<td className="py-2 px-3 text-right font-mono font-semibold">
|
||||
{lineTotal(l).toLocaleString('ru', { maximumFractionDigits: 2 })}
|
||||
</td>
|
||||
<td className="py-2 pl-3">
|
||||
{!isPosted && (
|
||||
<button type="button" onClick={() => removeLine(i)} className="text-slate-400 hover:text-red-600">
|
||||
<Trash2 className="w-4 h-4" />
|
||||
</button>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
<tfoot>
|
||||
<tr><td colSpan={4} className="py-2 pr-3 text-right text-sm text-slate-500">Подытог:</td>
|
||||
<td className="py-2 px-3 text-right text-sm text-slate-500"></td>
|
||||
<td className="py-2 px-3 text-right font-mono">{subtotal.toLocaleString('ru', { maximumFractionDigits: 2 })}</td>
|
||||
<td/></tr>
|
||||
<tr><td colSpan={4} className="py-1 pr-3 text-right text-sm text-slate-500">Скидка:</td>
|
||||
<td className="py-1 px-3"></td>
|
||||
<td className="py-1 px-3 text-right font-mono text-red-600">−{discountTotal.toLocaleString('ru', { maximumFractionDigits: 2 })}</td>
|
||||
<td/></tr>
|
||||
<tr><td colSpan={4} className="py-3 pr-3 text-right text-sm font-semibold text-slate-700 dark:text-slate-200">К оплате:</td>
|
||||
<td/>
|
||||
<td className="py-3 px-3 text-right font-mono text-lg font-bold">
|
||||
{grandTotal.toLocaleString('ru', { maximumFractionDigits: 2 })}{' '}
|
||||
<span className="text-sm text-slate-500">{currencies.data?.find((c) => c.id === form.currencyId)?.code ?? ''}</span>
|
||||
</td><td/></tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</Section>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<ProductPicker open={pickerOpen} onClose={() => setPickerOpen(false)} onPick={addLineFromProduct} />
|
||||
</form>
|
||||
)
|
||||
}
|
||||
|
||||
function Section({ title, children, action }: { title: string; children: ReactNode; action?: ReactNode }) {
|
||||
return (
|
||||
<section className="bg-white dark:bg-slate-900 rounded-xl border border-slate-200 dark:border-slate-800 overflow-hidden">
|
||||
<header className="flex items-center justify-between px-5 py-3 border-b border-slate-100 dark:border-slate-800 bg-slate-50/50 dark:bg-slate-800/30">
|
||||
<h2 className="text-sm font-semibold text-slate-900 dark:text-slate-100">{title}</h2>
|
||||
{action}
|
||||
</header>
|
||||
<div className="p-5">{children}</div>
|
||||
</section>
|
||||
)
|
||||
}
|
||||
65
src/food-market.web/src/pages/RetailSalesPage.tsx
Normal file
65
src/food-market.web/src/pages/RetailSalesPage.tsx
Normal file
|
|
@ -0,0 +1,65 @@
|
|||
import { Link, useNavigate } from 'react-router-dom'
|
||||
import { Plus } from 'lucide-react'
|
||||
import { ListPageShell } from '@/components/ListPageShell'
|
||||
import { DataTable } from '@/components/DataTable'
|
||||
import { Pagination } from '@/components/Pagination'
|
||||
import { SearchBar } from '@/components/SearchBar'
|
||||
import { Button } from '@/components/Button'
|
||||
import { useCatalogList } from '@/lib/useCatalog'
|
||||
import { type RetailSaleListRow, RetailSaleStatus, PaymentMethod } from '@/lib/types'
|
||||
|
||||
const URL = '/api/sales/retail'
|
||||
|
||||
const paymentLabel: Record<number, string> = {
|
||||
[PaymentMethod.Cash]: 'Наличные',
|
||||
[PaymentMethod.Card]: 'Карта',
|
||||
[PaymentMethod.BankTransfer]: 'Перевод',
|
||||
[PaymentMethod.Bonus]: 'Бонусы',
|
||||
[PaymentMethod.Mixed]: 'Смешанная',
|
||||
}
|
||||
|
||||
export function RetailSalesPage() {
|
||||
const navigate = useNavigate()
|
||||
const { data, isLoading, page, setPage, search, setSearch } = useCatalogList<RetailSaleListRow>(URL)
|
||||
|
||||
return (
|
||||
<ListPageShell
|
||||
title="Розничные продажи"
|
||||
description={data ? `${data.total.toLocaleString('ru')} чеков` : 'Чеки розничных продаж'}
|
||||
actions={
|
||||
<>
|
||||
<SearchBar value={search} onChange={setSearch} placeholder="По номеру чека…" />
|
||||
<Link to="/sales/retail/new">
|
||||
<Button><Plus className="w-4 h-4" /> Новый чек</Button>
|
||||
</Link>
|
||||
</>
|
||||
}
|
||||
footer={data && data.total > 0 && (
|
||||
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
|
||||
)}
|
||||
>
|
||||
<DataTable
|
||||
rows={data?.items ?? []}
|
||||
isLoading={isLoading}
|
||||
rowKey={(r) => r.id}
|
||||
onRowClick={(r) => navigate(`/sales/retail/${r.id}`)}
|
||||
columns={[
|
||||
{ header: '№', width: '160px', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> },
|
||||
{ header: 'Дата/время', width: '160px', cell: (r) => new Date(r.date).toLocaleString('ru') },
|
||||
{ header: 'Статус', width: '120px', cell: (r) => (
|
||||
r.status === RetailSaleStatus.Posted
|
||||
? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Проведён</span>
|
||||
: <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">Черновик</span>
|
||||
)},
|
||||
{ header: 'Магазин', cell: (r) => r.storeName },
|
||||
{ header: 'Касса', width: '160px', cell: (r) => r.retailPointName ?? '—' },
|
||||
{ header: 'Покупатель', width: '180px', cell: (r) => r.customerName ?? <span className="text-slate-400">аноним</span> },
|
||||
{ header: 'Оплата', width: '120px', cell: (r) => paymentLabel[r.payment] ?? '?' },
|
||||
{ header: 'Позиций', width: '90px', className: 'text-right', cell: (r) => r.lineCount },
|
||||
{ header: 'Сумма', width: '160px', className: 'text-right font-mono font-semibold', cell: (r) => `${r.total.toLocaleString('ru', { maximumFractionDigits: 2 })} ${r.currencyCode}` },
|
||||
]}
|
||||
empty="Чеков пока нет. На Phase 5 здесь начнут падать продажи с касс. Сейчас можно создать вручную для теста."
|
||||
/>
|
||||
</ListPageShell>
|
||||
)
|
||||
}
|
||||
Loading…
Reference in a new issue