From 47a019dc6dc0c65aed6267e99c15c6602d77349b Mon Sep 17 00:00:00 2001 From: nns Date: Thu, 28 May 2026 16:18:49 +0500 Subject: [PATCH] =?UTF-8?q?feat(demands):=20=D0=BE=D0=BF=D1=82=D0=BE=D0=B2?= =?UTF-8?q?=D0=B0=D1=8F=20=D0=BE=D1=82=D0=B3=D1=80=D1=83=D0=B7=D0=BA=D0=B0?= =?UTF-8?q?=20=D0=BA=D0=BE=D0=BD=D1=82=D1=80=D0=B0=D0=B3=D0=B5=D0=BD=D1=82?= =?UTF-8?q?=D1=83-=D1=8E=D1=80=D0=BB=D0=B8=D1=86=D1=83=20(P1-5)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Domain Demand+DemandLine - зеркалит RetailSale, но всегда с CustomerId (обязателен, не nullable), способ оплаты DemandPayment с Credit (постоплата = дебиторка), без RetailPoint/Cashier. EF + миграция Phase8a_Demands (idempotent CREATE TABLE). Контроллер api/sales/demands - CRUD + Post/Unpost. Post создаёт StockMovement тип WholesaleSale с -Quantity; защита от ухода в минус (409 со списком конфликтов). Unpost возвращает товар. ApplyLines пишет в DbSet напрямую (не через nav-collection) и Update использует ExecuteDelete для старых строк - тот же fix-паттерн что в RetailSalesController (избегает DbUpdateConcurrency на client-side Id). Permissions переиспользуют DemandsEdit/DemandsPost (уже в RolePermissions). Метрики observability: food_market_documents_posted_total{type="demand"} и documents_error_total{type="demand", reason="serialization"}. Web: /sales/demands (list+edit) с AsyncSelect контрагентов, способом оплаты включая Credit, PaidAmount-полем для дебиторки. Сайдбар: "Оптовые отгрузки" в группе Продажи для Admin. Тесты: 3 интеграционных (post снижает stock + unpost восстанавливает, over-stock posting -> 409 без побочных эффектов, tenant-изоляция). Co-Authored-By: Claude Opus 4.7 --- .../Controllers/Sales/DemandsController.cs | 402 +++++++++++++++++ src/food-market.domain/Sales/Demand.cs | 77 ++++ .../Persistence/AppDbContext.cs | 3 + .../Configurations/SalesConfigurations.cs | 35 ++ .../20260528200000_Phase8a_Demands.cs | 83 ++++ src/food-market.web/src/App.tsx | 5 + .../src/components/AppLayout.tsx | 3 +- src/food-market.web/src/lib/types.ts | 41 ++ .../src/pages/DemandEditPage.tsx | 403 ++++++++++++++++++ src/food-market.web/src/pages/DemandsPage.tsx | 66 +++ .../DemandPostUnpostTests.cs | 110 +++++ 11 files changed, 1227 insertions(+), 1 deletion(-) create mode 100644 src/food-market.api/Controllers/Sales/DemandsController.cs create mode 100644 src/food-market.domain/Sales/Demand.cs create mode 100644 src/food-market.infrastructure/Persistence/Migrations/20260528200000_Phase8a_Demands.cs create mode 100644 src/food-market.web/src/pages/DemandEditPage.tsx create mode 100644 src/food-market.web/src/pages/DemandsPage.tsx create mode 100644 tests/food-market.IntegrationTests/DemandPostUnpostTests.cs diff --git a/src/food-market.api/Controllers/Sales/DemandsController.cs b/src/food-market.api/Controllers/Sales/DemandsController.cs new file mode 100644 index 0000000..5510267 --- /dev/null +++ b/src/food-market.api/Controllers/Sales/DemandsController.cs @@ -0,0 +1,402 @@ +using System.ComponentModel.DataAnnotations; +using foodmarket.Application.Common; +using foodmarket.Application.Inventory; +using foodmarket.Domain.Inventory; +using foodmarket.Domain.Sales; +using foodmarket.Infrastructure.Persistence; +using foodmarket.Api.Infrastructure.Authorization; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Sales; + +/// Оптовая отгрузка (Demand). Списывает товар со склада в адрес +/// юрлица-контрагента. Зеркалит , но +/// без RetailPoint/Cashier и с другим способом оплаты (Credit вместо Mixed/Bonus). +/// Множ. возвратов нет в MVP — учётный шаг без подтверждения дебиторки. +[ApiController] +[Authorize] +[Route("api/sales/demands")] +public class DemandsController : ControllerBase +{ + private readonly AppDbContext _db; + private readonly IStockService _stock; + + public DemandsController(AppDbContext db, IStockService stock) + { + _db = db; + _stock = stock; + } + + public record DemandListRow( + Guid Id, string Number, DateTime Date, DemandStatus Status, + Guid CustomerId, string CustomerName, + Guid StoreId, string StoreName, + Guid CurrencyId, string CurrencyCode, + decimal Total, decimal PaidAmount, + DemandPayment Payment, int LineCount, + DateTime? PostedAt); + + public record DemandLineDto( + Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, string? UnitSymbol, + decimal Quantity, decimal UnitPrice, decimal Discount, decimal LineTotal, + decimal VatPercent, int SortOrder, + decimal? StockAtStore); + + public record DemandDto( + Guid Id, string Number, DateTime Date, DemandStatus Status, + Guid CustomerId, string CustomerName, + Guid StoreId, string StoreName, + Guid CurrencyId, string CurrencyCode, + DemandPayment Payment, decimal Subtotal, decimal DiscountTotal, + decimal Total, decimal PaidAmount, + string? Notes, DateTime? PostedAt, + IReadOnlyList Lines); + + public record DemandLineInput( + Guid ProductId, + [Range(0, 1e10)] decimal Quantity, + [Range(0, 1e10)] decimal UnitPrice, + [Range(0, 1e10)] decimal Discount, + [Range(0, 100)] decimal VatPercent); + + public record DemandInput( + DateTime Date, + Guid CustomerId, Guid StoreId, Guid CurrencyId, + DemandPayment Payment, + [Range(0, 1e10)] decimal PaidAmount, + string? Notes, + IReadOnlyList Lines); + + [HttpGet] + public async Task>> List( + [FromQuery] PagedRequest req, + [FromQuery] DemandStatus? status, + [FromQuery] Guid? customerId, + [FromQuery] Guid? storeId, + CancellationToken ct) + { + var q = from d in _db.Demands.AsNoTracking() + join cp in _db.Counterparties on d.CustomerId equals cp.Id + join st in _db.Stores on d.StoreId equals st.Id + join cu in _db.Currencies on d.CurrencyId equals cu.Id + select new { d, cp, st, cu }; + + if (status is not null) q = q.Where(x => x.d.Status == status); + if (customerId is not null) q = q.Where(x => x.d.CustomerId == customerId); + if (storeId is not null) q = q.Where(x => x.d.StoreId == storeId); + if (!string.IsNullOrWhiteSpace(req.Search)) + { + var s = req.Search.Trim().ToLower(); + q = q.Where(x => x.d.Number.ToLower().Contains(s) || x.cp.Name.ToLower().Contains(s)); + } + + var total = await q.CountAsync(ct); + q = (req.Sort, req.Desc) switch + { + ("number", false) => q.OrderBy(x => x.d.Number), + ("number", true) => q.OrderByDescending(x => x.d.Number), + ("customer", false) => q.OrderBy(x => x.cp.Name).ThenByDescending(x => x.d.Date), + ("customer", true) => q.OrderByDescending(x => x.cp.Name).ThenByDescending(x => x.d.Date), + ("status", false) => q.OrderBy(x => x.d.Status).ThenByDescending(x => x.d.Date), + ("status", true) => q.OrderByDescending(x => x.d.Status).ThenByDescending(x => x.d.Date), + ("total", false) => q.OrderBy(x => x.d.Total).ThenByDescending(x => x.d.Date), + ("total", true) => q.OrderByDescending(x => x.d.Total).ThenByDescending(x => x.d.Date), + ("date", false) => q.OrderBy(x => x.d.Date).ThenBy(x => x.d.Number), + _ => q.OrderByDescending(x => x.d.Date).ThenByDescending(x => x.d.Number), + }; + var items = await q.Skip(req.Skip).Take(req.Take) + .Select(x => new DemandListRow( + x.d.Id, x.d.Number, x.d.Date, x.d.Status, + x.cp.Id, x.cp.Name, + x.st.Id, x.st.Name, + x.cu.Id, x.cu.Code, + x.d.Total, x.d.PaidAmount, x.d.Payment, x.d.Lines.Count, + x.d.PostedAt)) + .ToListAsync(ct); + + return new PagedResult { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; + } + + [HttpGet("{id:guid}")] + public async Task> Get(Guid id, CancellationToken ct) + { + var dto = await GetInternal(id, ct); + return dto is null ? NotFound() : Ok(dto); + } + + [HttpPost, RequiresPermission("DemandsEdit")] + public async Task> Create([FromBody] DemandInput input, CancellationToken ct) + { + if (RequiredGuid.FirstMissing( + (nameof(input.CustomerId), input.CustomerId), + (nameof(input.StoreId), input.StoreId), + (nameof(input.CurrencyId), input.CurrencyId)) is { } missing) + return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing }); + if (input.Lines is null || input.Lines.Count == 0) + return BadRequest(new { error = "Отгрузка должна содержать хотя бы одну позицию." }); + + var number = await GenerateNumberAsync(input.Date, ct); + var demand = new Demand + { + Number = number, + Date = input.Date, + Status = DemandStatus.Draft, + CustomerId = input.CustomerId, + StoreId = input.StoreId, + CurrencyId = input.CurrencyId, + Payment = input.Payment, + PaidAmount = input.PaidAmount, + Notes = input.Notes, + }; + ApplyLines(demand, input.Lines); + _db.Demands.Add(demand); + if (await SaveOrFkErrorAsync(ct) is { } err) return err; + var dto = await GetInternal(demand.Id, ct); + return CreatedAtAction(nameof(Get), new { id = demand.Id }, dto); + } + + private async Task SaveOrFkErrorAsync(CancellationToken ct) + { + try + { + await _db.SaveChangesAsync(ct); + return null; + } + catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23503") + { + var name = pg.ConstraintName ?? ""; + string field = name.Contains("Customer") ? "customerId" + : name.Contains("Store") ? "storeId" + : name.Contains("Currency") ? "currencyId" + : name.Contains("Product") ? "productId" + : "(unknown)"; + return BadRequest(new { error = $"Связанная запись не найдена: {field}.", field, constraint = name }); + } + } + + [HttpPut("{id:guid}"), RequiresPermission("DemandsEdit")] + public async Task Update(Guid id, [FromBody] DemandInput input, CancellationToken ct) + { + if (RequiredGuid.FirstMissing( + (nameof(input.CustomerId), input.CustomerId), + (nameof(input.StoreId), input.StoreId), + (nameof(input.CurrencyId), input.CurrencyId)) is { } missing) + return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing }); + if (input.Lines is null || input.Lines.Count == 0) + return BadRequest(new { error = "Отгрузка должна содержать хотя бы одну позицию." }); + + var demand = await _db.Demands.FirstOrDefaultAsync(d => d.Id == id, ct); + if (demand is null) return NotFound(); + if (demand.Status != DemandStatus.Draft) + return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." }); + + demand.Date = input.Date; + demand.CustomerId = input.CustomerId; + demand.StoreId = input.StoreId; + demand.CurrencyId = input.CurrencyId; + demand.Payment = input.Payment; + demand.PaidAmount = input.PaidAmount; + demand.Notes = input.Notes; + + // Удаляем старые строки через ExecuteDelete (минует трекер), добавляем новые + // напрямую в DbSet — тот же паттерн что в RetailSale.Update (см. P1-8 fix). + await _db.DemandLines.Where(l => l.DemandId == demand.Id).ExecuteDeleteAsync(ct); + ApplyLines(demand, input.Lines); + + if (await SaveOrFkErrorAsync(ct) is { } err) return err; + return NoContent(); + } + + [HttpDelete("{id:guid}"), RequiresPermission("DemandsEdit")] + public async Task Delete(Guid id, CancellationToken ct) + { + var demand = await _db.Demands.FirstOrDefaultAsync(d => d.Id == id, ct); + if (demand is null) return NotFound(); + if (demand.Status != DemandStatus.Draft) + return Conflict(new { error = "Нельзя удалить проведённый документ. Сначала отмени проведение." }); + _db.Demands.Remove(demand); + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpPost("{id:guid}/post"), RequiresPermission("DemandsPost")] + public async Task Post(Guid id, CancellationToken ct) + { + var demand = await _db.Demands.Include(d => d.Lines).FirstOrDefaultAsync(d => d.Id == id, ct); + if (demand is null) return NotFound(); + if (demand.Status == DemandStatus.Posted) return Conflict(new { error = "Документ уже проведён." }); + if (demand.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." }); + + // Защита от ухода в минус. + var byProduct = demand.Lines.GroupBy(l => l.ProductId) + .Select(g => new { ProductId = g.Key, Qty = g.Sum(x => x.Quantity) }).ToList(); + var productIds = byProduct.Select(x => x.ProductId).ToList(); + var stocks = await _db.Stocks + .Where(s => s.StoreId == demand.StoreId && productIds.Contains(s.ProductId)) + .ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct); + var conflicts = new List(); + foreach (var x in byProduct) + { + stocks.TryGetValue(x.ProductId, out var avail); + if (avail < x.Qty) + { + var name = await _db.Products.Where(p => p.Id == x.ProductId).Select(p => p.Name).FirstOrDefaultAsync(ct); + conflicts.Add(new + { + productId = x.ProductId, productName = name, + requested = x.Qty, available = avail, + }); + } + } + if (conflicts.Count > 0) + return Conflict(new { error = "Недостаточно остатка для проведения отгрузки.", lines = conflicts }); + + await using var tx = await _db.Database.BeginTransactionAsync( + System.Data.IsolationLevel.Serializable, ct); + + foreach (var line in demand.Lines) + { + await _stock.ApplyMovementAsync(new StockMovementDraft( + ProductId: line.ProductId, + StoreId: demand.StoreId, + Quantity: -line.Quantity, + Type: MovementType.WholesaleSale, + DocumentType: "demand", + DocumentId: demand.Id, + DocumentNumber: demand.Number, + UnitCost: line.UnitPrice, + OccurredAt: demand.Date), ct); + } + + demand.Status = DemandStatus.Posted; + demand.PostedAt = DateTime.UtcNow; + try + { + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + } + catch (Exception ex) when (IsSerializationConflict(ex)) + { + foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementError("demand", "serialization"); + return Conflict(new { error = "Документ проводится параллельно. Повторите попытку." }); + } + foodmarket.Api.Infrastructure.Observability.AppMetrics.IncrementPosted("demand"); + return NoContent(); + } + + [HttpPost("{id:guid}/unpost"), RequiresPermission("DemandsPost")] + public async Task Unpost(Guid id, CancellationToken ct) + { + var demand = await _db.Demands.Include(d => d.Lines).FirstOrDefaultAsync(d => d.Id == id, ct); + if (demand is null) return NotFound(); + if (demand.Status != DemandStatus.Posted) return Conflict(new { error = "Документ не проведён." }); + + foreach (var line in demand.Lines) + { + await _stock.ApplyMovementAsync(new StockMovementDraft( + ProductId: line.ProductId, + StoreId: demand.StoreId, + Quantity: line.Quantity, + Type: MovementType.WholesaleSale, + DocumentType: "demand-reversal", + DocumentId: demand.Id, + DocumentNumber: demand.Number, + UnitCost: line.UnitPrice, + OccurredAt: DateTime.UtcNow, + Notes: $"Отмена отгрузки {demand.Number}"), ct); + } + + demand.Status = DemandStatus.Draft; + demand.PostedAt = null; + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + private static bool IsSerializationConflict(Exception ex) + { + for (Exception? e = ex; e is not null; e = e.InnerException) + { + if (e is System.Data.Common.DbException { SqlState: "40001" or "40P01" }) + return true; + } + return false; + } + + private void ApplyLines(Demand demand, IReadOnlyList input) + { + var order = 0; + decimal subtotal = 0m, discountTotal = 0m; + foreach (var l in input) + { + var lineTotal = l.Quantity * l.UnitPrice - l.Discount; + // Прямой Add в DbSet — тот же паттерн что в RetailSale.ApplyLines: + // через nav-collection EF8 в комбинации с client-side Id путается. + _db.DemandLines.Add(new DemandLine + { + DemandId = demand.Id, + 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; + } + demand.Subtotal = subtotal; + demand.DiscountTotal = discountTotal; + demand.Total = subtotal - discountTotal; + } + + private async Task GenerateNumberAsync(DateTime date, CancellationToken ct) + { + var prefix = $"ОТГ-{date.Year}-"; + var lastNumber = await _db.Demands + .Where(d => d.Number.StartsWith(prefix)) + .OrderByDescending(d => d.Number) + .Select(d => d.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 GetInternal(Guid id, CancellationToken ct) + { + var row = await (from d in _db.Demands.AsNoTracking() + join cp in _db.Counterparties on d.CustomerId equals cp.Id + join st in _db.Stores on d.StoreId equals st.Id + join cu in _db.Currencies on d.CurrencyId equals cu.Id + where d.Id == id + select new { d, cp, st, cu }).FirstOrDefaultAsync(ct); + if (row is null) return null; + + var lines = await (from l in _db.DemandLines.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.DemandId == id + orderby l.SortOrder + select new DemandLineDto( + l.Id, l.ProductId, p.Name, p.Article, u.Name, + l.Quantity, l.UnitPrice, l.Discount, l.LineTotal, + l.VatPercent, l.SortOrder, + _db.Stocks.Where(s => s.ProductId == l.ProductId && s.StoreId == row.d.StoreId) + .Select(s => (decimal?)s.Quantity).FirstOrDefault())) + .ToListAsync(ct); + + return new DemandDto( + row.d.Id, row.d.Number, row.d.Date, row.d.Status, + row.cp.Id, row.cp.Name, + row.st.Id, row.st.Name, + row.cu.Id, row.cu.Code, + row.d.Payment, row.d.Subtotal, row.d.DiscountTotal, + row.d.Total, row.d.PaidAmount, + row.d.Notes, row.d.PostedAt, + lines); + } +} diff --git a/src/food-market.domain/Sales/Demand.cs b/src/food-market.domain/Sales/Demand.cs new file mode 100644 index 0000000..944333a --- /dev/null +++ b/src/food-market.domain/Sales/Demand.cs @@ -0,0 +1,77 @@ +using foodmarket.Domain.Catalog; +using foodmarket.Domain.Common; + +namespace foodmarket.Domain.Sales; + +public enum DemandStatus +{ + Draft = 0, + Posted = 1, +} + +/// Оптовая отгрузка контрагенту-юрлицу. По MoySklad — «Отгрузка». +/// Отличие от : всегда юрлицо (CounterpartyId +/// обязателен), способ оплаты — нал/безнал/в кредит, цена и НДС могут +/// отличаться от розничных (тип цен «Опт.»). При проведении создаёт +/// тип +/// с -Quantity. +public class Demand : TenantEntity +{ + public string Number { get; set; } = ""; + public DateTime Date { get; set; } = DateTime.UtcNow; + public DemandStatus Status { get; set; } = DemandStatus.Draft; + + public Guid CustomerId { get; set; } + public Counterparty Customer { get; set; } = null!; + + public Guid StoreId { get; set; } + public Store Store { get; set; } = null!; + + public Guid CurrencyId { get; set; } + public Currency Currency { get; set; } = null!; + + /// 0=Cash, 1=Card, 2=BankTransfer, 3=Credit (постоплата), 99=Mixed. + /// Совпадает с + добавлен Credit. + public DemandPayment Payment { get; set; } = DemandPayment.BankTransfer; + + public decimal Subtotal { get; set; } + public decimal DiscountTotal { get; set; } + public decimal Total { get; set; } + + /// Сумма оплаченного по этой отгрузке (для отслеживания дебиторки). + /// Может быть меньше Total — тогда остаток за контрагентом (Total − PaidAmount). + /// На MVP не строим отчёт по задолженности — просто сохраняем. + public decimal PaidAmount { get; set; } + + public string? Notes { get; set; } + + public DateTime? PostedAt { get; set; } + public Guid? PostedByUserId { get; set; } + + public ICollection Lines { get; set; } = new List(); +} + +public enum DemandPayment +{ + Cash = 0, + Card = 1, + BankTransfer = 2, + Credit = 3, // постоплата, дебиторка + Mixed = 99, +} + +public class DemandLine : TenantEntity +{ + public Guid DemandId { get; set; } + public Demand Demand { 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; } + public decimal VatPercent { get; set; } + public int SortOrder { get; set; } +} diff --git a/src/food-market.infrastructure/Persistence/AppDbContext.cs b/src/food-market.infrastructure/Persistence/AppDbContext.cs index cd20504..0bf831b 100644 --- a/src/food-market.infrastructure/Persistence/AppDbContext.cs +++ b/src/food-market.infrastructure/Persistence/AppDbContext.cs @@ -63,6 +63,9 @@ public AppDbContext(DbContextOptions options, ITenantContext tenan public DbSet RetailSaleLines => Set(); public DbSet PosBatchAcks => Set(); + public DbSet Demands => Set(); + public DbSet DemandLines => Set(); + public DbSet EmployeeRoles => Set(); public DbSet Employees => Set(); public DbSet EmployeeRetailPointAssignments => Set(); diff --git a/src/food-market.infrastructure/Persistence/Configurations/SalesConfigurations.cs b/src/food-market.infrastructure/Persistence/Configurations/SalesConfigurations.cs index 8f916f0..c52a582 100644 --- a/src/food-market.infrastructure/Persistence/Configurations/SalesConfigurations.cs +++ b/src/food-market.infrastructure/Persistence/Configurations/SalesConfigurations.cs @@ -59,5 +59,40 @@ public static void ConfigureSales(this ModelBuilder b) e.HasIndex(x => new { x.OrganizationId, x.IdempotencyKey }).IsUnique(); e.HasIndex(x => x.CreatedAt); // для фонового cleanup'а старых acks }); + + b.Entity(e => + { + e.ToTable("demands"); + 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.PaidAmount).HasPrecision(18, 4); + + e.HasOne(x => x.Customer).WithMany().HasForeignKey(x => x.CustomerId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(x => x.Store).WithMany().HasForeignKey(x => x.StoreId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(x => x.Currency).WithMany().HasForeignKey(x => x.CurrencyId).OnDelete(DeleteBehavior.Restrict); + + e.HasMany(x => x.Lines).WithOne(l => l.Demand).HasForeignKey(l => l.DemandId).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.CustomerId }); + }); + + b.Entity(e => + { + e.ToTable("demand_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 }); + }); } } diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260528200000_Phase8a_Demands.cs b/src/food-market.infrastructure/Persistence/Migrations/20260528200000_Phase8a_Demands.cs new file mode 100644 index 0000000..7fa2513 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260528200000_Phase8a_Demands.cs @@ -0,0 +1,83 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Phase8a — оптовая отгрузка (Demand). + /// + /// По MoySklad: «Отгрузка контрагенту». Зеркалит RetailSale но всегда с + /// юрлицом-контрагентом, способ оплаты включает Credit (постоплата). + /// При проведении создаёт stock_movements тип WholesaleSale (=3) с -Quantity. + [DbContext(typeof(AppDbContext))] + [Migration("20260528200000_Phase8a_Demands")] + public partial class Phase8a_Demands : Migration + { + protected override void Up(MigrationBuilder b) + { + b.Sql(@" + CREATE TABLE IF NOT EXISTS public.demands ( + ""Id"" uuid PRIMARY KEY, + ""OrganizationId"" uuid NOT NULL, + ""Number"" varchar(50) NOT NULL, + ""Date"" timestamp with time zone NOT NULL, + ""Status"" integer NOT NULL, + ""CustomerId"" uuid NOT NULL, + ""StoreId"" uuid NOT NULL, + ""CurrencyId"" uuid NOT NULL, + ""Payment"" integer NOT NULL, + ""Subtotal"" numeric(18,4) NOT NULL, + ""DiscountTotal"" numeric(18,4) NOT NULL, + ""Total"" numeric(18,4) NOT NULL, + ""PaidAmount"" numeric(18,4) NOT NULL DEFAULT 0, + ""Notes"" varchar(1000), + ""PostedAt"" timestamp with time zone, + ""PostedByUserId"" uuid, + ""CreatedAt"" timestamp with time zone NOT NULL, + ""UpdatedAt"" timestamp with time zone, + CONSTRAINT ""FK_demands_counterparties_CustomerId"" FOREIGN KEY (""CustomerId"") REFERENCES public.counterparties(""Id"") ON DELETE RESTRICT, + CONSTRAINT ""FK_demands_stores_StoreId"" FOREIGN KEY (""StoreId"") REFERENCES public.stores(""Id"") ON DELETE RESTRICT, + CONSTRAINT ""FK_demands_currencies_CurrencyId"" FOREIGN KEY (""CurrencyId"") REFERENCES public.currencies(""Id"") ON DELETE RESTRICT + ); + + CREATE UNIQUE INDEX IF NOT EXISTS ""IX_demands_OrganizationId_Number"" ON public.demands (""OrganizationId"", ""Number""); + CREATE INDEX IF NOT EXISTS ""IX_demands_OrganizationId_Date"" ON public.demands (""OrganizationId"", ""Date""); + CREATE INDEX IF NOT EXISTS ""IX_demands_OrganizationId_Status"" ON public.demands (""OrganizationId"", ""Status""); + CREATE INDEX IF NOT EXISTS ""IX_demands_OrganizationId_CustomerId"" ON public.demands (""OrganizationId"", ""CustomerId""); + CREATE INDEX IF NOT EXISTS ""IX_demands_StoreId"" ON public.demands (""StoreId""); + CREATE INDEX IF NOT EXISTS ""IX_demands_CurrencyId"" ON public.demands (""CurrencyId""); + + CREATE TABLE IF NOT EXISTS public.demand_lines ( + ""Id"" uuid PRIMARY KEY, + ""OrganizationId"" uuid NOT NULL, + ""DemandId"" uuid NOT NULL, + ""ProductId"" uuid NOT NULL, + ""Quantity"" numeric(18,4) NOT NULL, + ""UnitPrice"" numeric(18,4) NOT NULL, + ""Discount"" numeric(18,4) NOT NULL, + ""LineTotal"" numeric(18,4) NOT NULL, + ""VatPercent"" numeric(5,2) NOT NULL, + ""SortOrder"" integer NOT NULL, + ""CreatedAt"" timestamp with time zone NOT NULL, + ""UpdatedAt"" timestamp with time zone, + CONSTRAINT ""FK_demand_lines_demands_DemandId"" FOREIGN KEY (""DemandId"") REFERENCES public.demands(""Id"") ON DELETE CASCADE, + CONSTRAINT ""FK_demand_lines_products_ProductId"" FOREIGN KEY (""ProductId"") REFERENCES public.products(""Id"") ON DELETE RESTRICT + ); + + CREATE INDEX IF NOT EXISTS ""IX_demand_lines_DemandId"" ON public.demand_lines (""DemandId""); + CREATE INDEX IF NOT EXISTS ""IX_demand_lines_ProductId"" ON public.demand_lines (""ProductId""); + CREATE INDEX IF NOT EXISTS ""IX_demand_lines_OrganizationId_ProductId"" ON public.demand_lines (""OrganizationId"", ""ProductId""); + "); + } + + protected override void Down(MigrationBuilder b) + { + b.Sql(@" + DROP TABLE IF EXISTS public.demand_lines; + DROP TABLE IF EXISTS public.demands; + "); + } + } +} diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index b77d5a4..0ca7b52 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -38,6 +38,8 @@ import { InventoriesPage } from '@/pages/InventoriesPage' import { InventoryEditPage } from '@/pages/InventoryEditPage' import { SupplierReturnsPage } from '@/pages/SupplierReturnsPage' import { SupplierReturnEditPage } from '@/pages/SupplierReturnEditPage' +import { DemandsPage } from '@/pages/DemandsPage' +import { DemandEditPage } from '@/pages/DemandEditPage' import { SalesReportPage } from '@/pages/SalesReportPage' import { StockReportPage } from '@/pages/StockReportPage' import { ProfitReportPage } from '@/pages/ProfitReportPage' @@ -136,6 +138,9 @@ export default function App() { } /> } /> } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/src/food-market.web/src/components/AppLayout.tsx b/src/food-market.web/src/components/AppLayout.tsx index b66936a..0fcf980 100644 --- a/src/food-market.web/src/components/AppLayout.tsx +++ b/src/food-market.web/src/components/AppLayout.tsx @@ -7,7 +7,7 @@ import { cn } from '@/lib/utils' import { LayoutDashboard, Package, FolderTree, Ruler, Tag, Users, Warehouse, Store as StoreIcon, LogOut, Download, UserCog, Shield, ShieldCheck, - Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, PackagePlus, PackageMinus, ArrowRightLeft, ClipboardCheck, Undo2, BarChart3, TrendingUp, Target, + Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, PackagePlus, PackageMinus, ArrowRightLeft, ClipboardCheck, Undo2, BarChart3, TrendingUp, Target, Send, } from 'lucide-react' import { Logo } from './Logo' import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner' @@ -105,6 +105,7 @@ function buildNav(roles: string[]): NavSection[] { if (isAdmin || isCashier) { sections.push({ group: 'Продажи', items: [ { to: '/sales/retail', icon: ShoppingCart, label: 'Розничные чеки' }, + ...(isAdmin ? [{ to: '/sales/demands', icon: Send, label: 'Оптовые отгрузки' }] : []), ]}) } diff --git a/src/food-market.web/src/lib/types.ts b/src/food-market.web/src/lib/types.ts index 46a224e..90c9642 100644 --- a/src/food-market.web/src/lib/types.ts +++ b/src/food-market.web/src/lib/types.ts @@ -137,6 +137,47 @@ export interface EnterDto { lines: EnterLineDto[]; } +export const DemandStatus = { Draft: 0, Posted: 1 } as const +export type DemandStatus = (typeof DemandStatus)[keyof typeof DemandStatus] + +export const DemandPayment = { Cash: 0, Card: 1, BankTransfer: 2, Credit: 3, Mixed: 99 } as const +export type DemandPayment = (typeof DemandPayment)[keyof typeof DemandPayment] +export const demandPaymentLabel: Record = { + [DemandPayment.Cash]: 'Наличные', + [DemandPayment.Card]: 'Карта', + [DemandPayment.BankTransfer]: 'Безнал', + [DemandPayment.Credit]: 'В кредит', + [DemandPayment.Mixed]: 'Смешанная', +} + +export interface DemandListRow { + id: string; number: string; date: string; status: DemandStatus; + customerId: string; customerName: string; + storeId: string; storeName: string; + currencyId: string; currencyCode: string; + total: number; paidAmount: number; + payment: DemandPayment; lineCount: number; postedAt: string | null; +} + +export interface DemandLineDto { + 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; + stockAtStore: number | null; +} + +export interface DemandDto { + id: string; number: string; date: string; status: DemandStatus; + customerId: string; customerName: string; + storeId: string; storeName: string; + currencyId: string; currencyCode: string; + payment: DemandPayment; + subtotal: number; discountTotal: number; total: number; paidAmount: number; + notes: string | null; postedAt: string | null; + lines: DemandLineDto[]; +} + export const LossStatus = { Draft: 0, Posted: 1 } as const export type LossStatus = (typeof LossStatus)[keyof typeof LossStatus] diff --git a/src/food-market.web/src/pages/DemandEditPage.tsx b/src/food-market.web/src/pages/DemandEditPage.tsx new file mode 100644 index 0000000..bd746da --- /dev/null +++ b/src/food-market.web/src/pages/DemandEditPage.tsx @@ -0,0 +1,403 @@ +import { useState, useEffect, type FormEvent } from 'react' +import { useNavigate, useParams, Link } from 'react-router-dom' +import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query' +import { ArrowLeft, Plus, Trash2, Save, CheckCircle } from 'lucide-react' +import { api } from '@/lib/api' +import { Button } from '@/components/Button' +import { Field, TextArea, Select, AsyncSelect, MoneyInput, NumberInput, Checkbox } from '@/components/Field' +import { DateField } from '@/components/DateField' +import { ProductPicker } from '@/components/ProductPicker' +import { useStores, useCurrencies } from '@/lib/useLookups' +import { useOrgSettings } from '@/lib/useOrgSettings' +import { + DemandStatus, DemandPayment, demandPaymentLabel, + type DemandDto, type Product, +} from '@/lib/types' + +interface LineRow { + productId: string + productName: string + productArticle: string | null + unitSymbol: string | null + quantity: number + unitPrice: number + discount: number + vatPercent: number + stockAtStore: number | null +} + +interface Form { + date: string + customerId: string + storeId: string + currencyId: string + payment: DemandPayment + paidAmount: number + notes: string + lines: LineRow[] +} + +const todayIso = () => { + const d = new Date() + return `${d.getFullYear()}-${String(d.getMonth() + 1).padStart(2, '0')}-${String(d.getDate()).padStart(2, '0')}` +} + +const emptyForm: Form = { + date: todayIso(), customerId: '', storeId: '', currencyId: '', + payment: DemandPayment.BankTransfer, paidAmount: 0, + notes: '', lines: [], +} + +export function DemandEditPage() { + const { id } = useParams<{ id: string }>() + const isNew = !id || id === 'new' + const navigate = useNavigate() + const qc = useQueryClient() + + const stores = useStores() + const currencies = useCurrencies() + const org = useOrgSettings() + + const [form, setForm] = useState
(emptyForm) + const [pickerOpen, setPickerOpen] = useState(false) + const [error, setError] = useState(null) + + const existing = useQuery({ + queryKey: ['/api/sales/demands', id], + queryFn: async () => (await api.get(`/api/sales/demands/${id}`)).data, + enabled: !isNew, + }) + + useEffect(() => { + if (!isNew && existing.data) { + const s = existing.data + setForm({ + date: s.date.slice(0, 10), + customerId: s.customerId, + storeId: s.storeId, + currencyId: s.currencyId, + payment: s.payment, + paidAmount: s.paidAmount, + notes: s.notes ?? '', + lines: s.lines.map((l) => ({ + productId: l.productId, + productName: l.productName ?? '', + productArticle: l.productArticle, + unitSymbol: l.unitSymbol, + quantity: l.quantity, + unitPrice: l.unitPrice, + discount: l.discount, + vatPercent: l.vatPercent, + stockAtStore: l.stockAtStore, + })), + }) + } + }, [isNew, existing.data]) + + useEffect(() => { + if (isNew) { + if (!form.storeId && stores.data?.length) { + const main = stores.data.find((s) => s.isMain) ?? stores.data[0] + setForm((f) => ({ ...f, storeId: main.id })) + } + if (!form.currencyId && currencies.data?.length) { + const def = org.data?.defaultCurrencyId + ? currencies.data.find((c) => c.id === org.data!.defaultCurrencyId) + : currencies.data.find((c) => c.code === 'KZT') + if (def) setForm((f) => ({ ...f, currencyId: def.id })) + } + } + }, [isNew, stores.data, currencies.data, org.data?.defaultCurrencyId, form.storeId, form.currencyId]) + + const isDraft = isNew || existing.data?.status === DemandStatus.Draft + const isPosted = existing.data?.status === DemandStatus.Posted + + const lineTotal = (l: LineRow) => l.quantity * l.unitPrice - l.discount + const grandTotal = form.lines.reduce((s, l) => s + lineTotal(l), 0) + + const save = useMutation({ + mutationFn: async () => { + const payload = { + date: new Date(form.date).toISOString(), + customerId: form.customerId, + storeId: form.storeId, + currencyId: form.currencyId, + payment: form.payment, + paidAmount: form.paidAmount, + 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('/api/sales/demands', payload)).data + await api.put(`/api/sales/demands/${id}`, payload) + return null + }, + onSuccess: (created) => { + qc.invalidateQueries({ queryKey: ['/api/sales/demands'] }) + navigate(created ? `/sales/demands/${created.id}` : `/sales/demands/${id}`) + }, + onError: (e: Error) => setError(e.message), + }) + + const post = useMutation({ + mutationFn: async () => { await api.post(`/api/sales/demands/${id}/post`) }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['/api/sales/demands'] }) + qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] }) + qc.invalidateQueries({ queryKey: ['/api/inventory/movements'] }) + existing.refetch() + }, + onError: (e: Error) => { + const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? e.message + setError(msg) + }, + }) + + const unpost = useMutation({ + mutationFn: async () => { await api.post(`/api/sales/demands/${id}/unpost`) }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['/api/sales/demands'] }) + qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] }) + existing.refetch() + }, + onError: (e: Error) => setError(e.message), + }) + + const remove = useMutation({ + mutationFn: async () => { await api.delete(`/api/sales/demands/${id}`) }, + onSuccess: () => navigate('/sales/demands'), + onError: (e: Error) => setError(e.message), + }) + + const onSubmit = (e: FormEvent) => { e.preventDefault(); save.mutate() } + + const addLineFromProduct = (p: Product) => { + setForm({ + ...form, + lines: [...form.lines, { + productId: p.id, + productName: p.name, + productArticle: p.article, + unitSymbol: p.unitName, + quantity: 1, + unitPrice: p.prices?.[0]?.amount ?? 0, + discount: 0, + vatPercent: p.vat, + stockAtStore: null, + }], + }) + } + const updateLine = (i: number, patch: Partial) => + 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.date && !!form.customerId && !!form.storeId && !!form.currencyId + && form.lines.length > 0 && isDraft + + const fractional = org.data?.allowFractionalPrices ?? false + const moneyFmt = fractional + ? { minimumFractionDigits: 2, maximumFractionDigits: 2 } + : { maximumFractionDigits: 0 } + + return ( + +
+
+ + + +
+

+ {isNew ? 'Новая отгрузка' : existing.data?.number ?? 'Отгрузка'} +

+

+ {isPosted + ? Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''} + : 'Черновик — товар не списан, пока не проведёшь'} +

+
+
+
+ {isDraft && !isNew && ( + + )} + {isDraft && ( + + )} +
+
+ +
+
+ {error && ( +
{error}
+ )} + +
+
+ + setForm({ ...form, date: iso ?? '' })} /> + + + setForm({ ...form, customerId: v })} + placeholder="Выберите контрагента…" + /> + + + + + + + + + setForm({ ...form, paidAmount: v ?? 0 })} /> + + {org.data?.multiCurrencyEnabled && ( + + + + )} + +