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; /// Инвентаризация (пересчёт) по модели МойСклада. Создание подгружает /// текущие остатки склада в bookQty; пользователь вносит фактические /// количества. «Завершение» (complete) замораживает строки (Completed) и /// НЕ двигает остатки. По результату создаются связанные Оприходование /// (create-enter, излишки diff>0) и Списание (create-loss, недостача /// diff<0) — их проводят как обычные документы (вот тогда меняются остатки). /// post/unpost — временная совместимость для мобильного «FM Склад». [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); /// Связанный документ результата: «enter» (оприходование) / «loss» (списание). /// Posted — проведён ли (двигал ли остатки). 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 Lines, IReadOnlyList Related); public record InventoryLineInput( Guid ProductId, [Range(0, 1e10)] decimal ActualQty); public record InventoryInput( DateTime Date, Guid StoreId, string? Notes, /// Если null/пусто — контроллер сам заполнит строками всеми /// товарами склада с их текущим Stock в качестве bookQty и actual=0. IReadOnlyList? Lines); /// Компактный слепок каталога для телефона: тройки /// [штрихкод, productId, name]. Телефон держит его в IndexedDB, /// работает без сети. Опциональный режет /// каталог для частичной инвентаризации (считаем только одну группу). public record MobileCatalogRow(string Barcode, Guid ProductId, string Name); public record MobileCatalogResponse(int Count, DateTime BuiltAt, List Rows); // ── импорт Excel/CSV для инвентаризации ───────────────────────────── public record InventoryImportFoundRow(Guid Id, string Name, decimal Qty); public record InventoryImportUnknownRow(string Code, decimal Qty); public record InventoryImportResponse( IReadOnlyList Found, IReadOnlyList Unknown); /// Импорт файла в текущий сеанс инвентаризации. Колонки «Штрихкод» и /// «Количество» (если нет заголовков — 1-я и 2-я); повторы одного штрихкода /// суммируются. Документ НЕ создаётся: клиент добавляет строки к текущему /// списку, кладовщик может ещё досканировать камерой/сканером, и уже /// потом создаёт черновик обычным POST /inventories. Порт поведения /// из ~/ref/sklad/sklad/Controllers/InventoryController.cs:Import. [HttpPost("import"), RequiresPermission("InventoryEdit"), RequestSizeLimit(20_000_000)] public async Task> 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(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(); foreach (var h2 in hits) byCode.TryAdd(h2.Code, (h2.ProductId, h2.Name)); var found = new List(); var unknown = new List(); 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> 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(); var rows = new List(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>> 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 { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; } [HttpGet("{id:guid}")] public async Task> Get(Guid id, CancellationToken ct) { var dto = await GetInternal(id, ct); return dto is null ? NotFound() : Ok(dto); } [HttpPost, RequiresPermission("InventoryEdit")] public async Task> Create([FromBody] InventoryInput input, CancellationToken ct) { if (RequiredGuid.FirstMissing((nameof(input.StoreId), input.StoreId)) is { } missing) return BadRequest(new { error = $"Поле {missing} обязательно.", field = missing }); var number = await GenerateNumberAsync(input.Date, ct); var doc = new InventoryDoc { Number = number, Date = input.Date, Status = InventoryStatus.Draft, StoreId = input.StoreId, Notes = input.Notes, }; // Если строки не указаны — подтягиваем товары склада как «ещё не // посчитанные»: 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 SaveOrFkErrorAsync(CancellationToken ct) { try { await _db.SaveChangesAsync(ct); return null; } catch (DbUpdateException ex) when (ex.InnerException is Npgsql.PostgresException pg && pg.SqlState == "23503") { var name = pg.ConstraintName ?? ""; string field = name.Contains("Store") ? "storeId" : name.Contains("Product") ? "productId" : "(unknown)"; return BadRequest(new { error = $"Связанная запись не найдена: {field}.", field, constraint = name }); } } [HttpPut("{id:guid}"), RequiresPermission("InventoryEdit")] public async Task Update(Guid id, [FromBody] InventoryInput input, CancellationToken ct) { var doc = await _db.InventoryDocs.Include(d => d.Lines).FirstOrDefaultAsync(d => d.Id == id, ct); if (doc is null) return NotFound(); if (doc.Status != InventoryStatus.Draft) return Conflict(new { error = "Только черновик может быть изменён. Сначала отмени проведение." }); doc.Date = input.Date; doc.Notes = input.Notes; // StoreId на UPDATE не меняем — это пересчитало бы bookQty целиком. if (input.Lines is not null && input.Lines.Count > 0) { // Обновление actualQty по существующим строкам — 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(); } /// Sprint 31: быстрое добавление позиции по productId /// (мобильный сканер — +1 к существующей, иначе новая строка). /// bookQty подставляется из текущего Stock склада. Отличается от /// общего Update: пишет только одну строку, не трогает остальные. public record AddLineInput([Required] Guid ProductId, decimal ActualQty); [HttpPost("{id:guid}/add-line"), RequiresPermission("InventoryEdit")] public async Task 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(); } /// Sprint 31 (пункт 6): проверка «учётный изменился с момента /// создания документа» — чтобы перед Post показать, что stock поплыл /// (были продажи/приёмки). Возвращает список строк, у которых текущий /// Stock != сохранённый BookQty. Клиент показывает предупреждение. public record BookDriftRow(Guid ProductId, string ProductName, decimal SavedBook, decimal CurrentBook); [HttpGet("{id:guid}/book-drift")] public async Task>> 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(); 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 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), затем по // результату создаём связанные Оприходование (излишки) и Списание (недостача). /// Завершить инвентаризацию: заморозить строки (Status=Completed). /// Остатки НЕ двигаются. После — кнопки «Создать оприходование/списание». [HttpPost("{id:guid}/complete"), RequiresPermission("InventoryEdit")] public async Task 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(); } /// Создать Оприходование излишков (diff>0) «на основании» инвентаризации — /// черновик, проводится отдельно как обычный документ. Повторно нельзя. [HttpPost("{id:guid}/create-enter"), RequiresPermission("EnterEdit")] public Task CreateEnter(Guid id, CancellationToken ct) => CreateResultDocAsync(id, surplus: true, ct); /// Создать Списание недостачи (diff<0) «на основании» инвентаризации. [HttpPost("{id:guid}/create-loss"), RequiresPermission("LossEdit")] public Task CreateLoss(Guid id, CancellationToken ct) => CreateResultDocAsync(id, surplus: false, ct); private async Task 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 }); } } /// Строит Оприходование из строк излишков. Если post — сразу проводит /// (движения Enter, +остаток). Не сохраняет (SaveChanges делает вызывающий). private async Task BuildEnterAsync(InventoryDoc doc, IReadOnlyList lines, bool post, CancellationToken ct) { var currencyId = await DefaultCurrencyIdAsync(ct); var enter = new Enter { Number = await GenerateDocNumberAsync("О-", 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; } /// Строит Списание из строк недостачи. Если post — проводит (WriteOff, −остаток). private async Task BuildLossAsync(InventoryDoc doc, IReadOnlyList lines, bool post, CancellationToken ct) { var currencyId = await DefaultCurrencyIdAsync(ct); var loss = new Loss { Number = await GenerateDocNumberAsync("С-", 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; } /// МОБИЛЬНАЯ СОВМЕСТИМОСТЬ (временная, см. docs/mobile-api.md): iOS /// «FM Склад» вызывает post. post = завершить + создать и ПРОВЕСТИ Оприходование /// (излишки) и Списание (недостача). Веб использует complete + create-enter/create-loss. [HttpPost("{id:guid}/post"), RequiresPermission("InventoryEdit")] public async Task Post(Guid id, CancellationToken ct) { var doc = await _db.InventoryDocs.Include(d => d.Lines).FirstOrDefaultAsync(d => d.Id == id, ct); if (doc is null) return NotFound(); if (doc.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(); } /// «Вернуть в черновик» инвентаризацию. Если по ней ПРОВЕДЕНЫ /// связанные Оприходование/Списание — возвращаем 409 и просим сначала /// «Отменить результаты»: мы НЕ удаляем проведённые документы молча (это /// хуже МойСклада и теряет историю). Если связанные документы только /// черновики (или их нет) — удаляем черновики (они не влияли на остатки) и /// возвращаем инвентаризацию в черновик. /// /// Мобильный /unpost (совместимость, см. docs/mobile-api.md) — та же логика: /// при проведённых связанных документах возвращает тот же 409. [HttpPost("{id:guid}/unpost"), RequiresPermission("InventoryEdit")] public async Task 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(); 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(); } /// «Отменить результаты»: снять с проведения связанные Оприходование /// и Списание, НЕ удаляя их — они становятся черновиками, остаются в /// «Связанных документах», истории и аудите. Остатки возвращаются (как при /// обычной отмене проведения Оприходования/Списания). После этого /// инвентаризацию можно вернуть в черновик или снова провести документы. /// /// Право: InventoryEdit (эндпоинт) + EnterEdit (если есть проведённые /// Оприходования) и/или LossEdit (если есть проведённые Списания). [HttpPost("{id:guid}/cancel-results"), RequiresPermission("InventoryEdit")] public async Task 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(); 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 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); } /// Генерирует номер документа Оприходования/Списания по образцу их /// контроллеров: «<prefix><год>-NNNNNN». private async Task GenerateDocNumberAsync(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 GenerateNumberAsync(DateTime date, CancellationToken ct) { var year = date.Year; var prefix = $"И-{year}-"; var lastNumber = await _db.InventoryDocs .Where(i => i.Number.StartsWith(prefix)) .OrderByDescending(i => i.Number) .Select(i => i.Number) .FirstOrDefaultAsync(ct); var seq = 1; if (lastNumber is not null && int.TryParse(lastNumber[prefix.Length..], out var last)) seq = last + 1; return $"{prefix}{seq:D6}"; } private async Task GetInternal(Guid id, CancellationToken ct) { var row = await (from i in _db.InventoryDocs.AsNoTracking() join st in _db.Stores on i.StoreId equals st.Id where i.Id == id select new { i, st }).FirstOrDefaultAsync(ct); if (row is null) return null; var lines = await (from l in _db.InventoryLines.AsNoTracking() join p in _db.Products on l.ProductId equals p.Id join u in _db.UnitsOfMeasure on p.UnitOfMeasureId equals u.Id where l.InventoryDocId == id orderby l.SortOrder select new InventoryLineDto( l.Id, l.ProductId, p.Name, p.Article, u.Name, l.BookQty, l.ActualQty, l.Diff, l.UnitCost, l.SortOrder)) .ToListAsync(ct); var related = new List(); 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); } }