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:
parent
6254b61caa
commit
4285bdee91
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
61
src/food-market.domain/Inventory/InventoryDoc.cs
Normal file
61
src/food-market.domain/Inventory/InventoryDoc.cs
Normal 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; }
|
||||||
|
}
|
||||||
|
|
@ -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>();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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 });
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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;
|
||||||
|
");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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 />} />
|
||||||
|
|
|
||||||
|
|
@ -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 })
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
||||||
|
|
|
||||||
64
src/food-market.web/src/pages/InventoriesPage.tsx
Normal file
64
src/food-market.web/src/pages/InventoriesPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
353
src/food-market.web/src/pages/InventoryEditPage.tsx
Normal file
353
src/food-market.web/src/pages/InventoryEditPage.tsx
Normal 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>
|
||||||
|
)
|
||||||
|
}
|
||||||
143
tests/food-market.IntegrationTests/InventoryPostUnpostTests.cs
Normal file
143
tests/food-market.IntegrationTests/InventoryPostUnpostTests.cs
Normal 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
Loading…
Reference in a new issue