From fa1e1233273364df723a12c4c18263a814e84c03 Mon Sep 17 00:00:00 2001 From: nns Date: Thu, 28 May 2026 09:32:32 +0500 Subject: [PATCH] =?UTF-8?q?feat(transfers):=20=D0=B0=D1=82=D0=BE=D0=BC?= =?UTF-8?q?=D0=B0=D1=80=D0=BD=D0=BE=D0=B5=20=D0=BF=D0=B5=D1=80=D0=B5=D0=BC?= =?UTF-8?q?=D0=B5=D1=89=D0=B5=D0=BD=D0=B8=D0=B5=20=D0=BC=D0=B5=D0=B6=D0=B4?= =?UTF-8?q?=D1=83=20=D1=81=D0=BA=D0=BB=D0=B0=D0=B4=D0=B0=D0=BC=D0=B8=20(P1?= =?UTF-8?q?-3)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Domain Transfer+TransferLine (FromStoreId → ToStoreId, обязательны и различны). EF, миграция Phase6c_Transfers. Контроллер api/inventory/transfers: CRUD + Post/Unpost. Post создаёт ПАРУ движений TransferOut(-) + TransferIn(+) в одной Serializable-транзакции; Unpost — обратная пара тоже атомарно. Защита от ухода в минус: post (на FromStore), unpost (на ToStore — товар мог быть уже расходован). Web: /inventory/transfers (list+edit) с двумя селекторами складов и визуализацией «From → To». Пункт «Перемещения» в сайдбаре. Permission TransferEdit добавлен в RolePermissions. Тесты: 4 интеграционных (post создаёт пару движений, unpost оставляет ровно 4 движения и обнуляет stock-диффы; same-store → 400; short-stock на FromStore → 409 без побочных эффектов; tenant-изоляция). Co-Authored-By: Claude Opus 4.7 --- .../Inventory/TransfersController.cs | 459 ++++++++++++++++++ src/food-market.domain/Inventory/Transfer.cs | 55 +++ .../Organizations/RolePermissions.cs | 3 +- .../Persistence/AppDbContext.cs | 3 + .../Configurations/InventoryConfigurations.cs | 29 ++ .../20260528020000_Phase6c_Transfers.cs | 74 +++ src/food-market.web/src/App.tsx | 5 + .../src/components/AppLayout.tsx | 3 +- src/food-market.web/src/lib/types.ts | 26 + .../src/pages/TransferEditPage.tsx | 344 +++++++++++++ .../src/pages/TransfersPage.tsx | 69 +++ .../TransferPostUnpostTests.cs | 161 ++++++ 12 files changed, 1229 insertions(+), 2 deletions(-) create mode 100644 src/food-market.api/Controllers/Inventory/TransfersController.cs create mode 100644 src/food-market.domain/Inventory/Transfer.cs create mode 100644 src/food-market.infrastructure/Persistence/Migrations/20260528020000_Phase6c_Transfers.cs create mode 100644 src/food-market.web/src/pages/TransferEditPage.tsx create mode 100644 src/food-market.web/src/pages/TransfersPage.tsx create mode 100644 tests/food-market.IntegrationTests/TransferPostUnpostTests.cs diff --git a/src/food-market.api/Controllers/Inventory/TransfersController.cs b/src/food-market.api/Controllers/Inventory/TransfersController.cs new file mode 100644 index 0000000..1ebadec --- /dev/null +++ b/src/food-market.api/Controllers/Inventory/TransfersController.cs @@ -0,0 +1,459 @@ +using System.ComponentModel.DataAnnotations; +using foodmarket.Application.Common; +using foodmarket.Application.Inventory; +using foodmarket.Domain.Inventory; +using foodmarket.Infrastructure.Persistence; +using foodmarket.Api.Infrastructure.Authorization; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Inventory; + +/// Перемещение (Transfer): атомарное движение товара между складами. +/// Post создаёт ПАРУ движений в : TransferOut с +/// отрицательным Quantity из FromStore + TransferIn с положительным в +/// ToStore. Серьёзный кейс: post→unpost не должен оставить orphan-движений +/// (только один из двух) — оба создаются и оба «отменяются» в одной +/// транзакции. +[ApiController] +[Authorize] +[Route("api/inventory/transfers")] +public class TransfersController : ControllerBase +{ + private readonly AppDbContext _db; + private readonly IStockService _stock; + + public TransfersController(AppDbContext db, IStockService stock) + { + _db = db; + _stock = stock; + } + + public record TransferListRow( + Guid Id, string Number, DateTime Date, TransferStatus Status, + Guid FromStoreId, string FromStoreName, + Guid ToStoreId, string ToStoreName, + decimal Total, int LineCount, + DateTime? PostedAt); + + public record TransferLineDto( + Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, + string? UnitSymbol, + decimal Quantity, decimal UnitCost, decimal LineTotal, int SortOrder, + decimal? StockAtFrom); + + public record TransferDto( + Guid Id, string Number, DateTime Date, TransferStatus Status, + Guid FromStoreId, string FromStoreName, + Guid ToStoreId, string ToStoreName, + string? Notes, + decimal Total, DateTime? PostedAt, + IReadOnlyList Lines); + + public record TransferLineInput( + Guid ProductId, + [Range(0, 1e10)] decimal Quantity, + [Range(0, 1e10)] decimal UnitCost); + + public record TransferInput( + DateTime Date, Guid FromStoreId, Guid ToStoreId, + string? Notes, + IReadOnlyList Lines); + + [HttpGet] + public async Task>> List( + [FromQuery] PagedRequest req, + [FromQuery] TransferStatus? status, + [FromQuery] Guid? fromStoreId, + [FromQuery] Guid? toStoreId, + CancellationToken ct) + { + var q = from t in _db.Transfers.AsNoTracking() + join fs in _db.Stores on t.FromStoreId equals fs.Id + join ts in _db.Stores on t.ToStoreId equals ts.Id + select new { t, fs, ts }; + + if (status is not null) q = q.Where(x => x.t.Status == status); + if (fromStoreId is not null) q = q.Where(x => x.t.FromStoreId == fromStoreId); + if (toStoreId is not null) q = q.Where(x => x.t.ToStoreId == toStoreId); + if (!string.IsNullOrWhiteSpace(req.Search)) + { + var s = req.Search.Trim().ToLower(); + q = q.Where(x => x.t.Number.ToLower().Contains(s)); + } + + var total = await q.CountAsync(ct); + q = (req.Sort, req.Desc) switch + { + ("number", false) => q.OrderBy(x => x.t.Number), + ("number", true) => q.OrderByDescending(x => x.t.Number), + ("status", false) => q.OrderBy(x => x.t.Status).ThenByDescending(x => x.t.Date), + ("status", true) => q.OrderByDescending(x => x.t.Status).ThenByDescending(x => x.t.Date), + ("total", false) => q.OrderBy(x => x.t.Total).ThenByDescending(x => x.t.Date), + ("total", true) => q.OrderByDescending(x => x.t.Total).ThenByDescending(x => x.t.Date), + ("date", false) => q.OrderBy(x => x.t.Date).ThenBy(x => x.t.Number), + _ => q.OrderByDescending(x => x.t.Date).ThenByDescending(x => x.t.Number), + }; + var items = await q + .Skip(req.Skip).Take(req.Take) + .Select(x => new TransferListRow( + x.t.Id, x.t.Number, x.t.Date, x.t.Status, + x.fs.Id, x.fs.Name, + x.ts.Id, x.ts.Name, + x.t.Total, + x.t.Lines.Count, + x.t.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("TransferEdit")] + public async Task> Create([FromBody] TransferInput input, CancellationToken ct) + { + if (RequiredGuid.FirstMissing( + (nameof(input.FromStoreId), input.FromStoreId), + (nameof(input.ToStoreId), input.ToStoreId)) is { } missing) + return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing }); + if (input.FromStoreId == input.ToStoreId) + return BadRequest(new { error = "Склад-отправитель и склад-получатель должны различаться.", field = "toStoreId" }); + if (input.Lines is null || input.Lines.Count == 0) + return BadRequest(new { error = "Перемещение должно содержать хотя бы одну позицию." }); + + var number = await GenerateNumberAsync(input.Date, ct); + var t = new Transfer + { + Number = number, + Date = input.Date, + Status = TransferStatus.Draft, + FromStoreId = input.FromStoreId, + ToStoreId = input.ToStoreId, + Notes = input.Notes, + }; + var order = 0; + foreach (var l in input.Lines) + { + t.Lines.Add(new TransferLine + { + ProductId = l.ProductId, + Quantity = l.Quantity, + UnitCost = l.UnitCost, + LineTotal = l.Quantity * l.UnitCost, + SortOrder = order++, + }); + } + t.Total = t.Lines.Sum(x => x.LineTotal); + + _db.Transfers.Add(t); + if (await SaveOrFkErrorAsync(ct) is { } err) return err; + var dto = await GetInternal(t.Id, ct); + return CreatedAtAction(nameof(Get), new { id = t.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("FromStore") ? "fromStoreId" + : name.Contains("ToStore") ? "toStoreId" + : name.Contains("Product") ? "productId" + : "(unknown)"; + return BadRequest(new { error = $"Связанная запись не найдена: {field}.", field, constraint = name }); + } + } + + [HttpPut("{id:guid}"), RequiresPermission("TransferEdit")] + public async Task Update(Guid id, [FromBody] TransferInput input, CancellationToken ct) + { + if (RequiredGuid.FirstMissing( + (nameof(input.FromStoreId), input.FromStoreId), + (nameof(input.ToStoreId), input.ToStoreId)) is { } missing) + return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing }); + if (input.FromStoreId == input.ToStoreId) + return BadRequest(new { error = "Склад-отправитель и склад-получатель должны различаться.", field = "toStoreId" }); + if (input.Lines is null || input.Lines.Count == 0) + return BadRequest(new { error = "Перемещение должно содержать хотя бы одну позицию." }); + + var t = await _db.Transfers.Include(x => x.Lines).FirstOrDefaultAsync(x => x.Id == id, ct); + if (t is null) return NotFound(); + if (t.Status != TransferStatus.Draft) + return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." }); + + t.Date = input.Date; + t.FromStoreId = input.FromStoreId; + t.ToStoreId = input.ToStoreId; + t.Notes = input.Notes; + + _db.TransferLines.RemoveRange(t.Lines); + t.Lines.Clear(); + var order = 0; + foreach (var l in input.Lines) + { + t.Lines.Add(new TransferLine + { + TransferId = t.Id, + ProductId = l.ProductId, + Quantity = l.Quantity, + UnitCost = l.UnitCost, + LineTotal = l.Quantity * l.UnitCost, + SortOrder = order++, + }); + } + t.Total = t.Lines.Sum(x => x.LineTotal); + + if (await SaveOrFkErrorAsync(ct) is { } err) return err; + return NoContent(); + } + + [HttpDelete("{id:guid}"), RequiresPermission("TransferEdit")] + public async Task Delete(Guid id, CancellationToken ct) + { + var t = await _db.Transfers.FirstOrDefaultAsync(x => x.Id == id, ct); + if (t is null) return NotFound(); + if (t.Status != TransferStatus.Draft) + return Conflict(new { error = "Нельзя удалить проведённый документ. Сначала отмени проведение." }); + _db.Transfers.Remove(t); + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpPost("{id:guid}/post"), RequiresPermission("TransferEdit")] + public async Task Post(Guid id, CancellationToken ct) + { + var t = await _db.Transfers.Include(x => x.Lines).FirstOrDefaultAsync(x => x.Id == id, ct); + if (t is null) return NotFound(); + if (t.Status == TransferStatus.Posted) return Conflict(new { error = "Документ уже проведён." }); + if (t.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." }); + + // Проверка: на FromStore хватает товара (нельзя уйти в минус). + var byProduct = t.Lines.GroupBy(l => l.ProductId) + .Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Quantity) }).ToList(); + var productIds = byProduct.Select(x => x.ProductId).ToList(); + var stocks = await _db.Stocks + .Where(s => s.StoreId == t.FromStoreId && productIds.Contains(s.ProductId)) + .ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct); + var conflicts = new List(); + foreach (var r in byProduct) + { + stocks.TryGetValue(r.ProductId, out var available); + if (available < r.Quantity) + { + var name = await _db.Products.Where(p => p.Id == r.ProductId) + .Select(p => p.Name).FirstOrDefaultAsync(ct); + conflicts.Add(new + { + productId = r.ProductId, + productName = name, + requested = r.Quantity, + available, + }); + } + } + if (conflicts.Count > 0) + { + return Conflict(new + { + error = "На складе-отправителе недостаточно товара.", + lines = conflicts, + }); + } + + // Атомарная транзакция: пара движений (Out + In) — либо обе, либо ни одной. + await using var tx = await _db.Database.BeginTransactionAsync( + System.Data.IsolationLevel.Serializable, ct); + var now = DateTime.UtcNow; + + foreach (var line in t.Lines) + { + await _stock.ApplyMovementAsync(new StockMovementDraft( + ProductId: line.ProductId, + StoreId: t.FromStoreId, + Quantity: -line.Quantity, + Type: MovementType.TransferOut, + DocumentType: "transfer-out", + DocumentId: t.Id, + DocumentNumber: t.Number, + UnitCost: line.UnitCost, + OccurredAt: t.Date), ct); + await _stock.ApplyMovementAsync(new StockMovementDraft( + ProductId: line.ProductId, + StoreId: t.ToStoreId, + Quantity: line.Quantity, + Type: MovementType.TransferIn, + DocumentType: "transfer-in", + DocumentId: t.Id, + DocumentNumber: t.Number, + UnitCost: line.UnitCost, + OccurredAt: t.Date), ct); + } + + t.Status = TransferStatus.Posted; + t.PostedAt = now; + try + { + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + } + catch (Exception ex) when (IsSerializationConflict(ex)) + { + return Conflict(new { error = "Документ проводится параллельно другим запросом. Повторите попытку." }); + } + return NoContent(); + } + + [HttpPost("{id:guid}/unpost"), RequiresPermission("TransferEdit")] + public async Task Unpost(Guid id, CancellationToken ct) + { + var t = await _db.Transfers.Include(x => x.Lines).FirstOrDefaultAsync(x => x.Id == id, ct); + if (t is null) return NotFound(); + if (t.Status != TransferStatus.Posted) return Conflict(new { error = "Документ не проведён." }); + + // Reverse: на ToStore должен хватать товар (могли уже использовать). + var byProduct = t.Lines.GroupBy(l => l.ProductId) + .Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Quantity) }).ToList(); + var productIds = byProduct.Select(x => x.ProductId).ToList(); + var stocks = await _db.Stocks + .Where(s => s.StoreId == t.ToStoreId && productIds.Contains(s.ProductId)) + .ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct); + var conflicts = new List(); + foreach (var r in byProduct) + { + stocks.TryGetValue(r.ProductId, out var available); + if (available < r.Quantity) + { + var name = await _db.Products.Where(p => p.Id == r.ProductId) + .Select(p => p.Name).FirstOrDefaultAsync(ct); + conflicts.Add(new + { + productId = r.ProductId, + productName = name, + reverseQty = r.Quantity, + availableAtTo = available, + }); + } + } + 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 t.Lines) + { + // Возвращаем на FromStore (+Quantity). + await _stock.ApplyMovementAsync(new StockMovementDraft( + ProductId: line.ProductId, + StoreId: t.FromStoreId, + Quantity: line.Quantity, + Type: MovementType.TransferOut, + DocumentType: "transfer-out-reversal", + DocumentId: t.Id, + DocumentNumber: t.Number, + UnitCost: line.UnitCost, + OccurredAt: DateTime.UtcNow, + Notes: $"Отмена проведения документа {t.Number}"), ct); + // Снимаем с ToStore (-Quantity). + await _stock.ApplyMovementAsync(new StockMovementDraft( + ProductId: line.ProductId, + StoreId: t.ToStoreId, + Quantity: -line.Quantity, + Type: MovementType.TransferIn, + DocumentType: "transfer-in-reversal", + DocumentId: t.Id, + DocumentNumber: t.Number, + UnitCost: line.UnitCost, + OccurredAt: DateTime.UtcNow, + Notes: $"Отмена проведения документа {t.Number}"), ct); + } + + t.Status = TransferStatus.Draft; + t.PostedAt = null; + try + { + await _db.SaveChangesAsync(ct); + await tx.CommitAsync(ct); + } + catch (Exception ex) when (IsSerializationConflict(ex)) + { + return Conflict(new { error = "Документ обрабатывается параллельно. Повторите попытку." }); + } + 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 async Task GenerateNumberAsync(DateTime date, CancellationToken ct) + { + var year = date.Year; + var prefix = $"П-{year}-Т-"; + var lastNumber = await _db.Transfers + .Where(t => t.Number.StartsWith(prefix)) + .OrderByDescending(t => t.Number) + .Select(t => t.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 t in _db.Transfers.AsNoTracking() + join fs in _db.Stores on t.FromStoreId equals fs.Id + join ts in _db.Stores on t.ToStoreId equals ts.Id + where t.Id == id + select new { t, fs, ts }).FirstOrDefaultAsync(ct); + if (row is null) return null; + + var lines = await (from l in _db.TransferLines.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.TransferId == id + orderby l.SortOrder + select new TransferLineDto( + l.Id, l.ProductId, p.Name, p.Article, + u.Name, + l.Quantity, l.UnitCost, l.LineTotal, l.SortOrder, + _db.Stocks.Where(s => s.ProductId == l.ProductId && s.StoreId == row.t.FromStoreId) + .Select(s => (decimal?)s.Quantity).FirstOrDefault())) + .ToListAsync(ct); + + return new TransferDto( + row.t.Id, row.t.Number, row.t.Date, row.t.Status, + row.fs.Id, row.fs.Name, + row.ts.Id, row.ts.Name, + row.t.Notes, + row.t.Total, row.t.PostedAt, + lines); + } +} diff --git a/src/food-market.domain/Inventory/Transfer.cs b/src/food-market.domain/Inventory/Transfer.cs new file mode 100644 index 0000000..48cdda4 --- /dev/null +++ b/src/food-market.domain/Inventory/Transfer.cs @@ -0,0 +1,55 @@ +using foodmarket.Domain.Catalog; +using foodmarket.Domain.Common; + +namespace foodmarket.Domain.Inventory; + +public enum TransferStatus +{ + Draft = 0, + Posted = 1, +} + +/// Документ перемещения товара между складами одной организации. +/// При проведении создаёт ПАРУ движений: TransferOut из FromStore и +/// TransferIn в ToStore (атомарной транзакцией). Unpost тоже атомарен — +/// возвращает обратно ОБА движения. Никогда не должно остаться orphan-движений +/// (только один из двух). +public class Transfer : TenantEntity +{ + public string Number { get; set; } = ""; + public DateTime Date { get; set; } = DateTime.UtcNow; + public TransferStatus Status { get; set; } = TransferStatus.Draft; + + public Guid FromStoreId { get; set; } + public Store FromStore { get; set; } = null!; + + public Guid ToStoreId { get; set; } + public Store ToStore { get; set; } = null!; + + public string? Notes { get; set; } + + /// Балансовая сумма перемещения = Σ Quantity·UnitCost (только для отчётов; + /// при проведении не влияет на Product.Cost). + public decimal Total { get; set; } + + public DateTime? PostedAt { get; set; } + public Guid? PostedByUserId { get; set; } + + public ICollection Lines { get; set; } = new List(); +} + +public class TransferLine : TenantEntity +{ + public Guid TransferId { get; set; } + public Transfer Transfer { get; set; } = null!; + + public Guid ProductId { get; set; } + public Product Product { get; set; } = null!; + + public decimal Quantity { get; set; } + /// Балансовая цена единицы (снимок Product.Cost). На остатках не отражается. + public decimal UnitCost { get; set; } + public decimal LineTotal { get; set; } + + public int SortOrder { get; set; } +} diff --git a/src/food-market.domain/Organizations/RolePermissions.cs b/src/food-market.domain/Organizations/RolePermissions.cs index 3a9b11f..e9ff968 100644 --- a/src/food-market.domain/Organizations/RolePermissions.cs +++ b/src/food-market.domain/Organizations/RolePermissions.cs @@ -35,6 +35,7 @@ public class RolePermissions public bool InventoryEdit { get; set; } public bool LossEdit { get; set; } public bool EnterEdit { get; set; } + public bool TransferEdit { get; set; } // Отчёты public bool ReportsView { get; set; } @@ -59,7 +60,7 @@ public static RolePermissions All() => new() DemandsView = true, DemandsEdit = true, DemandsPost = true, RetailSalesOperate = true, RetailSalesRefund = true, CounterpartiesView = true, CounterpartiesEdit = true, CounterpartiesDelete = true, - StocksView = true, InventoryEdit = true, LossEdit = true, EnterEdit = true, + StocksView = true, InventoryEdit = true, LossEdit = true, EnterEdit = true, TransferEdit = true, ReportsView = true, ReportsFinanceView = true, ReportsStockView = true, OrgSettingsManage = true, EmployeesManage = true, RolesManage = true, StoresManage = true, RetailPointsManage = true, diff --git a/src/food-market.infrastructure/Persistence/AppDbContext.cs b/src/food-market.infrastructure/Persistence/AppDbContext.cs index 54714b4..748b141 100644 --- a/src/food-market.infrastructure/Persistence/AppDbContext.cs +++ b/src/food-market.infrastructure/Persistence/AppDbContext.cs @@ -50,6 +50,9 @@ public AppDbContext(DbContextOptions options, ITenantContext tenan public DbSet Losses => Set(); public DbSet LossLines => Set(); + public DbSet Transfers => Set(); + public DbSet TransferLines => Set(); + public DbSet RetailSales => Set(); public DbSet RetailSaleLines => Set(); diff --git a/src/food-market.infrastructure/Persistence/Configurations/InventoryConfigurations.cs b/src/food-market.infrastructure/Persistence/Configurations/InventoryConfigurations.cs index ced0d59..3cf349d 100644 --- a/src/food-market.infrastructure/Persistence/Configurations/InventoryConfigurations.cs +++ b/src/food-market.infrastructure/Persistence/Configurations/InventoryConfigurations.cs @@ -67,5 +67,34 @@ public static void ConfigureInventory(this ModelBuilder b) e.HasIndex(x => new { x.OrganizationId, x.ProductId }); }); + + b.Entity(e => + { + e.ToTable("transfers"); + e.Property(x => x.Number).HasMaxLength(50).IsRequired(); + e.Property(x => x.Notes).HasMaxLength(1000); + e.Property(x => x.Total).HasPrecision(18, 4); + + e.HasOne(x => x.FromStore).WithMany().HasForeignKey(x => x.FromStoreId).OnDelete(DeleteBehavior.Restrict); + e.HasOne(x => x.ToStore).WithMany().HasForeignKey(x => x.ToStoreId).OnDelete(DeleteBehavior.Restrict); + + e.HasMany(x => x.Lines).WithOne(l => l.Transfer).HasForeignKey(l => l.TransferId).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 }); + }); + + b.Entity(e => + { + e.ToTable("transfer_lines"); + e.Property(x => x.Quantity).HasPrecision(18, 4); + e.Property(x => x.UnitCost).HasPrecision(18, 4); + e.Property(x => x.LineTotal).HasPrecision(18, 4); + + 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/20260528020000_Phase6c_Transfers.cs b/src/food-market.infrastructure/Persistence/Migrations/20260528020000_Phase6c_Transfers.cs new file mode 100644 index 0000000..2422c64 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260528020000_Phase6c_Transfers.cs @@ -0,0 +1,74 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Phase6c — перемещение (Transfer). + /// + /// Документ перемещения товара между складами одной организации. При + /// проведении создаётся ПАРА движений в stock_movements: TransferOut + /// (-Qty из FromStore) + TransferIn (+Qty в ToStore). Атомарно. + [DbContext(typeof(AppDbContext))] + [Migration("20260528020000_Phase6c_Transfers")] + public partial class Phase6c_Transfers : Migration + { + protected override void Up(MigrationBuilder b) + { + b.Sql(@" + CREATE TABLE IF NOT EXISTS public.transfers ( + ""Id"" uuid PRIMARY KEY, + ""OrganizationId"" uuid NOT NULL, + ""Number"" varchar(50) NOT NULL, + ""Date"" timestamp with time zone NOT NULL, + ""Status"" integer NOT NULL, + ""FromStoreId"" uuid NOT NULL, + ""ToStoreId"" uuid NOT NULL, + ""Notes"" varchar(1000), + ""Total"" numeric(18,4) NOT NULL, + ""PostedAt"" timestamp with time zone, + ""PostedByUserId"" uuid, + ""CreatedAt"" timestamp with time zone NOT NULL, + ""UpdatedAt"" timestamp with time zone, + CONSTRAINT ""FK_transfers_stores_FromStoreId"" FOREIGN KEY (""FromStoreId"") REFERENCES public.stores(""Id"") ON DELETE RESTRICT, + CONSTRAINT ""FK_transfers_stores_ToStoreId"" FOREIGN KEY (""ToStoreId"") REFERENCES public.stores(""Id"") ON DELETE RESTRICT + ); + + CREATE UNIQUE INDEX IF NOT EXISTS ""IX_transfers_OrganizationId_Number"" ON public.transfers (""OrganizationId"", ""Number""); + CREATE INDEX IF NOT EXISTS ""IX_transfers_OrganizationId_Date"" ON public.transfers (""OrganizationId"", ""Date""); + CREATE INDEX IF NOT EXISTS ""IX_transfers_OrganizationId_Status"" ON public.transfers (""OrganizationId"", ""Status""); + CREATE INDEX IF NOT EXISTS ""IX_transfers_FromStoreId"" ON public.transfers (""FromStoreId""); + CREATE INDEX IF NOT EXISTS ""IX_transfers_ToStoreId"" ON public.transfers (""ToStoreId""); + + CREATE TABLE IF NOT EXISTS public.transfer_lines ( + ""Id"" uuid PRIMARY KEY, + ""OrganizationId"" uuid NOT NULL, + ""TransferId"" uuid NOT NULL, + ""ProductId"" uuid NOT NULL, + ""Quantity"" numeric(18,4) NOT NULL, + ""UnitCost"" numeric(18,4) NOT NULL, + ""LineTotal"" numeric(18,4) NOT NULL, + ""SortOrder"" integer NOT NULL, + ""CreatedAt"" timestamp with time zone NOT NULL, + ""UpdatedAt"" timestamp with time zone, + CONSTRAINT ""FK_transfer_lines_transfers_TransferId"" FOREIGN KEY (""TransferId"") REFERENCES public.transfers(""Id"") ON DELETE CASCADE, + CONSTRAINT ""FK_transfer_lines_products_ProductId"" FOREIGN KEY (""ProductId"") REFERENCES public.products(""Id"") ON DELETE RESTRICT + ); + + CREATE INDEX IF NOT EXISTS ""IX_transfer_lines_TransferId"" ON public.transfer_lines (""TransferId""); + CREATE INDEX IF NOT EXISTS ""IX_transfer_lines_ProductId"" ON public.transfer_lines (""ProductId""); + CREATE INDEX IF NOT EXISTS ""IX_transfer_lines_OrganizationId_ProductId"" ON public.transfer_lines (""OrganizationId"", ""ProductId""); + "); + } + + protected override void Down(MigrationBuilder b) + { + b.Sql(@" + DROP TABLE IF EXISTS public.transfer_lines; + DROP TABLE IF EXISTS public.transfers; + "); + } + } +} diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index 9f51b5e..2a08f29 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -32,6 +32,8 @@ import { EntersPage } from '@/pages/EntersPage' import { EnterEditPage } from '@/pages/EnterEditPage' import { LossesPage } from '@/pages/LossesPage' import { LossEditPage } from '@/pages/LossEditPage' +import { TransfersPage } from '@/pages/TransfersPage' +import { TransferEditPage } from '@/pages/TransferEditPage' import { RetailSalesPage } from '@/pages/RetailSalesPage' import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage' import { AppLayout } from '@/components/AppLayout' @@ -110,6 +112,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 52c5c0a..b42e3e9 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, + Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, PackagePlus, PackageMinus, ArrowRightLeft, } from 'lucide-react' import { Logo } from './Logo' import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner' @@ -87,6 +87,7 @@ function buildNav(roles: string[]): NavSection[] { stock.push({ to: '/inventory/movements', icon: History, label: 'Движения' }) stock.push({ to: '/inventory/enters', icon: PackagePlus, label: 'Оприходования' }) stock.push({ to: '/inventory/losses', icon: PackageMinus, label: 'Списания' }) + stock.push({ to: '/inventory/transfers', icon: ArrowRightLeft, label: 'Перемещения' }) } sections.push({ group: 'Остатки', items: stock }) } diff --git a/src/food-market.web/src/lib/types.ts b/src/food-market.web/src/lib/types.ts index d8d6a90..d47ae34 100644 --- a/src/food-market.web/src/lib/types.ts +++ b/src/food-market.web/src/lib/types.ts @@ -173,6 +173,32 @@ export interface LossDto { lines: LossLineDto[]; } +export const TransferStatus = { Draft: 0, Posted: 1 } as const +export type TransferStatus = (typeof TransferStatus)[keyof typeof TransferStatus] + +export interface TransferListRow { + id: string; number: string; date: string; status: TransferStatus; + fromStoreId: string; fromStoreName: string; + toStoreId: string; toStoreName: string; + total: number; lineCount: number; postedAt: string | null; +} + +export interface TransferLineDto { + id: string | null; productId: string; + productName: string | null; productArticle: string | null; unitSymbol: string | null; + quantity: number; unitCost: number; lineTotal: number; sortOrder: number; + stockAtFrom: number | null; +} + +export interface TransferDto { + id: string; number: string; date: string; status: TransferStatus; + fromStoreId: string; fromStoreName: string; + toStoreId: string; toStoreName: string; + notes: string | null; + total: number; postedAt: string | null; + lines: TransferLineDto[]; +} + export const RetailSaleStatus = { Draft: 0, Posted: 1 } as const export type RetailSaleStatus = (typeof RetailSaleStatus)[keyof typeof RetailSaleStatus] diff --git a/src/food-market.web/src/pages/TransferEditPage.tsx b/src/food-market.web/src/pages/TransferEditPage.tsx new file mode 100644 index 0000000..5d549af --- /dev/null +++ b/src/food-market.web/src/pages/TransferEditPage.tsx @@ -0,0 +1,344 @@ +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, ArrowRight, Plus, Trash2, Save, CheckCircle } from 'lucide-react' +import { api } from '@/lib/api' +import { Button } from '@/components/Button' +import { Field, TextArea, Select, MoneyInput, NumberInput, Checkbox } from '@/components/Field' +import { DateField } from '@/components/DateField' +import { ProductPicker } from '@/components/ProductPicker' +import { useStores } from '@/lib/useLookups' +import { useOrgSettings } from '@/lib/useOrgSettings' +import { TransferStatus, type TransferDto, type Product } from '@/lib/types' + +interface LineRow { + productId: string + productName: string + productArticle: string | null + unitSymbol: string | null + quantity: number + unitCost: number + stockAtFrom: number | null +} + +interface Form { + date: string + fromStoreId: string + toStoreId: string + 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(), fromStoreId: '', toStoreId: '', notes: '', lines: [], +} + +export function TransferEditPage() { + const { id } = useParams<{ id: string }>() + const isNew = !id || id === 'new' + const navigate = useNavigate() + const qc = useQueryClient() + + const stores = useStores() + const org = useOrgSettings() + + const [form, setForm] = useState
(emptyForm) + const [pickerOpen, setPickerOpen] = useState(false) + const [error, setError] = useState(null) + + const existing = useQuery({ + queryKey: ['/api/inventory/transfers', id], + queryFn: async () => (await api.get(`/api/inventory/transfers/${id}`)).data, + enabled: !isNew, + }) + + useEffect(() => { + if (!isNew && existing.data) { + const s = existing.data + setForm({ + date: s.date.slice(0, 10), + fromStoreId: s.fromStoreId, + toStoreId: s.toStoreId, + notes: s.notes ?? '', + lines: s.lines.map((l) => ({ + productId: l.productId, + productName: l.productName ?? '', + productArticle: l.productArticle, + unitSymbol: l.unitSymbol, + quantity: l.quantity, + unitCost: l.unitCost, + stockAtFrom: l.stockAtFrom, + })), + }) + } + }, [isNew, existing.data]) + + const isDraft = isNew || existing.data?.status === TransferStatus.Draft + const isPosted = existing.data?.status === TransferStatus.Posted + + const lineTotal = (l: LineRow) => l.quantity * l.unitCost + const grandTotal = form.lines.reduce((s, l) => s + lineTotal(l), 0) + const sameStore = !!form.fromStoreId && form.fromStoreId === form.toStoreId + + const save = useMutation({ + mutationFn: async () => { + const payload = { + date: new Date(form.date).toISOString(), + fromStoreId: form.fromStoreId, + toStoreId: form.toStoreId, + notes: form.notes || null, + lines: form.lines.map((l) => ({ + productId: l.productId, quantity: l.quantity, unitCost: l.unitCost, + })), + } + if (isNew) return (await api.post('/api/inventory/transfers', payload)).data + await api.put(`/api/inventory/transfers/${id}`, payload) + return null + }, + onSuccess: (created) => { + qc.invalidateQueries({ queryKey: ['/api/inventory/transfers'] }) + navigate(created ? `/inventory/transfers/${created.id}` : `/inventory/transfers/${id}`) + }, + onError: (e: Error) => setError(e.message), + }) + + const post = useMutation({ + mutationFn: async () => { await api.post(`/api/inventory/transfers/${id}/post`) }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['/api/inventory/transfers'] }) + 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/inventory/transfers/${id}/unpost`) }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['/api/inventory/transfers'] }) + 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 remove = useMutation({ + mutationFn: async () => { await api.delete(`/api/inventory/transfers/${id}`) }, + onSuccess: () => navigate('/inventory/transfers'), + 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, + unitCost: p.cost ?? 0, + stockAtFrom: 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.fromStoreId && !!form.toStoreId && !sameStore + && 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 ?? '' })} /> + + + + + + + + +