diff --git a/deploy/migrate-inventory-to-docs.sh b/deploy/migrate-inventory-to-docs.sh new file mode 100755 index 0000000..93c36e4 --- /dev/null +++ b/deploy/migrate-inventory-to-docs.sh @@ -0,0 +1,51 @@ +#!/usr/bin/env bash +# Миграция старой модели инвентаризации (Posted + InventoryAdjustment-движения) +# в модель МойСклада: связанные ПРОВЕДЁННЫЕ Оприходование/Списание. Движения не +# задваиваются (переметка), остатки остаются неизменными (проверяется в отчёте). +# +# ПОРЯДОК: 1) backup БД 2) dry-run (отчёт) 3) --apply (реально, под каждую орг). +# +# Запуск (стенд): +# TOKEN= BASE=https://test.admin.food-market.kz \ +# bash deploy/migrate-inventory-to-docs.sh # dry-run +# ... same ... --apply # применить +# +# Backup стенд-БД (на prod-vm, где живёт food-market-stage-postgres): +# docker exec food-market-stage-postgres pg_dump -U food_market food_market \ +# | gzip > ~/stage-backup-inventory-$(date +%Y%m%d-%H%M%S).sql.gz +set -euo pipefail + +BASE="${BASE:-https://test.admin.food-market.kz}" +APPLY=0 +[[ "${1:-}" == "--apply" ]] && APPLY=1 + +if [[ -z "${TOKEN:-}" ]]; then + echo "✗ Нужен TOKEN (admin-токен организации). Миграция выполняется под текущую орг." >&2 + echo " Для нескольких организаций — запустить с токеном каждой." >&2 + exit 1 +fi + +if [[ "$APPLY" -eq 1 ]]; then + echo "⚠ ПЕРЕД --apply сделайте backup стенд-БД (см. шапку скрипта)." + echo " Миграция переметит движения inventory → enter/loss и создаст проведённые документы." + echo " Остатки по товарам остаются прежними (проверяется stockUnchanged в отчёте)." +fi + +URL="$BASE/api/admin/inventory-migration/run?apply=$([[ $APPLY -eq 1 ]] && echo true || echo false)" +echo "→ POST $URL" +resp=$(curl -sk -m 60 -X POST "$URL" -H "Authorization: Bearer $TOKEN" -H "Content-Length: 0") +echo "$resp" | python3 -m json.tool 2>/dev/null || echo "$resp" + +# Короткий итог +echo "$resp" | python3 - <<'PY' 2>/dev/null || true +import sys, json +try: + d = json.load(sys.stdin) +except Exception: + sys.exit(0) +print(f"\nИтог: applied={d.get('applied')} candidates={d.get('candidates')} " + f"migrated={d.get('migrated')} stockUnchanged={d.get('stockUnchanged')} " + f"stockDiffRows={d.get('stockDiffRows')}") +if d.get('applied') and not d.get('stockUnchanged'): + print("✗ ВНИМАНИЕ: остатки изменились — проверьте вручную и восстановите из backup!") +PY diff --git a/docs/inventory-model-progress.md b/docs/inventory-model-progress.md new file mode 100644 index 0000000..1373523 --- /dev/null +++ b/docs/inventory-model-progress.md @@ -0,0 +1,34 @@ +# Инвентаризация по модели МойСклада — прогресс + +Замечание владельца (08.10.2026): «инвентаризация стоит в состоянии „проведено“, +но без оприходования и списания — логика непонятная». + +**Сейчас (неправильно):** `InventoriesController.Post` сам применяет движения +`MovementType.InventoryAdjustment` к остаткам и ставит `Status=Posted` — без создания +Оприходования/Списания. Владелец видит «Проведено» без связанных документов; в отчётах +непонятно, откуда изменения. + +**Как должно быть (по МойСкладу):** инвентаризация НЕ двигает остатки; она фиксирует +расчётный/фактический/разницу, а движения создаются связанными документами +Оприходование (излишки) и Списание (недостача). + +## План / статус +0. [ ] Сверка с МойСкладом (headless Playwright) → `docs/inventory-moysklad-reference.md`. +1. [ ] Инвентаризация не двигает остатки; статусы «Черновик» → «Завершена» (без «Проведено»). +2. [ ] На завершённой — кнопки «Создать оприходование излишков» / «Создать списание недостачи» + / «Применить результаты» (обе). Документы со строками разницы + ссылка на инвентаризацию. +3. [ ] Блок «Связанные документы» в карточке; колонка «Результат» в списке; обратная ссылка в enter/loss. +4. [ ] Отмена проведения связанных — штатно; инвентаризацию нельзя вернуть в черновик при + проведённых связанных (понятный отказ). +5. [ ] Миграция Posted+InventoryAdjustment → связанные проведённые Оприходование/Списание + (движения перенести, не задвоить); статус → «Завершена». Остатки до/после совпадают. Backup stage. +6. [ ] Веб + мобильное API (post/unpost совместимо: post = завершить+применить) + CSV-импорт. +7. [ ] Тесты: unit / integration / e2e на стенде; full-chain шаг 10 + UAT. +8. [ ] docs/supply-inventory.md — инструкция «Как провести инвентаризацию». +9. [ ] deploy-stage; коммиты в main (Co-Authored-By: Claude Opus 5.5); global.json не коммитить; не мешать с rebrand. + +## Что НЕ трогаем +Прод, shared-postgres, global.json, gateway nginx, sklad. + +## Журнал +- 08.10 — старт: прочитано замечание, составлен план. Следующий шаг — сверка с МойСкладом. diff --git a/src/food-market.api/Controllers/Inventory/InventoriesController.cs b/src/food-market.api/Controllers/Inventory/InventoriesController.cs index 297039c..18ba529 100644 --- a/src/food-market.api/Controllers/Inventory/InventoriesController.cs +++ b/src/food-market.api/Controllers/Inventory/InventoriesController.cs @@ -3,6 +3,7 @@ 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; @@ -11,11 +12,13 @@ namespace foodmarket.Api.Controllers.Inventory; -/// Инвентаризация (пересчёт). Создание подгружает текущие остатки -/// склада в bookQty; пользователь вносит фактические количества; -/// при Post создаются корректирующие движения -/// на diff = actual - book (положительный — приход, отрицательный -/// — списание). +/// Инвентаризация (пересчёт) по модели МойСклада. Создание подгружает +/// текущие остатки склада в bookQty; пользователь вносит фактические +/// количества. «Завершение» (complete) замораживает строки (Completed) и +/// НЕ двигает остатки. По результату создаются связанные Оприходование +/// (create-enter, излишки diff>0) и Списание (create-loss, недостача +/// diff<0) — их проводят как обычные документы (вот тогда меняются остатки). +/// post/unpost — временная совместимость для мобильного «FM Склад». [ApiController] [Authorize] [Route("api/inventory/inventories")] @@ -35,19 +38,26 @@ public record InventoryListRow( Guid StoreId, string StoreName, int LineCount, decimal SurplusValue, decimal ShortageValue, - DateTime? PostedAt); + 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 Lines, + IReadOnlyList Related); public record InventoryLineInput( Guid ProductId, @@ -239,7 +249,11 @@ await using (var fs = System.IO.File.Create(tmp)) 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)) + 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 }; @@ -477,126 +491,204 @@ public async Task Delete(Guid id, CancellationToken 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.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); + if (doc.Status != InventoryStatus.Completed) { doc.Status = InventoryStatus.Completed; doc.PostedAt = DateTime.UtcNow; } - 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 = "Документ проводится параллельно другим запросом. Повторите попытку." }); - } + 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(); } + /// Отменить результаты: отменить проведение и удалить связанные + /// Оприходование/Списание (вернуть остатки), затем вернуть инвентаризацию в + /// черновик. Веб предупреждает пользователя об удалении связанных документов. + /// Мобильный /unpost (совместимость) — эта же логика. [HttpPost("{id:guid}/unpost"), RequiresPermission("InventoryEdit")] public async Task Unpost(Guid id, CancellationToken ct) { - var doc = await _db.InventoryDocs.Include(d => d.Lines).FirstOrDefaultAsync(d => d.Id == id, ct); + var doc = await _db.InventoryDocs.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(); - foreach (var r in positive) + await using var tx = await _db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, ct); + var enters = await _db.Enters.Include(e => e.Lines).Where(e => e.InventoryDocId == id).ToListAsync(ct); + foreach (var e in enters) { - 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 (e.Status == EnterStatus.Posted) + 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: $"Отмена {e.Number} (инвентаризация {doc.Number})"), ct); + _db.Enters.Remove(e); } - if (conflicts.Count > 0) + var losses = await _db.Losses.Include(l => l.Lines).Where(l => l.InventoryDocId == id).ToListAsync(ct); + foreach (var ls in losses) { - 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); + if (ls.Status == LossStatus.Posted) + 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: $"Отмена {ls.Number} (инвентаризация {doc.Number})"), ct); + _db.Losses.Remove(ls); } 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 = "Документ обрабатывается параллельно. Повторите попытку." }); - } + try { await _db.SaveChangesAsync(ct); await tx.CommitAsync(ct); } + catch (Exception ex) when (IsSerializationConflict(ex)) { return Conflict(new { error = "Документ обрабатывается параллельно. Повторите попытку." }); } return NoContent(); } + 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) @@ -641,10 +733,16 @@ orderby l.SortOrder 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); + lines, related); } } diff --git a/src/food-market.api/Controllers/Inventory/InventoryMigrationController.cs b/src/food-market.api/Controllers/Inventory/InventoryMigrationController.cs new file mode 100644 index 0000000..88a3623 --- /dev/null +++ b/src/food-market.api/Controllers/Inventory/InventoryMigrationController.cs @@ -0,0 +1,170 @@ +using foodmarket.Domain.Inventory; +using foodmarket.Domain.Purchases; +using foodmarket.Infrastructure.Persistence; +using Microsoft.AspNetCore.Authorization; +using Microsoft.AspNetCore.Mvc; +using Microsoft.EntityFrameworkCore; + +namespace foodmarket.Api.Controllers.Inventory; + +/// Разовая миграция старой модели инвентаризации в модель МойСклада: +/// инвентаризации, которые раньше сами двигали остатки движениями +/// MovementType.InventoryAdjustment, преобразуются в связанные ПРОВЕДЁННЫЕ +/// Оприходование (излишки) и Списание (недостача). Движения НЕ задваиваются — +/// существующие InventoryAdjustment-движения ПЕРЕМЕЧАЮТСЯ на новые документы +/// (Type→Enter/WriteOff, DocumentType→enter/loss, DocumentId/Number→новый док). +/// Остатки по товарам остаются неизменными (таблица stocks не трогается). +/// +/// Выполняется для текущей организации (для прод — запускать под каждой орг). +/// Dry-run по умолчанию (apply=false) — только отчёт. См. deploy/migrate-inventory-to-docs.sh. +[ApiController] +[Authorize(Roles = "SuperAdmin,Admin")] +[Route("api/admin/inventory-migration")] +public class InventoryMigrationController : ControllerBase +{ + private readonly AppDbContext _db; + public InventoryMigrationController(AppDbContext db) => _db = db; + + public record MigRow(Guid InventoryId, string InventoryNumber, int SurplusLines, int ShortageLines, + decimal SurplusValue, decimal ShortageValue, string? EnterNumber, string? LossNumber); + public record MigReport(bool Applied, int Candidates, int Migrated, bool StockUnchanged, + int StockDiffRows, IReadOnlyList Rows); + + [HttpPost("run")] + public async Task> Run([FromQuery] bool apply = false, CancellationToken ct = default) + { + // Кандидаты: Completed-инвентаризации с «старыми» движениями inventory и без связанных документов. + var candidateIds = await _db.InventoryDocs + .Where(i => i.Status == InventoryStatus.Completed) + .Where(i => _db.StockMovements.Any(m => m.DocumentType == "inventory" && m.DocumentId == i.Id)) + .Where(i => !_db.Enters.Any(e => e.InventoryDocId == i.Id) && !_db.Losses.Any(l => l.InventoryDocId == i.Id)) + .OrderBy(i => i.Date) + .Select(i => i.Id) + .ToListAsync(ct); + + // Снимок остатков ДО (для проверки неизменности). + var before = await _db.Stocks.AsNoTracking() + .Select(s => new { s.ProductId, s.StoreId, s.Quantity }) + .ToDictionaryAsync(s => (s.ProductId, s.StoreId), s => s.Quantity, ct); + + var currencyId = await DefaultCurrencyIdAsync(ct); + var rows = new List(); + int migrated = 0; + // Счётчики номеров (чтобы в одном прогоне не было коллизий). + int enterSeq = await MaxSeqAsync(isEnter: true, ct); + int lossSeq = await MaxSeqAsync(isEnter: false, ct); + + await using var tx = apply ? await _db.Database.BeginTransactionAsync(ct) : null; + + foreach (var invId in candidateIds) + { + var inv = await _db.InventoryDocs.FirstAsync(i => i.Id == invId, ct); + var movements = await _db.StockMovements + .Where(m => m.DocumentType == "inventory" && m.DocumentId == invId) + .ToListAsync(ct); + var surplus = movements.Where(m => m.Quantity > 0m).ToList(); + var shortage = movements.Where(m => m.Quantity < 0m).ToList(); + var surplusVal = surplus.Sum(m => m.Quantity * (m.UnitCost ?? 0m)); + var shortageVal = shortage.Sum(m => m.Quantity * (m.UnitCost ?? 0m)); + + string? enterNumber = null, lossNumber = null; + if (apply) + { + if (surplus.Count > 0) + { + enterNumber = $"О-{inv.Date.Year}-{++enterSeq:D6}"; + var enter = new Enter + { + OrganizationId = inv.OrganizationId, + Number = enterNumber, Date = inv.Date, Status = EnterStatus.Posted, + StoreId = inv.StoreId, CurrencyId = currencyId, InventoryDocId = inv.Id, + Notes = $"Излишки по инвентаризации {inv.Number} (миграция)", + PostedAt = inv.PostedAt ?? inv.Date, + Total = surplus.Sum(m => m.Quantity * (m.UnitCost ?? 0m)), + Lines = surplus.Select((m, idx) => new EnterLine + { + OrganizationId = inv.OrganizationId, ProductId = m.ProductId, + Quantity = m.Quantity, UnitCost = m.UnitCost ?? 0m, + LineTotal = m.Quantity * (m.UnitCost ?? 0m), SortOrder = idx, + }).ToList(), + }; + _db.Enters.Add(enter); + // ПЕРЕМЕТИТЬ движения (не задваивать): теперь принадлежат оприходованию. + foreach (var m in surplus) + { + m.Type = MovementType.Enter; + m.DocumentType = "enter"; m.DocumentId = enter.Id; m.DocumentNumber = enter.Number; + } + } + if (shortage.Count > 0) + { + lossNumber = $"С-{inv.Date.Year}-{++lossSeq:D6}"; + var loss = new Loss + { + OrganizationId = inv.OrganizationId, + Number = lossNumber, Date = inv.Date, Status = LossStatus.Posted, + StoreId = inv.StoreId, CurrencyId = currencyId, Reason = LossReason.Shortage, + InventoryDocId = inv.Id, Notes = $"Недостача по инвентаризации {inv.Number} (миграция)", + PostedAt = inv.PostedAt ?? inv.Date, + Total = shortage.Sum(m => -m.Quantity * (m.UnitCost ?? 0m)), + Lines = shortage.Select((m, idx) => new LossLine + { + OrganizationId = inv.OrganizationId, ProductId = m.ProductId, + Quantity = -m.Quantity, UnitCost = m.UnitCost ?? 0m, + LineTotal = -m.Quantity * (m.UnitCost ?? 0m), SortOrder = idx, + }).ToList(), + }; + _db.Losses.Add(loss); + foreach (var m in shortage) + { + m.Type = MovementType.WriteOff; + m.DocumentType = "loss"; m.DocumentId = loss.Id; m.DocumentNumber = loss.Number; + } + } + migrated++; + } + rows.Add(new MigRow(invId, inv.Number, surplus.Count, shortage.Count, surplusVal, shortageVal, enterNumber, lossNumber)); + } + + if (apply) + { + await _db.SaveChangesAsync(ct); + await tx!.CommitAsync(ct); + } + + // Снимок остатков ПОСЛЕ — должен совпасть (мы движения только переметили). + var after = await _db.Stocks.AsNoTracking() + .Select(s => new { s.ProductId, s.StoreId, s.Quantity }) + .ToDictionaryAsync(s => (s.ProductId, s.StoreId), s => s.Quantity, ct); + int diff = 0; + foreach (var kv in after) + if (!before.TryGetValue(kv.Key, out var b) || b != kv.Value) diff++; + foreach (var kv in before) + if (!after.ContainsKey(kv.Key)) diff++; + + return Ok(new MigReport(apply, candidateIds.Count, migrated, diff == 0, diff, rows)); + } + + private async Task MaxSeqAsync(bool isEnter, CancellationToken ct) + { + // Берём максимальный хвост номера среди всех «О-»/«С-» документов (по всем годам) грубо: + // новые номера генерим от (max+1). Для миграции достаточно монотонности. + var numbers = isEnter + ? await _db.Enters.Select(e => e.Number).ToListAsync(ct) + : await _db.Losses.Select(l => l.Number).ToListAsync(ct); + int max = 0; + foreach (var n in numbers) + { + var dash = n.LastIndexOf('-'); + if (dash >= 0 && int.TryParse(n[(dash + 1)..], out var s) && s > max) max = s; + } + return max; + } + + 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); + } +} diff --git a/src/food-market.api/Controllers/Inventory/LossesController.cs b/src/food-market.api/Controllers/Inventory/LossesController.cs index fe029b5..3f82e00 100644 --- a/src/food-market.api/Controllers/Inventory/LossesController.cs +++ b/src/food-market.api/Controllers/Inventory/LossesController.cs @@ -48,7 +48,9 @@ public record LossDto( Guid CurrencyId, string CurrencyCode, string? Notes, decimal Total, DateTime? PostedAt, - IReadOnlyList Lines); + IReadOnlyList Lines, + // «На основании инвентаризации №…» (null — обычное списание). + Guid? InventoryDocId, string? InventoryNumber); public record LossLineInput( Guid ProductId, @@ -387,12 +389,16 @@ orderby l.SortOrder .Select(s => (decimal?)s.Quantity).FirstOrDefault())) .ToListAsync(ct); + string? invNumber = row.l.InventoryDocId is { } invId + ? await _db.InventoryDocs.Where(i => i.Id == invId).Select(i => i.Number).FirstOrDefaultAsync(ct) + : null; return new LossDto( row.l.Id, row.l.Number, row.l.Date, row.l.Status, row.l.Reason, row.st.Id, row.st.Name, row.cu.Id, row.cu.Code, row.l.Notes, row.l.Total, row.l.PostedAt, - lines); + lines, + row.l.InventoryDocId, invNumber); } } diff --git a/src/food-market.api/Controllers/Purchases/EntersController.cs b/src/food-market.api/Controllers/Purchases/EntersController.cs index f38302a..216e04a 100644 --- a/src/food-market.api/Controllers/Purchases/EntersController.cs +++ b/src/food-market.api/Controllers/Purchases/EntersController.cs @@ -48,7 +48,9 @@ public record EnterDto( Guid CurrencyId, string CurrencyCode, string? Notes, decimal Total, DateTime? PostedAt, - IReadOnlyList Lines); + IReadOnlyList Lines, + // «На основании инвентаризации №…» (null — обычное оприходование). + Guid? InventoryDocId, string? InventoryNumber); public record EnterLineInput( Guid ProductId, @@ -391,12 +393,16 @@ orderby l.SortOrder l.Quantity, l.UnitCost, l.LineTotal, l.SortOrder)) .ToListAsync(ct); + string? invNumber = row.e.InventoryDocId is { } invId + ? await _db.InventoryDocs.Where(i => i.Id == invId).Select(i => i.Number).FirstOrDefaultAsync(ct) + : null; return new EnterDto( row.e.Id, row.e.Number, row.e.Date, row.e.Status, row.st.Id, row.st.Name, row.cu.Id, row.cu.Code, row.e.Notes, row.e.Total, row.e.PostedAt, - lines); + lines, + row.e.InventoryDocId, invNumber); } } diff --git a/src/food-market.api/Seed/DemoTenantSeeder.cs b/src/food-market.api/Seed/DemoTenantSeeder.cs index 5f94c75..7071fcc 100644 --- a/src/food-market.api/Seed/DemoTenantSeeder.cs +++ b/src/food-market.api/Seed/DemoTenantSeeder.cs @@ -368,13 +368,16 @@ public async Task SeedAsync(Guid orgId, CancellationToken ct = defa UnitCost: transferProd.Cost, OccurredAt: transfer.Date), ct); _db.Transfers.Add(transfer); - // ── 1 инвентаризация (Posted) с небольшим diff ────────────────────── + // ── 1 инвентаризация (Completed) с небольшим diff ─────────────────── + // По модели МойСклада инвентаризация остатки НЕ двигает; результаты + // применяются связанными Оприходованием/Списанием (кнопки в карточке). + // Демо оставляем в статусе «Завершена, не применено». var invProds = products.OrderBy(_ => _rng.Next()).Take(5).ToList(); var inv = new InventoryDoc { OrganizationId = orgId, Number = "ИНВ-DEMO-001", - Date = now.AddDays(-1), Status = InventoryStatus.Posted, PostedAt = now.AddDays(-1), + Date = now.AddDays(-1), Status = InventoryStatus.Completed, PostedAt = now.AddDays(-1), StoreId = mainStore.Id, Notes = "DEMO", }; int iOrder = 0; @@ -383,7 +386,6 @@ public async Task SeedAsync(Guid orgId, CancellationToken ct = defa var book = await _db.Stocks .Where(s => s.StoreId == mainStore.Id && s.ProductId == p.Id) .Select(s => (decimal?)s.Quantity).FirstOrDefaultAsync(ct) ?? 0m; - // Для демо: +/- 1 от книги (можно случайно) var diff = _rng.Next(-1, 2); var actual = Math.Max(0m, book + diff); inv.Lines.Add(new InventoryLine @@ -393,15 +395,6 @@ public async Task SeedAsync(Guid orgId, CancellationToken ct = defa ProductId = p.Id, BookQty = book, ActualQty = actual, Diff = actual - book, UnitCost = p.Cost, SortOrder = iOrder++, }); - if (actual - book != 0m) - { - await _stock.ApplyMovementAsync(new StockMovementDraft( - ProductId: p.Id, StoreId: mainStore.Id, Quantity: actual - book, - Type: MovementType.InventoryAdjustment, DocumentType: "inventory", - DocumentId: inv.Id, DocumentNumber: inv.Number, - UnitCost: p.Cost, OccurredAt: inv.Date, - Notes: actual > book ? "surplus" : "shortage"), ct); - } } _db.InventoryDocs.Add(inv); diff --git a/src/food-market.api/Seed/YearDemoSeeder.cs b/src/food-market.api/Seed/YearDemoSeeder.cs index a5f479a..2ffb10a 100644 --- a/src/food-market.api/Seed/YearDemoSeeder.cs +++ b/src/food-market.api/Seed/YearDemoSeeder.cs @@ -464,7 +464,9 @@ public async Task SeedAsync(Guid orgId, CancellationToken ct = defa { OrganizationId = orgId, Number = $"ИНВ-Y1-{i + 1:D3}", - Date = date, Status = InventoryStatus.Posted, PostedAt = date, + // Модель МойСклада: инвентаризация остатки не двигает (Completed, + // результаты применяются связанными Оприходованием/Списанием). + Date = date, Status = InventoryStatus.Completed, PostedAt = date, StoreId = mainStore.Id, Notes = "Y1", }; int order = 0; @@ -480,18 +482,6 @@ public async Task SeedAsync(Guid orgId, CancellationToken ct = defa BookQty = book, ActualQty = actual, Diff = actual - book, UnitCost = p.Cost, SortOrder = order++, }); - if (actual - book != 0) - { - movements.Add(new StockMovement - { - OrganizationId = orgId, ProductId = p.Id, StoreId = mainStore.Id, - Quantity = actual - book, UnitCost = p.Cost, - Type = MovementType.InventoryAdjustment, - DocumentType = "inventory", DocumentId = inv.Id, DocumentNumber = inv.Number, - OccurredAt = date, - Notes = actual > book ? "surplus" : "shortage", - }); - } } _db.InventoryDocs.Add(inv); inventories.Add(inv); diff --git a/src/food-market.application/Inventory/InventoryResult.cs b/src/food-market.application/Inventory/InventoryResult.cs new file mode 100644 index 0000000..2d55bad --- /dev/null +++ b/src/food-market.application/Inventory/InventoryResult.cs @@ -0,0 +1,42 @@ +namespace foodmarket.Application.Inventory; + +/// Чистая логика «разбить результат инвентаризации на строки +/// Оприходования (излишки) и Списания (недостача)». Вынесено для юнит-тестов. +/// +/// Правила (по модели МойСклада): +/// - diff = факт − расчётное; строки с diff == 0 НЕ участвуют (непосчитанные/совпавшие +/// не трогаются); +/// - diff > 0 → излишек → строка Оприходования, количество = diff; +/// - diff < 0 → недостача → строка Списания, количество = |diff|; +/// - сумма строки = количество × UnitCost (снимок себестоимости). +/// Сама инвентаризация остатки НЕ двигает. +public static class InventoryResult +{ + public readonly record struct ResultLine(Guid ProductId, decimal Quantity, decimal UnitCost) + { + public decimal LineTotal => decimal.Round(Quantity * UnitCost, 2); + } + + public readonly record struct Split(IReadOnlyList Surplus, IReadOnlyList Shortage) + { + public bool HasSurplus => Surplus.Count > 0; + public bool HasShortage => Shortage.Count > 0; + public decimal SurplusTotal => Surplus.Sum(l => l.LineTotal); + public decimal ShortageTotal => Shortage.Sum(l => l.LineTotal); + } + + /// Разбивает строки инвентаризации (productId, diff, unitCost) на излишки/недостачи. + /// diff==0 пропускаются. Количество в Списании положительное (|diff|). + public static Split Build(IEnumerable<(Guid ProductId, decimal Diff, decimal UnitCost)> lines) + { + var surplus = new List(); + var shortage = new List(); + foreach (var l in lines) + { + if (l.Diff > 0m) surplus.Add(new ResultLine(l.ProductId, l.Diff, l.UnitCost)); + else if (l.Diff < 0m) shortage.Add(new ResultLine(l.ProductId, -l.Diff, l.UnitCost)); + // diff == 0 — пропускаем + } + return new Split(surplus, shortage); + } +} diff --git a/src/food-market.domain/Inventory/InventoryDoc.cs b/src/food-market.domain/Inventory/InventoryDoc.cs index 8eacb87..2a66103 100644 --- a/src/food-market.domain/Inventory/InventoryDoc.cs +++ b/src/food-market.domain/Inventory/InventoryDoc.cs @@ -5,16 +5,21 @@ namespace foodmarket.Domain.Inventory; public enum InventoryStatus { + /// Идёт подсчёт — строки можно править. Draft = 0, - Posted = 1, + /// Подсчёт закончен, строки заморожены. По модели МойСклада + /// инвентаризация НЕ двигает остатки — изменения остатков делают связанные + /// Оприходование (излишки) и Списание (недостача), создаваемые «на основании». + /// Прежнее значение называлось Posted (двигало остатки напрямую) — после + /// миграции 20261009 переименовано в Completed, движения перенесены в документы. + Completed = 1, } /// Документ инвентаризации (пересчёта). При создании контроллер -/// заполняет bookQty по текущему склада. После -/// внесения фактических количеств (actualQty) при Post создаются -/// корректирующие движения -/// на diff = actual - book: положительные приходят (излишек), -/// отрицательные списываются (недостача). +/// заполняет bookQty по текущему склада. Пользователь +/// вносит фактические количества (actualQty); документ завершается +/// (Completed) БЕЗ движения остатков. По результату создаются связанные +/// Оприходование/Списание на diff = actual - book (излишек/недостача). /// /// Назван InventoryDoc чтобы не конфликтовать с .NET-неймспейсом /// System.Collections.Specialized.Inventory и не путаться с самой diff --git a/src/food-market.domain/Inventory/Loss.cs b/src/food-market.domain/Inventory/Loss.cs index 8ee9311..9ff53b6 100644 --- a/src/food-market.domain/Inventory/Loss.cs +++ b/src/food-market.domain/Inventory/Loss.cs @@ -48,6 +48,10 @@ public class Loss : TenantEntity /// Сумма по строкам = Σ Quantity·UnitCost. Балансовая стоимость списания. public decimal Total { get; set; } + /// Если документ создан «на основании инвентаризации» (недостача) — + /// ссылка на неё. Иначе null (обычное списание брака/просрочки). + public Guid? InventoryDocId { get; set; } + public DateTime? PostedAt { get; set; } public Guid? PostedByUserId { get; set; } diff --git a/src/food-market.domain/Purchases/Enter.cs b/src/food-market.domain/Purchases/Enter.cs index d5be210..4acbfea 100644 --- a/src/food-market.domain/Purchases/Enter.cs +++ b/src/food-market.domain/Purchases/Enter.cs @@ -36,6 +36,10 @@ public class Enter : TenantEntity /// Сумма по строкам = Σ Quantity·UnitCost. public decimal Total { get; set; } + /// Если документ создан «на основании инвентаризации» (излишки) — + /// ссылка на неё. Иначе null (обычное оприходование начальных остатков). + public Guid? InventoryDocId { get; set; } + public DateTime? PostedAt { get; set; } public Guid? PostedByUserId { get; set; } diff --git a/src/food-market.infrastructure/Persistence/Migrations/20261009000000_Phase38_InventoryLinks.cs b/src/food-market.infrastructure/Persistence/Migrations/20261009000000_Phase38_InventoryLinks.cs new file mode 100644 index 0000000..86ca246 --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20261009000000_Phase38_InventoryLinks.cs @@ -0,0 +1,40 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Phase38 — связь Оприходования/Списания с инвентаризацией (модель + /// МойСклада: инвентаризация не двигает остатки; излишки/недостача оформляются + /// связанными документами). Добавляет nullable InventoryDocId в + /// enters и losses + индексы для выборки «связанные документы». + /// Статус инвентаризации Posted(1) переосмыслен как Completed(1) — числовое + /// значение не меняется; перенос движений делает отдельный скрипт миграции данных + /// (deploy/migrate-inventory-to-docs.sql / .sh). Идемпотентно. + [DbContext(typeof(AppDbContext))] + [Migration("20261009000000_Phase38_InventoryLinks")] + public partial class Phase38_InventoryLinks : Migration + { + protected override void Up(MigrationBuilder b) + { + b.Sql(@" + ALTER TABLE public.enters ADD COLUMN IF NOT EXISTS ""InventoryDocId"" uuid NULL; + ALTER TABLE public.losses ADD COLUMN IF NOT EXISTS ""InventoryDocId"" uuid NULL; + CREATE INDEX IF NOT EXISTS ""IX_enters_InventoryDocId"" ON public.enters (""InventoryDocId"") WHERE ""InventoryDocId"" IS NOT NULL; + CREATE INDEX IF NOT EXISTS ""IX_losses_InventoryDocId"" ON public.losses (""InventoryDocId"") WHERE ""InventoryDocId"" IS NOT NULL; + "); + } + + protected override void Down(MigrationBuilder b) + { + b.Sql(@" + DROP INDEX IF EXISTS ""IX_enters_InventoryDocId""; + DROP INDEX IF EXISTS ""IX_losses_InventoryDocId""; + ALTER TABLE public.enters DROP COLUMN IF EXISTS ""InventoryDocId""; + ALTER TABLE public.losses DROP COLUMN IF EXISTS ""InventoryDocId""; + "); + } + } +} diff --git a/tests/food-market.UnitTests/DomainPocoSmokeTests.cs b/tests/food-market.UnitTests/DomainPocoSmokeTests.cs index 6f8058f..d8fad96 100644 --- a/tests/food-market.UnitTests/DomainPocoSmokeTests.cs +++ b/tests/food-market.UnitTests/DomainPocoSmokeTests.cs @@ -195,13 +195,13 @@ public void Inventory_movement_loss_transfer_pocos() Number = "ИН-001", Date = DateTime.UtcNow, StoreId = Guid.NewGuid(), - Status = InventoryStatus.Posted, + Status = InventoryStatus.Completed, }; inv.Lines.Add(new InventoryLine { ProductId = Guid.NewGuid(), BookQty = 10m, ActualQty = 9m, Diff = -1m }); loss.Status.Should().Be(LossStatus.Posted); t.Status.Should().Be(TransferStatus.Posted); - inv.Status.Should().Be(InventoryStatus.Posted); + inv.Status.Should().Be(InventoryStatus.Completed); } [Fact] diff --git a/tests/food-market.UnitTests/InventoryResultTests.cs b/tests/food-market.UnitTests/InventoryResultTests.cs new file mode 100644 index 0000000..9f67d39 --- /dev/null +++ b/tests/food-market.UnitTests/InventoryResultTests.cs @@ -0,0 +1,76 @@ +using foodmarket.Application.Inventory; +using Xunit; + +namespace foodmarket.UnitTests; + +/// Логика разбивки результата инвентаризации на Оприходование/Списание +/// (модель МойСклада). diff==0 не участвует; суммы = кол-во × себестоимость. +public class InventoryResultTests +{ + private static (Guid, decimal, decimal) L(decimal diff, decimal cost) => (Guid.NewGuid(), diff, cost); + + [Fact] + public void Surplus_goes_to_enter_shortage_to_loss() + { + var p1 = Guid.NewGuid(); var p2 = Guid.NewGuid(); + var split = InventoryResult.Build(new[] + { + (p1, 3m, 100m), // излишек +3 + (p2, -2m, 50m), // недостача -2 + }); + + Assert.Single(split.Surplus); + Assert.Single(split.Shortage); + Assert.Equal(p1, split.Surplus[0].ProductId); + Assert.Equal(3m, split.Surplus[0].Quantity); + Assert.Equal(p2, split.Shortage[0].ProductId); + Assert.Equal(2m, split.Shortage[0].Quantity); // |-2| + } + + [Fact] + public void Zero_diff_lines_are_ignored() + { + var split = InventoryResult.Build(new[] + { + L(0m, 100m), // совпало — не трогаем + L(0m, 999m), + L(1m, 10m), // только эта + }); + Assert.Single(split.Surplus); + Assert.Empty(split.Shortage); + Assert.Equal(1m, split.Surplus[0].Quantity); + } + + [Fact] + public void Sums_are_quantity_times_cost() + { + var split = InventoryResult.Build(new[] + { + L(2m, 150m), // +2 × 150 = 300 + L(5m, 20m), // +5 × 20 = 100 + L(-3m, 40m), // 3 × 40 = 120 (недостача) + }); + Assert.Equal(400m, split.SurplusTotal); + Assert.Equal(120m, split.ShortageTotal); + Assert.True(split.HasSurplus); + Assert.True(split.HasShortage); + } + + [Fact] + public void Line_total_rounds_to_two_decimals() + { + var split = InventoryResult.Build(new[] { L(3m, 548.6441m) }); + // 3 × 548.6441 = 1645.9323 → округление до 2 знаков + Assert.Equal(1645.93m, split.Surplus[0].LineTotal); + } + + [Fact] + public void No_diffs_gives_empty_split() + { + var split = InventoryResult.Build(new[] { L(0m, 10m), L(0m, 20m) }); + Assert.False(split.HasSurplus); + Assert.False(split.HasShortage); + Assert.Equal(0m, split.SurplusTotal); + Assert.Equal(0m, split.ShortageTotal); + } +}