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:
nurdotnet 2026-04-22 16:07:37 +05:00
parent 01f99cfff3
commit 1c108b88a4
12 changed files with 3245 additions and 1 deletions

View 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);
}
}

View 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; }
}

View file

@ -3,6 +3,7 @@
using foodmarket.Domain.Common; using foodmarket.Domain.Common;
using foodmarket.Domain.Inventory; using foodmarket.Domain.Inventory;
using foodmarket.Domain.Purchases; using foodmarket.Domain.Purchases;
using foodmarket.Domain.Sales;
using foodmarket.Infrastructure.Identity; using foodmarket.Infrastructure.Identity;
using foodmarket.Domain.Organizations; using foodmarket.Domain.Organizations;
using foodmarket.Infrastructure.Persistence.Configurations; using foodmarket.Infrastructure.Persistence.Configurations;
@ -43,6 +44,9 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
public DbSet<Supply> Supplies => Set<Supply>(); public DbSet<Supply> Supplies => Set<Supply>();
public DbSet<SupplyLine> SupplyLines => Set<SupplyLine>(); public DbSet<SupplyLine> SupplyLines => Set<SupplyLine>();
public DbSet<RetailSale> RetailSales => Set<RetailSale>();
public DbSet<RetailSaleLine> RetailSaleLines => Set<RetailSaleLine>();
protected override void OnModelCreating(ModelBuilder builder) protected override void OnModelCreating(ModelBuilder builder)
{ {
base.OnModelCreating(builder); base.OnModelCreating(builder);
@ -72,6 +76,7 @@ protected override void OnModelCreating(ModelBuilder builder)
builder.ConfigureCatalog(); builder.ConfigureCatalog();
builder.ConfigureInventory(); builder.ConfigureInventory();
builder.ConfigurePurchases(); builder.ConfigurePurchases();
builder.ConfigureSales();
// Apply multi-tenant query filter to every entity that implements ITenantEntity // Apply multi-tenant query filter to every entity that implements ITenantEntity
foreach (var entityType in builder.Model.GetEntityTypes()) foreach (var entityType in builder.Model.GetEntityTypes())

View file

@ -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 });
});
}
}

View file

@ -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");
}
}
}

View file

@ -1273,6 +1273,157 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.ToTable("supply_lines", "public"); 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 => modelBuilder.Entity("foodmarket.Infrastructure.Identity.Role", b =>
{ {
b.Property<Guid>("Id") b.Property<Guid>("Id")
@ -1674,6 +1825,58 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Navigation("Supply"); 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 => modelBuilder.Entity("OpenIddict.EntityFrameworkCore.Models.OpenIddictEntityFrameworkCoreApplication", b =>
{ {
b.Navigation("Authorizations"); b.Navigation("Authorizations");
@ -1704,6 +1907,11 @@ protected override void BuildModel(ModelBuilder modelBuilder)
{ {
b.Navigation("Lines"); b.Navigation("Lines");
}); });
modelBuilder.Entity("foodmarket.Domain.Sales.RetailSale", b =>
{
b.Navigation("Lines");
});
#pragma warning restore 612, 618 #pragma warning restore 612, 618
} }
} }

View file

@ -18,6 +18,8 @@ import { StockPage } from '@/pages/StockPage'
import { StockMovementsPage } from '@/pages/StockMovementsPage' import { StockMovementsPage } from '@/pages/StockMovementsPage'
import { SuppliesPage } from '@/pages/SuppliesPage' import { SuppliesPage } from '@/pages/SuppliesPage'
import { SupplyEditPage } from '@/pages/SupplyEditPage' import { SupplyEditPage } from '@/pages/SupplyEditPage'
import { RetailSalesPage } from '@/pages/RetailSalesPage'
import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage'
import { AppLayout } from '@/components/AppLayout' import { AppLayout } from '@/components/AppLayout'
import { ProtectedRoute } from '@/components/ProtectedRoute' import { ProtectedRoute } from '@/components/ProtectedRoute'
@ -56,6 +58,9 @@ export default function App() {
<Route path="/purchases/supplies" element={<SuppliesPage />} /> <Route path="/purchases/supplies" element={<SuppliesPage />} />
<Route path="/purchases/supplies/new" element={<SupplyEditPage />} /> <Route path="/purchases/supplies/new" element={<SupplyEditPage />} />
<Route path="/purchases/supplies/:id" 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 path="/admin/import/moysklad" element={<MoySkladImportPage />} />
</Route> </Route>
</Route> </Route>

View file

@ -6,7 +6,7 @@ import { cn } from '@/lib/utils'
import { import {
LayoutDashboard, Package, FolderTree, Ruler, Percent, Tag, LayoutDashboard, Package, FolderTree, Ruler, Percent, Tag,
Users, Warehouse, Store as StoreIcon, Globe, Coins, LogOut, Download, Users, Warehouse, Store as StoreIcon, Globe, Coins, LogOut, Download,
Boxes, History, TruckIcon, Boxes, History, TruckIcon, ShoppingCart,
} from 'lucide-react' } from 'lucide-react'
import { Logo } from './Logo' import { Logo } from './Logo'
@ -43,6 +43,9 @@ const nav = [
{ group: 'Закупки', items: [ { group: 'Закупки', items: [
{ to: '/purchases/supplies', icon: TruckIcon, label: 'Приёмки' }, { to: '/purchases/supplies', icon: TruckIcon, label: 'Приёмки' },
]}, ]},
{ group: 'Продажи', items: [
{ to: '/sales/retail', icon: ShoppingCart, label: 'Розничные чеки' },
]},
{ group: 'Справочники', items: [ { group: 'Справочники', items: [
{ to: '/catalog/countries', icon: Globe, label: 'Страны' }, { to: '/catalog/countries', icon: Globe, label: 'Страны' },
{ to: '/catalog/currencies', icon: Coins, label: 'Валюты' }, { to: '/catalog/currencies', icon: Coins, label: 'Валюты' },

View file

@ -97,3 +97,38 @@ export interface SupplyDto {
total: number; postedAt: string | null; total: number; postedAt: string | null;
lines: SupplyLineDto[]; 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[];
}

View 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>
)
}

View 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>
)
}