diff --git a/src/food-market.api/Controllers/Inventory/InventoriesController.cs b/src/food-market.api/Controllers/Inventory/InventoriesController.cs new file mode 100644 index 0000000..649cb13 --- /dev/null +++ b/src/food-market.api/Controllers/Inventory/InventoriesController.cs @@ -0,0 +1,426 @@ +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; + +/// Инвентаризация (пересчёт). Создание подгружает текущие остатки +/// склада в bookQty; пользователь вносит фактические количества; +/// при Post создаются корректирующие движения +/// на diff = actual - book (положительный — приход, отрицательный +/// — списание). +[ApiController] +[Authorize] +[Route("api/inventory/inventories")] +public class InventoriesController : ControllerBase +{ + private readonly AppDbContext _db; + private readonly IStockService _stock; + + public InventoriesController(AppDbContext db, IStockService stock) + { + _db = db; + _stock = stock; + } + + public record InventoryListRow( + Guid Id, string Number, DateTime Date, InventoryStatus Status, + Guid StoreId, string StoreName, + int LineCount, + decimal SurplusValue, decimal ShortageValue, + DateTime? PostedAt); + + public record InventoryLineDto( + Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, string? UnitSymbol, + decimal BookQty, decimal ActualQty, decimal Diff, decimal UnitCost, + int SortOrder); + + public record InventoryDto( + Guid Id, string Number, DateTime Date, InventoryStatus Status, + Guid StoreId, string StoreName, + string? Notes, + DateTime? PostedAt, + IReadOnlyList Lines); + + public record InventoryLineInput( + Guid ProductId, + [Range(0, 1e10)] decimal ActualQty); + + public record InventoryInput( + DateTime Date, Guid StoreId, + string? Notes, + /// Если null/пусто — контроллер сам заполнит строками всеми + /// товарами склада с их текущим Stock в качестве bookQty и actual=0. + IReadOnlyList? Lines); + + [HttpGet] + public async Task>> List( + [FromQuery] PagedRequest req, + [FromQuery] InventoryStatus? status, + [FromQuery] Guid? storeId, + CancellationToken ct) + { + var q = from i in _db.InventoryDocs.AsNoTracking() + join st in _db.Stores on i.StoreId equals st.Id + select new { i, st }; + + if (status is not null) q = q.Where(x => x.i.Status == status); + if (storeId is not null) q = q.Where(x => x.i.StoreId == storeId); + if (!string.IsNullOrWhiteSpace(req.Search)) + { + var s = req.Search.Trim().ToLower(); + q = q.Where(x => x.i.Number.ToLower().Contains(s)); + } + + var total = await q.CountAsync(ct); + q = (req.Sort, req.Desc) switch + { + ("number", false) => q.OrderBy(x => x.i.Number), + ("number", true) => q.OrderByDescending(x => x.i.Number), + ("status", false) => q.OrderBy(x => x.i.Status).ThenByDescending(x => x.i.Date), + ("status", true) => q.OrderByDescending(x => x.i.Status).ThenByDescending(x => x.i.Date), + ("date", false) => q.OrderBy(x => x.i.Date).ThenBy(x => x.i.Number), + _ => q.OrderByDescending(x => x.i.Date).ThenByDescending(x => x.i.Number), + }; + var items = await q + .Skip(req.Skip).Take(req.Take) + .Select(x => new InventoryListRow( + x.i.Id, x.i.Number, x.i.Date, x.i.Status, + x.st.Id, x.st.Name, + x.i.Lines.Count, + x.i.Lines.Where(l => l.Diff > 0).Sum(l => l.Diff * l.UnitCost), + x.i.Lines.Where(l => l.Diff < 0).Sum(l => l.Diff * l.UnitCost), + x.i.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("InventoryEdit")] + public async Task> Create([FromBody] InventoryInput input, CancellationToken ct) + { + if (RequiredGuid.FirstMissing((nameof(input.StoreId), input.StoreId)) is { } missing) + return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing }); + + var number = await GenerateNumberAsync(input.Date, ct); + var doc = new InventoryDoc + { + Number = number, + Date = input.Date, + Status = InventoryStatus.Draft, + StoreId = input.StoreId, + Notes = input.Notes, + }; + + // Если строки не указаны — подтягиваем все товары с ненулевым stock на складе. + if (input.Lines is null || input.Lines.Count == 0) + { + var stocks = await (from s in _db.Stocks.AsNoTracking() + join p in _db.Products.AsNoTracking() on s.ProductId equals p.Id + where s.StoreId == input.StoreId + select new { s.ProductId, s.Quantity, p.Cost }) + .ToListAsync(ct); + var order = 0; + foreach (var st in stocks.OrderBy(x => x.ProductId)) + { + doc.Lines.Add(new InventoryLine + { + ProductId = st.ProductId, + BookQty = st.Quantity, + ActualQty = 0, + Diff = -st.Quantity, + UnitCost = st.Cost, + SortOrder = order++, + }); + } + } + else + { + var productIds = input.Lines.Select(l => l.ProductId).Distinct().ToList(); + var book = await _db.Stocks.Where(s => s.StoreId == input.StoreId && productIds.Contains(s.ProductId)) + .ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct); + var costs = await _db.Products.Where(p => productIds.Contains(p.Id)) + .ToDictionaryAsync(p => p.Id, p => p.Cost, ct); + var order = 0; + foreach (var l in input.Lines) + { + book.TryGetValue(l.ProductId, out var b); + costs.TryGetValue(l.ProductId, out var c); + doc.Lines.Add(new InventoryLine + { + ProductId = l.ProductId, + BookQty = b, + ActualQty = l.ActualQty, + Diff = l.ActualQty - b, + UnitCost = c, + SortOrder = order++, + }); + } + } + + _db.InventoryDocs.Add(doc); + if (await SaveOrFkErrorAsync(ct) is { } err) return err; + var dto = await GetInternal(doc.Id, ct); + return CreatedAtAction(nameof(Get), new { id = doc.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("Store") ? "storeId" + : name.Contains("Product") ? "productId" + : "(unknown)"; + return BadRequest(new { error = $"Связанная запись не найдена: {field}.", field, constraint = name }); + } + } + + [HttpPut("{id:guid}"), RequiresPermission("InventoryEdit")] + public async Task Update(Guid id, [FromBody] InventoryInput input, CancellationToken ct) + { + var doc = await _db.InventoryDocs.Include(d => d.Lines).FirstOrDefaultAsync(d => d.Id == id, ct); + if (doc is null) return NotFound(); + if (doc.Status != InventoryStatus.Draft) + return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." }); + + doc.Date = input.Date; + doc.Notes = input.Notes; + // StoreId на UPDATE не меняем — это пересчитало бы bookQty целиком. + + if (input.Lines is not null && input.Lines.Count > 0) + { + // Обновление actualQty по существующим строкам. + var byProduct = doc.Lines.ToDictionary(l => l.ProductId); + foreach (var ln in input.Lines) + { + if (byProduct.TryGetValue(ln.ProductId, out var existing)) + { + existing.ActualQty = ln.ActualQty; + existing.Diff = ln.ActualQty - existing.BookQty; + } + else + { + // Новая строка — подгружаем book на момент изменения. + var b = await _db.Stocks.Where(s => s.StoreId == doc.StoreId && s.ProductId == ln.ProductId) + .Select(s => (decimal?)s.Quantity).FirstOrDefaultAsync(ct) ?? 0m; + var c = await _db.Products.Where(p => p.Id == ln.ProductId).Select(p => p.Cost).FirstOrDefaultAsync(ct); + doc.Lines.Add(new InventoryLine + { + InventoryDocId = doc.Id, + ProductId = ln.ProductId, + BookQty = b, + ActualQty = ln.ActualQty, + Diff = ln.ActualQty - b, + UnitCost = c, + SortOrder = doc.Lines.Count, + }); + } + } + } + + if (await SaveOrFkErrorAsync(ct) is { } err) return err; + return NoContent(); + } + + [HttpDelete("{id:guid}"), RequiresPermission("InventoryEdit")] + public async Task Delete(Guid id, CancellationToken ct) + { + var doc = await _db.InventoryDocs.FirstOrDefaultAsync(d => d.Id == id, ct); + if (doc is null) return NotFound(); + if (doc.Status != InventoryStatus.Draft) + return Conflict(new { error = "Нельзя удалить проведённый документ. Сначала отмени проведение." }); + _db.InventoryDocs.Remove(doc); + await _db.SaveChangesAsync(ct); + return NoContent(); + } + + [HttpPost("{id:guid}/post"), RequiresPermission("InventoryEdit")] + public async Task Post(Guid id, CancellationToken ct) + { + var doc = await _db.InventoryDocs.Include(d => d.Lines).FirstOrDefaultAsync(d => d.Id == id, ct); + if (doc is null) return NotFound(); + if (doc.Status == InventoryStatus.Posted) return Conflict(new { error = "Документ уже проведён." }); + if (doc.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." }); + + var withDiff = doc.Lines.Where(l => l.Diff != 0m).ToList(); + if (withDiff.Count == 0) + return BadRequest(new { error = "Нет расхождений учётного и фактического количества — нечего проводить." }); + + await using var tx = await _db.Database.BeginTransactionAsync( + System.Data.IsolationLevel.Serializable, ct); + var now = DateTime.UtcNow; + + foreach (var line in withDiff) + { + await _stock.ApplyMovementAsync(new StockMovementDraft( + ProductId: line.ProductId, + StoreId: doc.StoreId, + Quantity: line.Diff, // positive: surplus; negative: shortage + Type: MovementType.InventoryAdjustment, + DocumentType: "inventory", + DocumentId: doc.Id, + DocumentNumber: doc.Number, + UnitCost: line.UnitCost, + OccurredAt: doc.Date, + Notes: line.Diff > 0 ? "surplus" : "shortage"), ct); + } + + doc.Status = InventoryStatus.Posted; + doc.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("InventoryEdit")] + public async Task Unpost(Guid id, CancellationToken ct) + { + var doc = await _db.InventoryDocs.Include(d => d.Lines).FirstOrDefaultAsync(d => d.Id == id, ct); + if (doc is null) return NotFound(); + if (doc.Status != InventoryStatus.Posted) return Conflict(new { error = "Документ не проведён." }); + + // Reverse: для каждой строки c diff != 0 — обратное движение на -diff. + // Защита от ухода в минус: если diff был положительный (излишек), при unpost + // мы списываем (-diff = -surplus) — стоит проверить что эта величина в наличии. + var positive = doc.Lines.Where(l => l.Diff > 0) + .GroupBy(l => l.ProductId) + .Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Diff) }).ToList(); + var productIds = positive.Select(x => x.ProductId).ToList(); + var stocks = await _db.Stocks + .Where(s => s.StoreId == doc.StoreId && productIds.Contains(s.ProductId)) + .ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct); + var conflicts = new List(); + foreach (var r in positive) + { + 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, + 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 doc.Lines.Where(l => l.Diff != 0m)) + { + await _stock.ApplyMovementAsync(new StockMovementDraft( + ProductId: line.ProductId, + StoreId: doc.StoreId, + Quantity: -line.Diff, + Type: MovementType.InventoryAdjustment, + DocumentType: "inventory-reversal", + DocumentId: doc.Id, + DocumentNumber: doc.Number, + UnitCost: line.UnitCost, + OccurredAt: DateTime.UtcNow, + Notes: $"Отмена проведения документа {doc.Number}"), ct); + } + + doc.Status = InventoryStatus.Draft; + doc.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.InventoryDocs + .Where(i => i.Number.StartsWith(prefix)) + .OrderByDescending(i => i.Number) + .Select(i => i.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 i in _db.InventoryDocs.AsNoTracking() + join st in _db.Stores on i.StoreId equals st.Id + where i.Id == id + select new { i, st }).FirstOrDefaultAsync(ct); + if (row is null) return null; + + var lines = await (from l in _db.InventoryLines.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.InventoryDocId == id + orderby l.SortOrder + select new InventoryLineDto( + l.Id, l.ProductId, p.Name, p.Article, u.Name, + l.BookQty, l.ActualQty, l.Diff, l.UnitCost, l.SortOrder)) + .ToListAsync(ct); + + return new InventoryDto( + row.i.Id, row.i.Number, row.i.Date, row.i.Status, + row.st.Id, row.st.Name, + row.i.Notes, row.i.PostedAt, + lines); + } +} diff --git a/src/food-market.domain/Inventory/InventoryDoc.cs b/src/food-market.domain/Inventory/InventoryDoc.cs new file mode 100644 index 0000000..674f7bd --- /dev/null +++ b/src/food-market.domain/Inventory/InventoryDoc.cs @@ -0,0 +1,61 @@ +using foodmarket.Domain.Catalog; +using foodmarket.Domain.Common; + +namespace foodmarket.Domain.Inventory; + +public enum InventoryStatus +{ + Draft = 0, + Posted = 1, +} + +/// Документ инвентаризации (пересчёта). При создании контроллер +/// заполняет bookQty по текущему склада. После +/// внесения фактических количеств (actualQty) при Post создаются +/// корректирующие движения +/// на diff = actual - book: положительные приходят (излишек), +/// отрицательные списываются (недостача). +/// +/// Назван InventoryDoc чтобы не конфликтовать с .NET-неймспейсом +/// System.Collections.Specialized.Inventory и не путаться с самой +/// сущностью «остатков». Таблица — inventories. +public class InventoryDoc : TenantEntity +{ + public string Number { get; set; } = ""; + public DateTime Date { get; set; } = DateTime.UtcNow; + public InventoryStatus Status { get; set; } = InventoryStatus.Draft; + + public Guid StoreId { get; set; } + public Store Store { get; set; } = null!; + + public string? Notes { get; set; } + + public DateTime? PostedAt { get; set; } + public Guid? PostedByUserId { get; set; } + + public ICollection Lines { get; set; } = new List(); +} + +public class InventoryLine : TenantEntity +{ + public Guid InventoryDocId { get; set; } + public InventoryDoc InventoryDoc { get; set; } = null!; + + public Guid ProductId { get; set; } + public Product Product { get; set; } = null!; + + /// Учётное количество (Stock.Quantity на момент создания/обновления документа). + public decimal BookQty { get; set; } + + /// Фактическое количество (введено вручную или импортом CSV). + public decimal ActualQty { get; set; } + + /// diff = ActualQty - BookQty (положительный — излишек, отрицательный — недостача). + /// Вычисляется и сохраняется при сохранении строки для отчётности. + public decimal Diff { get; set; } + + /// Снимок Product.Cost для расчёта суммы излишка/недостачи (только для отчётов). + public decimal UnitCost { 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 748b141..fa6cf43 100644 --- a/src/food-market.infrastructure/Persistence/AppDbContext.cs +++ b/src/food-market.infrastructure/Persistence/AppDbContext.cs @@ -53,6 +53,9 @@ public AppDbContext(DbContextOptions options, ITenantContext tenan public DbSet Transfers => Set(); public DbSet TransferLines => Set(); + public DbSet InventoryDocs => Set(); + public DbSet InventoryLines => 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 3cf349d..f6c1315 100644 --- a/src/food-market.infrastructure/Persistence/Configurations/InventoryConfigurations.cs +++ b/src/food-market.infrastructure/Persistence/Configurations/InventoryConfigurations.cs @@ -96,5 +96,33 @@ public static void ConfigureInventory(this ModelBuilder b) e.HasIndex(x => new { x.OrganizationId, x.ProductId }); }); + + b.Entity(e => + { + e.ToTable("inventories"); + e.Property(x => x.Number).HasMaxLength(50).IsRequired(); + e.Property(x => x.Notes).HasMaxLength(1000); + + e.HasOne(x => x.Store).WithMany().HasForeignKey(x => x.StoreId).OnDelete(DeleteBehavior.Restrict); + + e.HasMany(x => x.Lines).WithOne(l => l.InventoryDoc).HasForeignKey(l => l.InventoryDocId).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("inventory_lines"); + e.Property(x => x.BookQty).HasPrecision(18, 4); + e.Property(x => x.ActualQty).HasPrecision(18, 4); + e.Property(x => x.Diff).HasPrecision(18, 4); + e.Property(x => x.UnitCost).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/20260528030000_Phase6d_Inventories.cs b/src/food-market.infrastructure/Persistence/Migrations/20260528030000_Phase6d_Inventories.cs new file mode 100644 index 0000000..9b22aff --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260528030000_Phase6d_Inventories.cs @@ -0,0 +1,72 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Phase6d — инвентаризация (InventoryDoc). + /// + /// Документ инвентаризации хранит снимок учётных остатков (bookQty) и + /// фактические количества (actualQty) с разницей diff = actual - book. + /// При проведении создаётся корректирующее движение типа + /// InventoryAdjustment на diff для каждой строки. + [DbContext(typeof(AppDbContext))] + [Migration("20260528030000_Phase6d_Inventories")] + public partial class Phase6d_Inventories : Migration + { + protected override void Up(MigrationBuilder b) + { + b.Sql(@" + CREATE TABLE IF NOT EXISTS public.inventories ( + ""Id"" uuid PRIMARY KEY, + ""OrganizationId"" uuid NOT NULL, + ""Number"" varchar(50) NOT NULL, + ""Date"" timestamp with time zone NOT NULL, + ""Status"" integer NOT NULL, + ""StoreId"" uuid NOT NULL, + ""Notes"" varchar(1000), + ""PostedAt"" timestamp with time zone, + ""PostedByUserId"" uuid, + ""CreatedAt"" timestamp with time zone NOT NULL, + ""UpdatedAt"" timestamp with time zone, + CONSTRAINT ""FK_inventories_stores_StoreId"" FOREIGN KEY (""StoreId"") REFERENCES public.stores(""Id"") ON DELETE RESTRICT + ); + + CREATE UNIQUE INDEX IF NOT EXISTS ""IX_inventories_OrganizationId_Number"" ON public.inventories (""OrganizationId"", ""Number""); + CREATE INDEX IF NOT EXISTS ""IX_inventories_OrganizationId_Date"" ON public.inventories (""OrganizationId"", ""Date""); + CREATE INDEX IF NOT EXISTS ""IX_inventories_OrganizationId_Status"" ON public.inventories (""OrganizationId"", ""Status""); + CREATE INDEX IF NOT EXISTS ""IX_inventories_StoreId"" ON public.inventories (""StoreId""); + + CREATE TABLE IF NOT EXISTS public.inventory_lines ( + ""Id"" uuid PRIMARY KEY, + ""OrganizationId"" uuid NOT NULL, + ""InventoryDocId"" uuid NOT NULL, + ""ProductId"" uuid NOT NULL, + ""BookQty"" numeric(18,4) NOT NULL, + ""ActualQty"" numeric(18,4) NOT NULL, + ""Diff"" numeric(18,4) NOT NULL, + ""UnitCost"" numeric(18,4) NOT NULL, + ""SortOrder"" integer NOT NULL, + ""CreatedAt"" timestamp with time zone NOT NULL, + ""UpdatedAt"" timestamp with time zone, + CONSTRAINT ""FK_inventory_lines_inventories_InventoryDocId"" FOREIGN KEY (""InventoryDocId"") REFERENCES public.inventories(""Id"") ON DELETE CASCADE, + CONSTRAINT ""FK_inventory_lines_products_ProductId"" FOREIGN KEY (""ProductId"") REFERENCES public.products(""Id"") ON DELETE RESTRICT + ); + + CREATE INDEX IF NOT EXISTS ""IX_inventory_lines_InventoryDocId"" ON public.inventory_lines (""InventoryDocId""); + CREATE INDEX IF NOT EXISTS ""IX_inventory_lines_ProductId"" ON public.inventory_lines (""ProductId""); + CREATE INDEX IF NOT EXISTS ""IX_inventory_lines_OrganizationId_ProductId"" ON public.inventory_lines (""OrganizationId"", ""ProductId""); + "); + } + + protected override void Down(MigrationBuilder b) + { + b.Sql(@" + DROP TABLE IF EXISTS public.inventory_lines; + DROP TABLE IF EXISTS public.inventories; + "); + } + } +} diff --git a/src/food-market.web/src/App.tsx b/src/food-market.web/src/App.tsx index 2a08f29..5051e0a 100644 --- a/src/food-market.web/src/App.tsx +++ b/src/food-market.web/src/App.tsx @@ -34,6 +34,8 @@ import { LossesPage } from '@/pages/LossesPage' import { LossEditPage } from '@/pages/LossEditPage' import { TransfersPage } from '@/pages/TransfersPage' import { TransferEditPage } from '@/pages/TransferEditPage' +import { InventoriesPage } from '@/pages/InventoriesPage' +import { InventoryEditPage } from '@/pages/InventoryEditPage' import { RetailSalesPage } from '@/pages/RetailSalesPage' import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage' import { AppLayout } from '@/components/AppLayout' @@ -115,6 +117,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 b42e3e9..f632481 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, + Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, PackagePlus, PackageMinus, ArrowRightLeft, ClipboardCheck, } from 'lucide-react' import { Logo } from './Logo' import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner' @@ -88,6 +88,7 @@ function buildNav(roles: string[]): NavSection[] { stock.push({ to: '/inventory/enters', icon: PackagePlus, label: 'Оприходования' }) stock.push({ to: '/inventory/losses', icon: PackageMinus, label: 'Списания' }) stock.push({ to: '/inventory/transfers', icon: ArrowRightLeft, label: 'Перемещения' }) + stock.push({ to: '/inventory/inventories', icon: ClipboardCheck, 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 d47ae34..2298e8e 100644 --- a/src/food-market.web/src/lib/types.ts +++ b/src/food-market.web/src/lib/types.ts @@ -199,6 +199,31 @@ export interface TransferDto { lines: TransferLineDto[]; } +export const InventoryStatus = { Draft: 0, Posted: 1 } as const +export type InventoryStatus = (typeof InventoryStatus)[keyof typeof InventoryStatus] + +export interface InventoryListRow { + id: string; number: string; date: string; status: InventoryStatus; + storeId: string; storeName: string; + lineCount: number; surplusValue: number; shortageValue: number; + postedAt: string | null; +} + +export interface InventoryLineDto { + id: string | null; productId: string; + productName: string | null; productArticle: string | null; unitSymbol: string | null; + bookQty: number; actualQty: number; diff: number; unitCost: number; + sortOrder: number; +} + +export interface InventoryDto { + id: string; number: string; date: string; status: InventoryStatus; + storeId: string; storeName: string; + notes: string | null; + postedAt: string | null; + lines: InventoryLineDto[]; +} + 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/InventoriesPage.tsx b/src/food-market.web/src/pages/InventoriesPage.tsx new file mode 100644 index 0000000..830ef0d --- /dev/null +++ b/src/food-market.web/src/pages/InventoriesPage.tsx @@ -0,0 +1,64 @@ +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 { useOrgSettings } from '@/lib/useOrgSettings' +import { type InventoryListRow, InventoryStatus } from '@/lib/types' + +const URL = '/api/inventory/inventories' + +export function InventoriesPage() { + const navigate = useNavigate() + const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList(URL) + const org = useOrgSettings() + const fractional = org.data?.allowFractionalPrices ?? false + const moneyFmt = fractional + ? { minimumFractionDigits: 2, maximumFractionDigits: 2 } + : { maximumFractionDigits: 0 } + + return ( + + + + + + + } + footer={data && data.total > 0 && ( + + )} + > + r.id} + sortKey={sortKey} + sortOrder={sortOrder} + onSortChange={setSort} + onRowClick={(r) => navigate(`/inventory/inventories/${r.id}`)} + columns={[ + { header: '№', width: '160px', sortKey: 'number', cell: (r) => {r.number} }, + { header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') }, + { header: 'Статус', width: '110px', sortKey: 'status', cell: (r) => ( + r.status === InventoryStatus.Posted + ? Проведён + : Черновик + )}, + { header: 'Склад', cell: (r) => r.storeName }, + { header: 'Позиций', width: '100px', className: 'text-right', cell: (r) => r.lineCount }, + { header: 'Излишек', width: '140px', className: 'text-right font-mono text-green-700', cell: (r) => `+${r.surplusValue.toLocaleString('ru', moneyFmt)}` }, + { header: 'Недостача', width: '140px', className: 'text-right font-mono text-red-700', cell: (r) => `${r.shortageValue.toLocaleString('ru', moneyFmt)}` }, + ]} + empty="Инвентаризаций пока нет." + /> + + ) +} diff --git a/src/food-market.web/src/pages/InventoryEditPage.tsx b/src/food-market.web/src/pages/InventoryEditPage.tsx new file mode 100644 index 0000000..9f2980f --- /dev/null +++ b/src/food-market.web/src/pages/InventoryEditPage.tsx @@ -0,0 +1,353 @@ +import { useState, useEffect, useRef, type FormEvent } from 'react' +import { useNavigate, useParams, Link } from 'react-router-dom' +import { useQuery, useQueryClient, useMutation } from '@tanstack/react-query' +import { ArrowLeft, Trash2, Save, CheckCircle, Upload } from 'lucide-react' +import { api } from '@/lib/api' +import { Button } from '@/components/Button' +import { Field, TextArea, Select, NumberInput, Checkbox } from '@/components/Field' +import { DateField } from '@/components/DateField' +import { useStores } from '@/lib/useLookups' +import { InventoryStatus, type InventoryDto } from '@/lib/types' + +interface LineRow { + productId: string + productName: string + productArticle: string | null + unitSymbol: string | null + bookQty: number + actualQty: number + diff: number + unitCost: number +} + +interface Form { + date: string + storeId: 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(), storeId: '', notes: '', lines: [] } + +export function InventoryEditPage() { + const { id } = useParams<{ id: string }>() + const isNew = !id || id === 'new' + const navigate = useNavigate() + const qc = useQueryClient() + + const stores = useStores() + const [form, setForm] = useState
(emptyForm) + const [error, setError] = useState(null) + const csvInputRef = useRef(null) + + const existing = useQuery({ + queryKey: ['/api/inventory/inventories', id], + queryFn: async () => (await api.get(`/api/inventory/inventories/${id}`)).data, + enabled: !isNew, + }) + + useEffect(() => { + if (!isNew && existing.data) { + const s = existing.data + setForm({ + date: s.date.slice(0, 10), + storeId: s.storeId, + notes: s.notes ?? '', + lines: s.lines.map((l) => ({ + productId: l.productId, + productName: l.productName ?? '', + productArticle: l.productArticle, + unitSymbol: l.unitSymbol, + bookQty: l.bookQty, + actualQty: l.actualQty, + diff: l.diff, + unitCost: l.unitCost, + })), + }) + } + }, [isNew, existing.data]) + + useEffect(() => { + if (isNew && !form.storeId && stores.data?.length) { + const main = stores.data.find((s) => s.isMain) ?? stores.data[0] + setForm((f) => ({ ...f, storeId: main.id })) + } + }, [isNew, stores.data, form.storeId]) + + const isDraft = isNew || existing.data?.status === InventoryStatus.Draft + const isPosted = existing.data?.status === InventoryStatus.Posted + + const surplusValue = form.lines.filter((l) => l.diff > 0).reduce((s, l) => s + l.diff * l.unitCost, 0) + const shortageValue = form.lines.filter((l) => l.diff < 0).reduce((s, l) => s + l.diff * l.unitCost, 0) + + const create = useMutation({ + mutationFn: async () => { + // Создание подгружает строки автоматически (lines: []) от текущего Stock склада. + const payload = { + date: new Date(form.date).toISOString(), + storeId: form.storeId, + notes: form.notes || null, + lines: [], + } + return (await api.post('/api/inventory/inventories', payload)).data + }, + onSuccess: (created) => { + qc.invalidateQueries({ queryKey: ['/api/inventory/inventories'] }) + navigate(`/inventory/inventories/${created.id}`) + }, + onError: (e: Error) => setError(e.message), + }) + + const save = useMutation({ + mutationFn: async () => { + const payload = { + date: new Date(form.date).toISOString(), + storeId: form.storeId, + notes: form.notes || null, + lines: form.lines.map((l) => ({ productId: l.productId, actualQty: l.actualQty })), + } + await api.put(`/api/inventory/inventories/${id}`, payload) + return null + }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['/api/inventory/inventories'] }) + existing.refetch() + }, + onError: (e: Error) => setError(e.message), + }) + + const post = useMutation({ + mutationFn: async () => { await api.post(`/api/inventory/inventories/${id}/post`) }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['/api/inventory/inventories'] }) + 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/inventories/${id}/unpost`) }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['/api/inventory/inventories'] }) + qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] }) + 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/inventories/${id}`) }, + onSuccess: () => navigate('/inventory/inventories'), + onError: (e: Error) => setError(e.message), + }) + + const onSubmit = (e: FormEvent) => { + e.preventDefault() + if (isNew) create.mutate() + else save.mutate() + } + + const updateLine = (i: number, actualQty: number) => { + setForm({ + ...form, + lines: form.lines.map((l, ix) => ix === i + ? { ...l, actualQty, diff: actualQty - l.bookQty } + : l), + }) + } + + /** Импорт CSV: ожидаемый формат «productId;actualQty» или «article;actualQty» + * (article совпадение через productArticle). Заголовок не обязателен. */ + const onCsvUpload = async (file: File) => { + const text = await file.text() + const rows = text.split(/\r?\n/).map((r) => r.trim()).filter(Boolean) + const byArticle = new Map() + for (const r of rows) { + const [a, b] = r.split(';').map((s) => s.trim()) + if (!a || !b) continue + if (a.toLowerCase() === 'productid' || a.toLowerCase() === 'article') continue + const qty = Number(b.replace(',', '.')) + if (!Number.isFinite(qty)) continue + byArticle.set(a, qty) + } + setForm((f) => ({ + ...f, + lines: f.lines.map((l) => { + const fromId = byArticle.get(l.productId) + const fromArticle = l.productArticle ? byArticle.get(l.productArticle) : undefined + const v = fromId ?? fromArticle + if (v == null) return l + return { ...l, actualQty: v, diff: v - l.bookQty } + }), + })) + } + + const canPost = isDraft && form.lines.some((l) => l.diff !== 0) && !isNew + + return ( + +
+
+ + + +
+

+ {isNew ? 'Новая инвентаризация' : existing.data?.number ?? 'Инвентаризация'} +

+

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

+
+
+
+ {!isNew && isDraft && ( + <> + { const f = e.target.files?.[0]; if (f) onCsvUpload(f) }} /> + + + + )} + {isDraft && ( + + )} +
+
+ +
+
+ {error && ( +
{error}
+ )} + +
+
+ + setForm({ ...form, date: iso ?? '' })} /> + + + + + +