feat(inventories): инвентаризация с CSV-импортом факта (P1-4)

Domain InventoryDoc+InventoryLine (productId, bookQty, actualQty, diff).
EF, миграция Phase6d_Inventories. Контроллер api/inventory/inventories:
Create без строк автоматически подгружает все товары склада с текущим
Stock в bookQty (actual=0); Update пишет actualQty по строкам, пересчитывая
diff. Post создаёт корректирующие движения InventoryAdjustment на diff
(положительный — приход излишка, отрицательный — списание недостачи).
Unpost атомарно откатывает; проверка «излишек уже расходован» → 409.

Web: /inventory/inventories (list с разделением излишек/недостача) +
edit с импортом CSV (productId|article;actualQty). Сайдбар «Инвентаризации».

Тесты: 3 интеграционных (create-подгрузка bookQty + apply diff;
post 400 если diff=0; tenant-изоляция).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
nns 2026-05-28 09:39:32 +05:00
parent 6254b61caa
commit 4285bdee91
11 changed files with 1182 additions and 1 deletions

View file

@ -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;
/// <summary>Инвентаризация (пересчёт). Создание подгружает текущие остатки
/// склада в <c>bookQty</c>; пользователь вносит фактические количества;
/// при Post создаются корректирующие движения <see cref="MovementType.InventoryAdjustment"/>
/// на <c>diff = actual - book</c> (положительный — приход, отрицательный
/// — списание).</summary>
[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<InventoryLineDto> Lines);
public record InventoryLineInput(
Guid ProductId,
[Range(0, 1e10)] decimal ActualQty);
public record InventoryInput(
DateTime Date, Guid StoreId,
string? Notes,
/// <summary>Если null/пусто — контроллер сам заполнит строками всеми
/// товарами склада с их текущим Stock в качестве bookQty и actual=0.</summary>
IReadOnlyList<InventoryLineInput>? Lines);
[HttpGet]
public async Task<ActionResult<PagedResult<InventoryListRow>>> 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<InventoryListRow> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
[HttpGet("{id:guid}")]
public async Task<ActionResult<InventoryDto>> Get(Guid id, CancellationToken ct)
{
var dto = await GetInternal(id, ct);
return dto is null ? NotFound() : Ok(dto);
}
[HttpPost, RequiresPermission("InventoryEdit")]
public async Task<ActionResult<InventoryDto>> 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<ActionResult?> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<IActionResult> 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<object>();
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<string> 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<InventoryDto?> 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);
}
}

View file

@ -0,0 +1,61 @@
using foodmarket.Domain.Catalog;
using foodmarket.Domain.Common;
namespace foodmarket.Domain.Inventory;
public enum InventoryStatus
{
Draft = 0,
Posted = 1,
}
/// <summary>Документ инвентаризации (пересчёта). При создании контроллер
/// заполняет <c>bookQty</c> по текущему <see cref="Stock"/> склада. После
/// внесения фактических количеств (<c>actualQty</c>) при Post создаются
/// корректирующие движения <see cref="MovementType.InventoryAdjustment"/>
/// на <c>diff = actual - book</c>: положительные приходят (излишек),
/// отрицательные списываются (недостача).
///
/// Назван <c>InventoryDoc</c> чтобы не конфликтовать с .NET-неймспейсом
/// <c>System.Collections.Specialized.Inventory</c> и не путаться с самой
/// сущностью «остатков». Таблица — <c>inventories</c>.</summary>
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<InventoryLine> Lines { get; set; } = new List<InventoryLine>();
}
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!;
/// <summary>Учётное количество (Stock.Quantity на момент создания/обновления документа).</summary>
public decimal BookQty { get; set; }
/// <summary>Фактическое количество (введено вручную или импортом CSV).</summary>
public decimal ActualQty { get; set; }
/// <summary>diff = ActualQty - BookQty (положительный — излишек, отрицательный — недостача).
/// Вычисляется и сохраняется при сохранении строки для отчётности.</summary>
public decimal Diff { get; set; }
/// <summary>Снимок Product.Cost для расчёта суммы излишка/недостачи (только для отчётов).</summary>
public decimal UnitCost { get; set; }
public int SortOrder { get; set; }
}

View file

@ -53,6 +53,9 @@ public AppDbContext(DbContextOptions<AppDbContext> options, ITenantContext tenan
public DbSet<Transfer> Transfers => Set<Transfer>(); public DbSet<Transfer> Transfers => Set<Transfer>();
public DbSet<TransferLine> TransferLines => Set<TransferLine>(); public DbSet<TransferLine> TransferLines => Set<TransferLine>();
public DbSet<InventoryDoc> InventoryDocs => Set<InventoryDoc>();
public DbSet<InventoryLine> InventoryLines => Set<InventoryLine>();
public DbSet<RetailSale> RetailSales => Set<RetailSale>(); public DbSet<RetailSale> RetailSales => Set<RetailSale>();
public DbSet<RetailSaleLine> RetailSaleLines => Set<RetailSaleLine>(); public DbSet<RetailSaleLine> RetailSaleLines => Set<RetailSaleLine>();

View file

@ -96,5 +96,33 @@ public static void ConfigureInventory(this ModelBuilder b)
e.HasIndex(x => new { x.OrganizationId, x.ProductId }); e.HasIndex(x => new { x.OrganizationId, x.ProductId });
}); });
b.Entity<InventoryDoc>(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<InventoryLine>(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 });
});
} }
} }

View file

@ -0,0 +1,72 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase6d — инвентаризация (InventoryDoc).
///
/// Документ инвентаризации хранит снимок учётных остатков (bookQty) и
/// фактические количества (actualQty) с разницей diff = actual - book.
/// При проведении создаётся корректирующее движение типа
/// InventoryAdjustment на diff для каждой строки.</summary>
[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;
");
}
}
}

View file

@ -34,6 +34,8 @@ import { LossesPage } from '@/pages/LossesPage'
import { LossEditPage } from '@/pages/LossEditPage' import { LossEditPage } from '@/pages/LossEditPage'
import { TransfersPage } from '@/pages/TransfersPage' import { TransfersPage } from '@/pages/TransfersPage'
import { TransferEditPage } from '@/pages/TransferEditPage' import { TransferEditPage } from '@/pages/TransferEditPage'
import { InventoriesPage } from '@/pages/InventoriesPage'
import { InventoryEditPage } from '@/pages/InventoryEditPage'
import { RetailSalesPage } from '@/pages/RetailSalesPage' import { RetailSalesPage } from '@/pages/RetailSalesPage'
import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage' import { RetailSaleEditPage } from '@/pages/RetailSaleEditPage'
import { AppLayout } from '@/components/AppLayout' import { AppLayout } from '@/components/AppLayout'
@ -115,6 +117,9 @@ export default function App() {
<Route path="/inventory/transfers" element={<TransfersPage />} /> <Route path="/inventory/transfers" element={<TransfersPage />} />
<Route path="/inventory/transfers/new" element={<TransferEditPage />} /> <Route path="/inventory/transfers/new" element={<TransferEditPage />} />
<Route path="/inventory/transfers/:id" element={<TransferEditPage />} /> <Route path="/inventory/transfers/:id" element={<TransferEditPage />} />
<Route path="/inventory/inventories" element={<InventoriesPage />} />
<Route path="/inventory/inventories/new" element={<InventoryEditPage />} />
<Route path="/inventory/inventories/:id" element={<InventoryEditPage />} />
<Route path="/sales/retail" element={<RetailSalesPage />} /> <Route path="/sales/retail" element={<RetailSalesPage />} />
<Route path="/sales/retail/new" element={<RetailSaleEditPage />} /> <Route path="/sales/retail/new" element={<RetailSaleEditPage />} />
<Route path="/sales/retail/:id" element={<RetailSaleEditPage />} /> <Route path="/sales/retail/:id" element={<RetailSaleEditPage />} />

View file

@ -7,7 +7,7 @@ import { cn } from '@/lib/utils'
import { import {
LayoutDashboard, Package, FolderTree, Ruler, Tag, LayoutDashboard, Package, FolderTree, Ruler, Tag,
Users, Warehouse, Store as StoreIcon, LogOut, Download, UserCog, Shield, ShieldCheck, 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' } from 'lucide-react'
import { Logo } from './Logo' import { Logo } from './Logo'
import { SuperAdminAsOrgBanner } from './SuperAdminAsOrgBanner' 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/enters', icon: PackagePlus, label: 'Оприходования' })
stock.push({ to: '/inventory/losses', icon: PackageMinus, label: 'Списания' }) stock.push({ to: '/inventory/losses', icon: PackageMinus, label: 'Списания' })
stock.push({ to: '/inventory/transfers', icon: ArrowRightLeft, label: 'Перемещения' }) stock.push({ to: '/inventory/transfers', icon: ArrowRightLeft, label: 'Перемещения' })
stock.push({ to: '/inventory/inventories', icon: ClipboardCheck, label: 'Инвентаризации' })
} }
sections.push({ group: 'Остатки', items: stock }) sections.push({ group: 'Остатки', items: stock })
} }

View file

@ -199,6 +199,31 @@ export interface TransferDto {
lines: TransferLineDto[]; 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 const RetailSaleStatus = { Draft: 0, Posted: 1 } as const
export type RetailSaleStatus = (typeof RetailSaleStatus)[keyof typeof RetailSaleStatus] export type RetailSaleStatus = (typeof RetailSaleStatus)[keyof typeof RetailSaleStatus]

View file

@ -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<InventoryListRow>(URL)
const org = useOrgSettings()
const fractional = org.data?.allowFractionalPrices ?? false
const moneyFmt = fractional
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
: { maximumFractionDigits: 0 }
return (
<ListPageShell
title="Инвентаризации"
description={data ? `${data.total.toLocaleString('ru')} документов` : 'Пересчёт фактических остатков склада.'}
actions={
<>
<SearchBar value={search} onChange={setSearch} placeholder="По номеру…" />
<Link to="/inventory/inventories/new">
<Button><Plus className="w-4 h-4" /> Новая инвентаризация</Button>
</Link>
</>
}
footer={data && data.total > 0 && (
<Pagination page={page} pageSize={data.pageSize} total={data.total} onPageChange={setPage} />
)}
>
<DataTable
rows={data?.items ?? []}
isLoading={isLoading}
rowKey={(r) => r.id}
sortKey={sortKey}
sortOrder={sortOrder}
onSortChange={setSort}
onRowClick={(r) => navigate(`/inventory/inventories/${r.id}`)}
columns={[
{ header: '№', width: '160px', sortKey: 'number', cell: (r) => <span className="font-mono text-slate-600">{r.number}</span> },
{ header: 'Дата', width: '120px', sortKey: 'date', cell: (r) => new Date(r.date).toLocaleDateString('ru') },
{ header: 'Статус', width: '110px', sortKey: 'status', cell: (r) => (
r.status === InventoryStatus.Posted
? <span className="text-xs px-2 py-0.5 rounded bg-green-50 text-green-700">Проведён</span>
: <span className="text-xs px-2 py-0.5 rounded bg-slate-100 text-slate-600">Черновик</span>
)},
{ header: 'Склад', cell: (r) => r.storeName },
{ header: 'Позиций', width: '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="Инвентаризаций пока нет."
/>
</ListPageShell>
)
}

View file

@ -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<Form>(emptyForm)
const [error, setError] = useState<string | null>(null)
const csvInputRef = useRef<HTMLInputElement>(null)
const existing = useQuery({
queryKey: ['/api/inventory/inventories', id],
queryFn: async () => (await api.get<InventoryDto>(`/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<InventoryDto>('/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<string, number>()
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 (
<form onSubmit={onSubmit} className="flex flex-col h-full">
<div className="flex items-center justify-between gap-4 px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-white dark:bg-slate-900">
<div className="flex items-center gap-3 min-w-0">
<Link to="/inventory/inventories" className="text-slate-400 hover:text-slate-700 dark:hover:text-slate-200 flex-shrink-0">
<ArrowLeft className="w-5 h-5" />
</Link>
<div className="min-w-0">
<h1 className="text-base font-semibold text-slate-900 dark:text-slate-100 truncate">
{isNew ? 'Новая инвентаризация' : existing.data?.number ?? 'Инвентаризация'}
</h1>
<p className="text-xs text-slate-500">
{isPosted
? <span className="text-green-700 inline-flex items-center gap-1"><CheckCircle className="w-3 h-3" /> Проведён {existing.data?.postedAt ? new Date(existing.data.postedAt).toLocaleString('ru') : ''}</span>
: 'Черновик — расхождения не отражаются на остатках, пока не проведёшь'}
</p>
</div>
</div>
<div className="flex gap-3 flex-shrink-0 items-center">
{!isNew && isDraft && (
<>
<input ref={csvInputRef} type="file" accept=".csv,text/csv" className="hidden"
onChange={(e) => { const f = e.target.files?.[0]; if (f) onCsvUpload(f) }} />
<Button type="button" variant="secondary" size="sm" onClick={() => csvInputRef.current?.click()}>
<Upload className="w-4 h-4" /> Импорт CSV
</Button>
<Button type="button" variant="danger" size="sm" onClick={() => { if (confirm('Удалить черновик?')) remove.mutate() }}>
<Trash2 className="w-4 h-4" /> Удалить
</Button>
</>
)}
{isDraft && (
<Button type="submit" disabled={!form.storeId || (create.isPending || save.isPending)}>
<Save className="w-4 h-4" />{' '}
{isNew
? (create.isPending ? 'Создаю…' : 'Создать и загрузить остатки')
: (save.isPending ? 'Сохраняю…' : 'Сохранить')}
</Button>
)}
</div>
</div>
<div className="flex-1 overflow-auto">
<div className="max-w-6xl mx-auto p-3 sm:p-6 space-y-4">
{error && (
<div className="p-3 rounded-md bg-red-50 text-red-700 text-sm border border-red-200">{error}</div>
)}
<section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4">
<div className="grid grid-cols-1 md:grid-cols-3 gap-x-4 gap-y-3">
<Field label="Дата *">
<DateField required value={form.date || null} disabled={isPosted}
onChange={(iso) => setForm({ ...form, date: iso ?? '' })} />
</Field>
<Field label="Склад *">
<Select value={form.storeId} disabled={!isNew}
onChange={(e) => setForm({ ...form, storeId: e.target.value })}>
<option value=""></option>
{stores.data?.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</Select>
</Field>
<Field label="Комментарий" className="md:col-span-3">
<TextArea rows={2} value={form.notes} disabled={isPosted}
onChange={(e) => setForm({ ...form, notes: e.target.value })}
placeholder="Состав комиссии, ссылка на акт…" />
</Field>
</div>
<div className="mt-4 flex flex-wrap gap-3 text-sm">
<span className="px-2 py-1 rounded bg-green-50 text-green-700">Излишек: +{surplusValue.toLocaleString('ru')}</span>
<span className="px-2 py-1 rounded bg-red-50 text-red-700">Недостача: {shortageValue.toLocaleString('ru')}</span>
<span className="px-2 py-1 rounded bg-slate-50 text-slate-600">Итого расхождений: {(surplusValue + shortageValue).toLocaleString('ru')}</span>
</div>
{!isNew && (
<div className="mt-4 pt-4 border-t border-slate-100 dark:border-slate-800">
<Checkbox
label="Проведено"
checked={isPosted}
disabled={post.isPending || unpost.isPending || !canPost && !isPosted}
onChange={(v) => {
if (v) {
if (confirm('Провести? Учтённые остатки будут скорректированы на разницу.')) post.mutate()
} else {
if (confirm('Снять проведение? Корректировки отменятся.')) unpost.mutate()
}
}}
/>
</div>
)}
</section>
{!isNew && (
<section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4">
<h2 className="font-medium text-slate-900 dark:text-slate-100 mb-3">Позиции</h2>
{form.lines.length === 0 ? (
<div className="text-sm text-slate-500 py-6 text-center">Нет товаров на складе на момент создания документа.</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead className="text-left">
<tr className="border-b border-slate-200 dark:border-slate-700">
<th className="py-2 pr-3 font-medium text-xs uppercase text-slate-500">Товар</th>
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[80px]">Ед.</th>
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[110px] text-right">Учёт</th>
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[130px] text-right">Факт</th>
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[110px] text-right">Расхожд.</th>
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[110px] text-right">Цена ед.</th>
<th className="py-2 px-3 font-medium text-xs uppercase text-slate-500 w-[130px] text-right">Сумма</th>
</tr>
</thead>
<tbody>
{form.lines.map((l, i) => (
<tr key={i} className="border-b border-slate-100 dark:border-slate-800">
<td className="py-2 pr-3">
<div className="font-medium text-slate-800 dark:text-slate-100">{l.productName || '(без названия)'}</div>
{l.productArticle && <div className="text-xs text-slate-500">{l.productArticle}</div>}
</td>
<td className="py-2 px-3 text-slate-500">{l.unitSymbol ?? '—'}</td>
<td className="py-2 px-3 text-right font-mono text-slate-500">{l.bookQty.toLocaleString('ru')}</td>
<td className="py-2 px-3 text-right">
<NumberInput value={l.actualQty} disabled={isPosted}
onChange={(v) => updateLine(i, v ?? 0)} />
</td>
<td className={`py-2 px-3 text-right font-mono ${l.diff > 0 ? 'text-green-700' : l.diff < 0 ? 'text-red-700' : 'text-slate-500'}`}>
{l.diff === 0 ? '—' : `${l.diff > 0 ? '+' : ''}${l.diff.toLocaleString('ru')}`}
</td>
<td className="py-2 px-3 text-right font-mono text-slate-500">{l.unitCost.toLocaleString('ru')}</td>
<td className={`py-2 px-3 text-right font-mono ${l.diff > 0 ? 'text-green-700' : l.diff < 0 ? 'text-red-700' : 'text-slate-500'}`}>
{l.diff === 0 ? '—' : (l.diff * l.unitCost).toLocaleString('ru')}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</section>
)}
{isNew && (
<section className="bg-white dark:bg-slate-900 rounded-lg border border-slate-200 dark:border-slate-800 p-4">
<p className="text-sm text-slate-600 dark:text-slate-300">
После создания документа автоматически подтянутся все товары со склада с
их учётным количеством. Затем введёшь фактические значения (вручную или через
импорт CSV), и при проведении остатки скорректируются на разницу.
</p>
</section>
)}
</div>
</div>
</form>
)
}

View file

@ -0,0 +1,143 @@
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
[Collection(ApiCollection.Name)]
public class InventoryPostUnpostTests
{
private readonly ApiFactory _factory;
public InventoryPostUnpostTests(ApiFactory factory) => _factory = factory;
private static string RandomBarcode()
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
[Fact]
public async Task Create_loads_book_qty_post_applies_diff_to_stock()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"inv-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var p1 = await api.CreateProductAsync(refs, $"P1-{Guid.NewGuid():N}", 100m, RandomBarcode());
var p2 = await api.CreateProductAsync(refs, $"P2-{Guid.NewGuid():N}", 200m, RandomBarcode());
// Подкладываем 10 шт p1 и 5 шт p2 через Enter.
foreach (var (pid, qty, cost) in new[] { (p1, 10m, 50m), (p2, 5m, 100m) })
{
var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
notes = "seed", lines = new[] { new { productId = pid, quantity = qty, unitCost = cost } },
});
enter.EnsureSuccessStatusCode();
var eid = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
}
// Создаём инвентаризацию без строк — контроллер подгрузит сам.
var create = await api.Http.PostAsJsonAsync("/api/inventory/inventories", new
{
date = DateTime.UtcNow,
storeId = refs.StoreId,
notes = "ежемесячный пересчёт",
lines = (object[])Array.Empty<object>(),
});
create.EnsureSuccessStatusCode();
var inv = await create.Content.ReadFromJsonAsync<JsonElement>();
var invId = inv.GetProperty("id").GetString();
var lines = inv.GetProperty("lines").EnumerateArray().ToList();
lines.Should().HaveCountGreaterOrEqualTo(2, "контроллер подгрузил bookQty для всех товаров склада");
var lineP1 = lines.First(l => l.GetProperty("productId").GetString() == p1);
lineP1.GetProperty("bookQty").GetDecimal().Should().Be(10m);
lineP1.GetProperty("actualQty").GetDecimal().Should().Be(0m);
// Вносим фактические: 12 шт p1 (излишек +2), 3 шт p2 (недостача -2).
var put = await api.Http.PutAsJsonAsync($"/api/inventory/inventories/{invId}", new
{
date = DateTime.UtcNow,
storeId = refs.StoreId,
notes = "проведено",
lines = new[]
{
new { productId = p1, actualQty = 12m },
new { productId = p2, actualQty = 3m },
},
});
put.EnsureSuccessStatusCode();
// Post → стоки 12 / 3.
using var post = await api.Http.PostAsJsonAsync($"/api/inventory/inventories/{invId}/post", new { });
post.IsSuccessStatusCode.Should().BeTrue($"post вернул {(int)post.StatusCode}: {await post.Content.ReadAsStringAsync()}");
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(12m);
(await api.StockOfAsync(refs.StoreId, p2)).Should().Be(3m);
// Unpost → откат к 10/5.
using var unpost = await api.Http.PostAsJsonAsync($"/api/inventory/inventories/{invId}/unpost", new { });
unpost.IsSuccessStatusCode.Should().BeTrue();
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(10m);
(await api.StockOfAsync(refs.StoreId, p2)).Should().Be(5m);
}
[Fact]
public async Task Post_rejected_if_no_diffs()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"inv-nodiff-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
notes = "seed", lines = new[] { new { productId = p1, quantity = 3m, unitCost = 50m } },
});
enter.EnsureSuccessStatusCode();
var eid = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
var create = await api.Http.PostAsJsonAsync("/api/inventory/inventories", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, notes = "match", lines = (object[])Array.Empty<object>(),
});
create.EnsureSuccessStatusCode();
var inv = await create.Content.ReadFromJsonAsync<JsonElement>();
var invId = inv.GetProperty("id").GetString();
// Фактическое = учётному (3 == 3, diff = 0).
await api.Http.PutAsJsonAsync($"/api/inventory/inventories/{invId}", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, notes = "no-diff",
lines = new[] { new { productId = p1, actualQty = 3m } },
});
using var post = await api.Http.PostAsJsonAsync($"/api/inventory/inventories/{invId}/post", new { });
((int)post.StatusCode).Should().Be(400);
}
[Fact]
public async Task Tenant_isolation_inventory()
{
var a = new ApiActor(_factory.CreateClient());
var b = new ApiActor(_factory.CreateClient());
await a.SignupAndLoginAsync($"inv-iso-a-{Guid.NewGuid():N}");
await b.SignupAndLoginAsync($"inv-iso-b-{Guid.NewGuid():N}");
var refsA = await a.LoadRefsAsync();
var resp = await a.Http.PostAsJsonAsync("/api/inventory/inventories", new
{
date = DateTime.UtcNow, storeId = refsA.StoreId, notes = "iso", lines = (object[])Array.Empty<object>(),
});
resp.EnsureSuccessStatusCode();
var invId = (await resp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
var bList = await b.ListAsync("/api/inventory/inventories?pageSize=200");
bList.Should().NotContain(x => x.GetProperty("id").GetString() == invId);
using var direct = await b.Http.GetAsync($"/api/inventory/inventories/{invId}");
direct.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound);
}
}