food-market/src/food-market.api/Controllers/Inventory/InventoriesController.cs
nns ab416d720c
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
feat(inventory): отмена результатов не удаляет проведённые документы
Было: unpost при проведённых связанных Оприходовании/Списании молча снимал их с
проведения и УДАЛЯЛ (204, номера исчезали). Хуже МойСклада, теряется история.

- unpost при ПРОВЕДЁННЫХ связанных → 409 с номерами О-/С- и текстом «Сначала
  отмените результаты». Если связанные только черновики — удаляем черновики
  (не влияли на остатки) и возвращаем инвентаризацию в черновик.
- Новое POST /{id}/cancel-results (InventoryEdit + EnterEdit/LossEdit): снимает
  связанные Оприходование/Списание с проведения, НЕ удаляя (остаются черновиками,
  видны в «Связанных документах», истории, аудите); остатки возвращаются; для
  Оприходования — гард на уход остатка в минус. Аудит InventoryCancelResults.
- Веб: кнопка «Отменить результаты» (при проведённых связанных) + диалог со
  списком документов и влиянием на остатки; «Вернуть в черновик» переформулирован.
- docs/mobile-api.md: unpost больше не удаляет проведённые (409), добавлен cancel-results.
- Тесты: integration (409 + cancel-results оставляет черновики + повторное
  проведение) + full-chain шаг 10.

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

861 lines
48 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.Domain.Purchases;
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>; пользователь вносит фактические
/// количества. «Завершение» (<c>complete</c>) замораживает строки (Completed) и
/// НЕ двигает остатки. По результату создаются связанные Оприходование
/// (<c>create-enter</c>, излишки diff&gt;0) и Списание (<c>create-loss</c>, недостача
/// diff&lt;0) — их проводят как обычные документы (вот тогда меняются остатки).
/// <c>post</c>/<c>unpost</c> — временная совместимость для мобильного «FM Склад».</summary>
[ApiController]
[Authorize]
[Route("api/inventory/inventories")]
public class InventoriesController : ControllerBase
{
private readonly AppDbContext _db;
private readonly IStockService _stock;
private readonly IAuthorizationService _authz;
private readonly foodmarket.Api.Infrastructure.Audit.SensitiveOpsAudit _audit;
public InventoriesController(AppDbContext db, IStockService stock,
IAuthorizationService authz, foodmarket.Api.Infrastructure.Audit.SensitiveOpsAudit audit)
{
_db = db;
_stock = stock;
_authz = authz;
_audit = audit;
}
public record InventoryListRow(
Guid Id, string Number, DateTime Date, InventoryStatus Status,
Guid StoreId, string StoreName,
int LineCount,
decimal SurplusValue, decimal ShortageValue,
DateTime? PostedAt,
// Результат: связанные документы (null — «не применено»).
Guid? EnterId, string? EnterNumber, Guid? LossId, string? LossNumber);
public record InventoryLineDto(
Guid? Id, Guid ProductId, string? ProductName, string? ProductArticle, string? UnitSymbol,
decimal BookQty, decimal ActualQty, decimal Diff, decimal UnitCost,
int SortOrder);
/// <summary>Связанный документ результата: «enter» (оприходование) / «loss» (списание).
/// <c>Posted</c> — проведён ли (двигал ли остатки).</summary>
public record RelatedDocDto(string Kind, Guid Id, string Number, bool Posted);
public record InventoryDto(
Guid Id, string Number, DateTime Date, InventoryStatus Status,
Guid StoreId, string StoreName,
string? Notes,
DateTime? PostedAt,
IReadOnlyList<InventoryLineDto> Lines,
IReadOnlyList<RelatedDocDto> Related);
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,
_db.Enters.Where(e => e.InventoryDocId == x.i.Id).Select(e => (Guid?)e.Id).FirstOrDefault(),
_db.Enters.Where(e => e.InventoryDocId == x.i.Id).Select(e => e.Number).FirstOrDefault(),
_db.Losses.Where(l => l.InventoryDocId == x.i.Id).Select(l => (Guid?)l.Id).FirstOrDefault(),
_db.Losses.Where(l => l.InventoryDocId == x.i.Id).Select(l => l.Number).FirstOrDefault()))
.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();
}
// ── Завершение + результирующие документы (модель МойСклада) ─────────
// Инвентаризация НЕ двигает остатки. Завершаем (Completed), затем по
// результату создаём связанные Оприходование (излишки) и Списание (недостача).
/// <summary>Завершить инвентаризацию: заморозить строки (Status=Completed).
/// Остатки НЕ двигаются. После — кнопки «Создать оприходование/списание».</summary>
[HttpPost("{id:guid}/complete"), RequiresPermission("InventoryEdit")]
public async Task<IActionResult> Complete(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.Completed) return Conflict(new { error = "Инвентаризация уже завершена." });
if (doc.Lines.Count == 0) return BadRequest(new { error = "Нельзя завершить инвентаризацию без строк." });
doc.Status = InventoryStatus.Completed;
doc.PostedAt = DateTime.UtcNow;
try { await _db.SaveChangesAsync(ct); }
catch (Exception ex) when (IsSerializationConflict(ex)) { return Conflict(new { error = "Документ обрабатывается параллельно. Повторите." }); }
return NoContent();
}
/// <summary>Создать Оприходование излишков (diff&gt;0) «на основании» инвентаризации —
/// черновик, проводится отдельно как обычный документ. Повторно нельзя.</summary>
[HttpPost("{id:guid}/create-enter"), RequiresPermission("EnterEdit")]
public Task<IActionResult> CreateEnter(Guid id, CancellationToken ct) => CreateResultDocAsync(id, surplus: true, ct);
/// <summary>Создать Списание недостачи (diff&lt;0) «на основании» инвентаризации.</summary>
[HttpPost("{id:guid}/create-loss"), RequiresPermission("LossEdit")]
public Task<IActionResult> CreateLoss(Guid id, CancellationToken ct) => CreateResultDocAsync(id, surplus: false, ct);
private async Task<IActionResult> CreateResultDocAsync(Guid id, bool surplus, 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.Completed)
return Conflict(new { error = "Сначала завершите инвентаризацию." });
var split = InventoryResult.Build(doc.Lines.Select(l => (l.ProductId, l.Diff, l.UnitCost)));
if (surplus)
{
if (!split.HasSurplus) return BadRequest(new { error = "Нет излишков — оприходовать нечего." });
var ex = await _db.Enters.Where(e => e.InventoryDocId == id).Select(e => new { e.Id, e.Number }).FirstOrDefaultAsync(ct);
if (ex is not null) return Conflict(new { error = $"Оприходование {ex.Number} уже создано по этой инвентаризации.", enterId = ex.Id, number = ex.Number });
var enter = await BuildEnterAsync(doc, split.Surplus, post: false, ct);
await _db.SaveChangesAsync(ct);
return Ok(new { enterId = enter.Id, number = enter.Number });
}
else
{
if (!split.HasShortage) return BadRequest(new { error = "Нет недостачи — списывать нечего." });
var ex = await _db.Losses.Where(l => l.InventoryDocId == id).Select(l => new { l.Id, l.Number }).FirstOrDefaultAsync(ct);
if (ex is not null) return Conflict(new { error = $"Списание {ex.Number} уже создано по этой инвентаризации.", lossId = ex.Id, number = ex.Number });
var loss = await BuildLossAsync(doc, split.Shortage, post: false, ct);
await _db.SaveChangesAsync(ct);
return Ok(new { lossId = loss.Id, number = loss.Number });
}
}
/// <summary>Строит Оприходование из строк излишков. Если post — сразу проводит
/// (движения Enter, +остаток). Не сохраняет (SaveChanges делает вызывающий).</summary>
private async Task<Enter> BuildEnterAsync(InventoryDoc doc, IReadOnlyList<InventoryResult.ResultLine> lines, bool post, CancellationToken ct)
{
var currencyId = await DefaultCurrencyIdAsync(ct);
var enter = new Enter
{
Number = await GenerateDocNumberAsync<Enter>("О-", doc.Date, ct),
Date = doc.Date,
Status = EnterStatus.Draft,
StoreId = doc.StoreId,
CurrencyId = currencyId,
InventoryDocId = doc.Id,
Notes = $"Излишки по инвентаризации {doc.Number}",
Total = lines.Sum(l => l.LineTotal),
Lines = lines.Select(l => new EnterLine { ProductId = l.ProductId, Quantity = l.Quantity, UnitCost = l.UnitCost, LineTotal = l.LineTotal }).ToList(),
};
_db.Enters.Add(enter);
if (post)
{
foreach (var l in enter.Lines)
await _stock.ApplyMovementAsync(new StockMovementDraft(l.ProductId, enter.StoreId, l.Quantity,
MovementType.Enter, "enter", enter.Id, enter.Number, l.UnitCost, enter.Date), ct);
enter.Status = EnterStatus.Posted;
enter.PostedAt = DateTime.UtcNow;
}
return enter;
}
/// <summary>Строит Списание из строк недостачи. Если post — проводит (WriteOff, −остаток).</summary>
private async Task<Loss> BuildLossAsync(InventoryDoc doc, IReadOnlyList<InventoryResult.ResultLine> lines, bool post, CancellationToken ct)
{
var currencyId = await DefaultCurrencyIdAsync(ct);
var loss = new Loss
{
Number = await GenerateDocNumberAsync<Loss>("С-", doc.Date, ct),
Date = doc.Date,
Status = LossStatus.Draft,
StoreId = doc.StoreId,
CurrencyId = currencyId,
Reason = LossReason.Shortage,
InventoryDocId = doc.Id,
Notes = $"Недостача по инвентаризации {doc.Number}",
Total = lines.Sum(l => l.LineTotal),
Lines = lines.Select(l => new LossLine { ProductId = l.ProductId, Quantity = l.Quantity, UnitCost = l.UnitCost, LineTotal = l.LineTotal }).ToList(),
};
_db.Losses.Add(loss);
if (post)
{
foreach (var l in loss.Lines)
await _stock.ApplyMovementAsync(new StockMovementDraft(l.ProductId, loss.StoreId, -l.Quantity,
MovementType.WriteOff, "loss", loss.Id, loss.Number, l.UnitCost, loss.Date, Notes: $"reason={loss.Reason}"), ct);
loss.Status = LossStatus.Posted;
loss.PostedAt = DateTime.UtcNow;
}
return loss;
}
/// <summary>МОБИЛЬНАЯ СОВМЕСТИМОСТЬ (временная, см. docs/mobile-api.md): iOS
/// «FM Склад» вызывает post. post = завершить + создать и ПРОВЕСТИ Оприходование
/// (излишки) и Списание (недостача). Веб использует complete + create-enter/create-loss.</summary>
[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.Lines.Count == 0) return BadRequest(new { error = "Нельзя провести документ без строк." });
await using var tx = await _db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, ct);
if (doc.Status != InventoryStatus.Completed) { doc.Status = InventoryStatus.Completed; doc.PostedAt = DateTime.UtcNow; }
var split = InventoryResult.Build(doc.Lines.Select(l => (l.ProductId, l.Diff, l.UnitCost)));
if (split.HasSurplus && !await _db.Enters.AnyAsync(e => e.InventoryDocId == id, ct))
await BuildEnterAsync(doc, split.Surplus, post: true, ct);
if (split.HasShortage && !await _db.Losses.AnyAsync(l => l.InventoryDocId == id, ct))
await BuildLossAsync(doc, split.Shortage, post: true, ct);
try { await _db.SaveChangesAsync(ct); await tx.CommitAsync(ct); }
catch (Exception ex) when (IsSerializationConflict(ex)) { return Conflict(new { error = "Документ проводится параллельно. Повторите попытку." }); }
return NoContent();
}
/// <summary>«Вернуть в черновик» инвентаризацию. Если по ней ПРОВЕДЕНЫ
/// связанные Оприходование/Списание — возвращаем 409 и просим сначала
/// «Отменить результаты»: мы НЕ удаляем проведённые документы молча (это
/// хуже МойСклада и теряет историю). Если связанные документы только
/// черновики (или их нет) — удаляем черновики (они не влияли на остатки) и
/// возвращаем инвентаризацию в черновик.
///
/// Мобильный /unpost (совместимость, см. docs/mobile-api.md) — та же логика:
/// при проведённых связанных документах возвращает тот же 409.</summary>
[HttpPost("{id:guid}/unpost"), RequiresPermission("InventoryEdit")]
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
{
var doc = await _db.InventoryDocs.FirstOrDefaultAsync(d => d.Id == id, ct);
if (doc is null) return NotFound();
var postedEnters = await _db.Enters
.Where(e => e.InventoryDocId == id && e.Status == EnterStatus.Posted)
.Select(e => new { e.Id, e.Number }).ToListAsync(ct);
var postedLosses = await _db.Losses
.Where(l => l.InventoryDocId == id && l.Status == LossStatus.Posted)
.Select(l => new { l.Id, l.Number }).ToListAsync(ct);
if (postedEnters.Count > 0 || postedLosses.Count > 0)
{
var parts = new List<string>();
if (postedEnters.Count > 0) parts.Add("Оприходование " + string.Join(", ", postedEnters.Select(e => e.Number)));
if (postedLosses.Count > 0) parts.Add("Списание " + string.Join(", ", postedLosses.Select(l => l.Number)));
return Conflict(new
{
error = $"По инвентаризации проведены {string.Join(" и ", parts)}. Сначала отмените результаты.",
enters = postedEnters,
losses = postedLosses,
});
}
// Проведённых связанных нет — удаляем черновики (они не влияли на остатки)
// и возвращаем инвентаризацию в черновик.
var draftEnters = await _db.Enters.Where(e => e.InventoryDocId == id).ToListAsync(ct);
_db.Enters.RemoveRange(draftEnters);
var draftLosses = await _db.Losses.Where(l => l.InventoryDocId == id).ToListAsync(ct);
_db.Losses.RemoveRange(draftLosses);
doc.Status = InventoryStatus.Draft;
doc.PostedAt = null;
try { await _db.SaveChangesAsync(ct); }
catch (Exception ex) when (IsSerializationConflict(ex)) { return Conflict(new { error = "Документ обрабатывается параллельно. Повторите попытку." }); }
return NoContent();
}
/// <summary>«Отменить результаты»: снять с проведения связанные Оприходование
/// и Списание, НЕ удаляя их — они становятся черновиками, остаются в
/// «Связанных документах», истории и аудите. Остатки возвращаются (как при
/// обычной отмене проведения Оприходования/Списания). После этого
/// инвентаризацию можно вернуть в черновик или снова провести документы.
///
/// Право: InventoryEdit (эндпоинт) + EnterEdit (если есть проведённые
/// Оприходования) и/или LossEdit (если есть проведённые Списания).</summary>
[HttpPost("{id:guid}/cancel-results"), RequiresPermission("InventoryEdit")]
public async Task<IActionResult> CancelResults(Guid id, CancellationToken ct)
{
var doc = await _db.InventoryDocs.FirstOrDefaultAsync(d => d.Id == id, ct);
if (doc is null) return NotFound();
var enters = await _db.Enters.Include(e => e.Lines)
.Where(e => e.InventoryDocId == id && e.Status == EnterStatus.Posted).ToListAsync(ct);
var losses = await _db.Losses.Include(l => l.Lines)
.Where(l => l.InventoryDocId == id && l.Status == LossStatus.Posted).ToListAsync(ct);
if (enters.Count == 0 && losses.Count == 0)
return Conflict(new { error = "Нет проведённых связанных документов — отменять нечего." });
// Доп. права под конкретные типы связанных документов.
if (enters.Count > 0 && !(await _authz.AuthorizeAsync(User, "perm:EnterEdit")).Succeeded)
return Forbid();
if (losses.Count > 0 && !(await _authz.AuthorizeAsync(User, "perm:LossEdit")).Succeeded)
return Forbid();
await using var tx = await _db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, ct);
// Для Оприходований проверяем, что снятие не уведёт остаток в минус
// (часть оприходованного излишка могла быть уже продана).
var conflicts = new List<object>();
foreach (var e in enters)
{
var reverseByProduct = e.Lines.GroupBy(l => l.ProductId)
.Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Quantity) }).ToList();
var pids = reverseByProduct.Select(r => r.ProductId).ToList();
var stocks = await _db.Stocks.Where(s => s.StoreId == e.StoreId && pids.Contains(s.ProductId))
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
foreach (var r in reverseByProduct)
{
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 { document = e.Number, productId = r.ProductId, productName = name, reverseQty = r.Quantity, available });
}
}
}
if (conflicts.Count > 0)
return Conflict(new { error = "Нельзя отменить результаты: остаток уйдёт в минус (часть оприходованного товара уже расходована).", lines = conflicts });
// Снимаем Оприходования с проведения (−остаток), оставляем черновиками.
foreach (var e in enters)
{
foreach (var l in e.Lines)
await _stock.ApplyMovementAsync(new StockMovementDraft(l.ProductId, e.StoreId, -l.Quantity,
MovementType.Enter, "enter-reversal", e.Id, e.Number, l.UnitCost, DateTime.UtcNow,
Notes: $"Отмена результатов инвентаризации {doc.Number}"), ct);
e.Status = EnterStatus.Draft;
e.PostedAt = null;
}
// Снимаем Списания с проведения (+остаток обратно), оставляем черновиками.
foreach (var ls in losses)
{
foreach (var l in ls.Lines)
await _stock.ApplyMovementAsync(new StockMovementDraft(l.ProductId, ls.StoreId, l.Quantity,
MovementType.WriteOff, "loss-reversal", ls.Id, ls.Number, l.UnitCost, DateTime.UtcNow,
Notes: $"Отмена результатов инвентаризации {doc.Number}"), ct);
ls.Status = LossStatus.Draft;
ls.PostedAt = null;
}
try
{
await _db.SaveChangesAsync(ct);
// Аудит в той же транзакции: кто/когда/из какой инвентаризации снял проведение.
await _audit.LogAsync("InventoryCancelResults", "inventory", doc.Id, new
{
inventoryNumber = doc.Number,
enters = enters.Select(e => new { e.Id, e.Number }).ToList(),
losses = losses.Select(l => new { l.Id, l.Number }).ToList(),
}, ct);
await tx.CommitAsync(ct);
}
catch (Exception ex) when (IsSerializationConflict(ex)) { return Conflict(new { error = "Документ обрабатывается параллельно. Повторите попытку." }); }
return Ok(new
{
cancelled = new
{
enters = enters.Select(e => new { e.Id, e.Number }).ToList(),
losses = losses.Select(l => new { l.Id, l.Number }).ToList(),
},
});
}
private async Task<Guid> DefaultCurrencyIdAsync(CancellationToken ct)
{
var orgCur = await _db.Organizations.Select(o => o.DefaultCurrencyId).FirstOrDefaultAsync(ct);
if (orgCur is { } c) return c;
return await _db.Currencies.Where(x => x.Code == "KZT").Select(x => x.Id).FirstOrDefaultAsync(ct);
}
/// <summary>Генерирует номер документа Оприходования/Списания по образцу их
/// контроллеров: «&lt;prefix&gt;&lt;год&gt;-NNNNNN».</summary>
private async Task<string> GenerateDocNumberAsync<T>(string prefix, DateTime date, CancellationToken ct) where T : class
{
var pfx = $"{prefix}{date.Year}-";
var last = typeof(T) == typeof(Enter)
? await _db.Enters.Where(e => e.Number.StartsWith(pfx)).OrderByDescending(e => e.Number).Select(e => e.Number).FirstOrDefaultAsync(ct)
: await _db.Losses.Where(l => l.Number.StartsWith(pfx)).OrderByDescending(l => l.Number).Select(l => l.Number).FirstOrDefaultAsync(ct);
var seq = 1;
if (last is not null && int.TryParse(last[pfx.Length..], out var n)) seq = n + 1;
return $"{pfx}{seq:D6}";
}
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);
var related = new List<RelatedDocDto>();
related.AddRange(await _db.Enters.AsNoTracking().Where(e => e.InventoryDocId == id)
.Select(e => new RelatedDocDto("enter", e.Id, e.Number, e.Status == EnterStatus.Posted)).ToListAsync(ct));
related.AddRange(await _db.Losses.AsNoTracking().Where(l => l.InventoryDocId == id)
.Select(l => new RelatedDocDto("loss", l.Id, l.Number, l.Status == LossStatus.Posted)).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, related);
}
}