food-market/src/food-market.api/Controllers/Inventory/InventoriesController.cs
nns 839fcaf9fc fix(inventory): пустая инвентаризация больше не обнуляет непосчитанное
Авто-подтянутые строки создаются как ActualQty=BookQty (Diff=0) вместо
ActualQty=0 (Diff=-stock). Проведение не трогает непересчитанные позиции (в т.ч.
вне выбранной группы); истинный ноль вводится явным ActualQty=0.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2026-10-08 02:10:11 +05:00

651 lines
32 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.ComponentModel.DataAnnotations;
using foodmarket.Api.Infrastructure.Invoices;
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);
/// <summary>Компактный слепок каталога для телефона: тройки
/// [штрихкод, productId, name]. Телефон держит его в IndexedDB,
/// работает без сети. Опциональный <paramref name="groupId"/> режет
/// каталог для частичной инвентаризации (считаем только одну группу).</summary>
public record MobileCatalogRow(string Barcode, Guid ProductId, string Name);
public record MobileCatalogResponse(int Count, DateTime BuiltAt, List<object[]> Rows);
// ── импорт Excel/CSV для инвентаризации ─────────────────────────────
public record InventoryImportFoundRow(Guid Id, string Name, decimal Qty);
public record InventoryImportUnknownRow(string Code, decimal Qty);
public record InventoryImportResponse(
IReadOnlyList<InventoryImportFoundRow> Found,
IReadOnlyList<InventoryImportUnknownRow> Unknown);
/// <summary>Импорт файла в текущий сеанс инвентаризации. Колонки «Штрихкод» и
/// «Количество» (если нет заголовков — 1-я и 2-я); повторы одного штрихкода
/// суммируются. Документ НЕ создаётся: клиент добавляет строки к текущему
/// списку, кладовщик может ещё досканировать камерой/сканером, и уже
/// потом создаёт черновик обычным <c>POST /inventories</c>. Порт поведения
/// из <c>~/ref/sklad/sklad/Controllers/InventoryController.cs</c>:Import.</summary>
[HttpPost("import"), RequiresPermission("InventoryEdit"), RequestSizeLimit(20_000_000)]
public async Task<ActionResult<InventoryImportResponse>> Import(
[FromForm] IFormFile file, CancellationToken ct)
{
if (file is null || file.Length == 0)
return BadRequest(new { error = "Файл не выбран." });
if (!TableReader.CanRead(file.FileName))
return BadRequest(new { error = "Поддерживаются только .xlsx и .csv." });
var tmp = Path.Combine(Path.GetTempPath(), $"fm-inv-{Guid.NewGuid():N}{Path.GetExtension(file.FileName)}");
try
{
await using (var fs = System.IO.File.Create(tmp))
await file.CopyToAsync(fs, ct);
var rows = TableReader.Read(tmp, file.FileName);
// Если шапка есть — читаем из колонок «штрихкод»/«кол»; иначе 1-я и 2-я.
var h = TableReader.HeaderRow(rows);
int cBc, cQty, start;
if (h >= 0)
{
var head = rows[h];
cBc = TableReader.Col(head, "штрих", "barcode", "^ean", "^шк$");
cQty = TableReader.Col(head, "кол", "qty", "quantity", "^факт");
start = h + 1;
}
else { cBc = 0; cQty = 1; start = 0; }
// Суммируем количества одного штрихкода в один проход.
var agg = new Dictionary<string, decimal>(StringComparer.Ordinal);
for (int i = start; i < rows.Count; i++)
{
var r = rows[i];
var bc = InvoiceMatcher.OnlyDigits(TableReader.At(r, cBc));
if (bc.Length < 6) continue;
var qty = TableReader.Num(TableReader.At(r, cQty)) ?? 0m;
if (qty <= 0) continue;
agg[bc] = (agg.TryGetValue(bc, out var e) ? e : 0m) + qty;
}
if (agg.Count == 0)
return BadRequest(new { error = "В файле нет строк со штрихкодом и количеством." });
// Разом ищем товары по всем кодам. Index на (OrgId, Code) — попадаем в индекс.
var codes = agg.Keys.ToList();
var hits = await (from pb in _db.ProductBarcodes.AsNoTracking()
join p in _db.Products.AsNoTracking() on pb.ProductId equals p.Id
where codes.Contains(pb.Code) && !p.IsArchived
select new { pb.Code, pb.ProductId, p.Name })
.ToListAsync(ct);
var byCode = new Dictionary<string, (Guid Id, string Name)>();
foreach (var h2 in hits)
byCode.TryAdd(h2.Code, (h2.ProductId, h2.Name));
var found = new List<InventoryImportFoundRow>();
var unknown = new List<InventoryImportUnknownRow>();
foreach (var kv in agg)
{
if (byCode.TryGetValue(kv.Key, out var p))
{
// Если один товар привязан к нескольким штрихкодам, которые встретились
// в файле — суммируем в одну строку клиенту.
var existing = found.FindIndex(f => f.Id == p.Id);
if (existing >= 0) found[existing] = found[existing] with { Qty = found[existing].Qty + kv.Value };
else found.Add(new InventoryImportFoundRow(p.Id, p.Name, kv.Value));
}
else unknown.Add(new InventoryImportUnknownRow(kv.Key, kv.Value));
}
return Ok(new InventoryImportResponse(found, unknown));
}
catch (InvoiceExtractionException ex)
{
return BadRequest(new { error = ex.Message });
}
finally
{
try { if (System.IO.File.Exists(tmp)) System.IO.File.Delete(tmp); } catch { }
}
}
[HttpGet("mobile-catalog")]
public async Task<ActionResult<MobileCatalogResponse>> MobileCatalog(
[FromQuery] Guid? groupId, CancellationToken ct)
{
// Берём все (OrgId implicit by filter) активные товары + их штрихкоды.
// Отдаём массивом троек (а не объектом с полями) — для 30 тыс. позиций
// JSON объекта весит 12 МБ, троек втрое меньше. Response-compression
// middleware жмёт результат gzip-ом (Program.cs:AddResponseCompression).
//
// Weak ETag из «последняя правка товара/штрихкода + их число» —
// клиент на повторной загрузке получит 304, если каталог не менялся.
// (handoff §9: «без простоев» — быстрый повтор при обрыве.)
var q = from pb in _db.ProductBarcodes.AsNoTracking()
join p in _db.Products.AsNoTracking() on pb.ProductId equals p.Id
where !p.IsArchived
select new { pb.Code, pb.ProductId, p.Name, p.ProductGroupId };
if (groupId.HasValue) q = q.Where(x => x.ProductGroupId == groupId);
var raw = await q.ToListAsync(ct);
var seen = new HashSet<string>();
var rows = new List<object[]>(raw.Count);
foreach (var r in raw)
{
var k = new string(r.Code.Where(char.IsDigit).ToArray());
if (k.Length < 6 || !seen.Add(k)) continue;
rows.Add(new object[] { k, r.ProductId, r.Name });
}
// ETag — hash от уникального содержимого. 304 на If-None-Match избавляет
// от повторной передачи при обрыве/перезагрузке страницы инвентаризации.
unchecked
{
long h = rows.Count;
foreach (var r in rows)
h = h * 1315423911L ^ ((string)r[0]).GetHashCode() ^ ((Guid)r[1]).GetHashCode();
var etag = $"\"inv-cat-{h:x}-{(groupId?.ToString("N") ?? "all")}\"";
if (Request.Headers.IfNoneMatch == etag)
return StatusCode(304);
Response.Headers.ETag = etag;
Response.Headers.CacheControl = "private, max-age=60";
}
return Ok(new MobileCatalogResponse(rows.Count, DateTime.UtcNow, rows));
}
[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,
};
// Если строки не указаны — подтягиваем товары склада как «ещё не
// посчитанные»: ActualQty = BookQty, Diff = 0. Так проведение НЕ трогает
// непосчитанные позиции (Diff=0 пропускается). Раньше здесь ставилось
// ActualQty=0 → Diff=-stock, и проведение ОБНУЛЯЛО всё, что кладовщик не
// пересчитал (в т.ч. всё вне выбранной группы). Кладовщик меняет ActualQty
// только у реально пересчитанных товаров (через add-line / правку строки);
// истинный ноль на складе вводится явным ActualQty=0.
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 = st.Quantity,
Diff = 0,
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 по существующим строкам — in-place правка
// (без Add/Remove на навигации, чтобы не словить EF8 bug на
// nav-collection, см. фикс на Products/Enters/Losses.Update).
var byProduct = doc.Lines.ToDictionary(l => l.ProductId);
var existingCount = doc.Lines.Count;
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 на момент изменения.
// Добавляем через DbSet, не через doc.Lines.Add, чтобы EF
// не путал nav-collection и xmin-токен на InventoryDoc.
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);
_db.InventoryLines.Add(new InventoryLine
{
InventoryDocId = doc.Id,
ProductId = ln.ProductId,
BookQty = b,
ActualQty = ln.ActualQty,
Diff = ln.ActualQty - b,
UnitCost = c,
SortOrder = existingCount++,
});
}
}
}
if (await SaveOrFkErrorAsync(ct) is { } err) return err;
return NoContent();
}
/// <summary>Sprint 31: быстрое добавление позиции по productId
/// (мобильный сканер — +1 к существующей, иначе новая строка).
/// bookQty подставляется из текущего Stock склада. Отличается от
/// общего Update: пишет только одну строку, не трогает остальные.</summary>
public record AddLineInput([Required] Guid ProductId, decimal ActualQty);
[HttpPost("{id:guid}/add-line"), RequiresPermission("InventoryEdit")]
public async Task<IActionResult> AddLine(Guid id, [FromBody] AddLineInput 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 = "Только черновик можно править." });
var existing = doc.Lines.FirstOrDefault(l => l.ProductId == input.ProductId);
if (existing is not null)
{
existing.ActualQty = input.ActualQty > 0 ? input.ActualQty : existing.ActualQty + 1;
existing.Diff = existing.ActualQty - existing.BookQty;
}
else
{
var b = await _db.Stocks.Where(s => s.StoreId == doc.StoreId && s.ProductId == input.ProductId)
.Select(s => (decimal?)s.Quantity).FirstOrDefaultAsync(ct) ?? 0m;
var c = await _db.Products.Where(p => p.Id == input.ProductId).Select(p => p.Cost).FirstOrDefaultAsync(ct);
var qty = input.ActualQty > 0 ? input.ActualQty : 1m;
_db.InventoryLines.Add(new InventoryLine
{
InventoryDocId = doc.Id,
ProductId = input.ProductId,
BookQty = b,
ActualQty = qty,
Diff = qty - b,
UnitCost = c,
SortOrder = doc.Lines.Count,
});
}
await _db.SaveChangesAsync(ct);
return NoContent();
}
/// <summary>Sprint 31 (пункт 6): проверка «учётный изменился с момента
/// создания документа» — чтобы перед Post показать, что stock поплыл
/// (были продажи/приёмки). Возвращает список строк, у которых текущий
/// Stock != сохранённый BookQty. Клиент показывает предупреждение.</summary>
public record BookDriftRow(Guid ProductId, string ProductName, decimal SavedBook, decimal CurrentBook);
[HttpGet("{id:guid}/book-drift")]
public async Task<ActionResult<List<BookDriftRow>>> BookDrift(Guid id, CancellationToken ct)
{
var doc = await _db.InventoryDocs.FirstOrDefaultAsync(d => d.Id == id, ct);
if (doc is null) return NotFound();
var lines = await _db.InventoryLines
.Where(l => l.InventoryDocId == id)
.Select(l => new { l.ProductId, l.BookQty, l.Product.Name })
.ToListAsync(ct);
var ids = lines.Select(l => l.ProductId).ToList();
var cur = await _db.Stocks
.Where(s => s.StoreId == doc.StoreId && ids.Contains(s.ProductId))
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
var drift = new List<BookDriftRow>();
foreach (var l in lines)
{
cur.TryGetValue(l.ProductId, out var now);
if (now != l.BookQty) drift.Add(new BookDriftRow(l.ProductId, l.Name, l.BookQty, now));
}
return Ok(drift);
}
[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);
}
}