From ab416d720c34e64c12dbc96d3361727bcd588b18 Mon Sep 17 00:00:00 2001 From: nns Date: Fri, 9 Oct 2026 00:51:00 +0500 Subject: [PATCH] =?UTF-8?q?feat(inventory):=20=D0=BE=D1=82=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D0=B0=20=D1=80=D0=B5=D0=B7=D1=83=D0=BB=D1=8C=D1=82=D0=B0?= =?UTF-8?q?=D1=82=D0=BE=D0=B2=20=D0=BD=D0=B5=20=D1=83=D0=B4=D0=B0=D0=BB?= =?UTF-8?q?=D1=8F=D0=B5=D1=82=20=D0=BF=D1=80=D0=BE=D0=B2=D0=B5=D0=B4=D1=91?= =?UTF-8?q?=D0=BD=D0=BD=D1=8B=D0=B5=20=D0=B4=D0=BE=D0=BA=D1=83=D0=BC=D0=B5?= =?UTF-8?q?=D0=BD=D1=82=D1=8B?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Было: 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 --- docs/inventory-cancel-results-progress.md | 22 +++ docs/mobile-api.md | 23 ++- .../Inventory/InventoriesController.cs | 162 +++++++++++++++--- .../src/pages/InventoryEditPage.tsx | 47 ++++- tests/e2e/scenarios/full-chain.steps.ts | 25 +++ .../InventoryPostUnpostTests.cs | 65 ++++++- 6 files changed, 306 insertions(+), 38 deletions(-) create mode 100644 docs/inventory-cancel-results-progress.md diff --git a/docs/inventory-cancel-results-progress.md b/docs/inventory-cancel-results-progress.md new file mode 100644 index 0000000..694651a --- /dev/null +++ b/docs/inventory-cancel-results-progress.md @@ -0,0 +1,22 @@ +# Отмена инвентаризации не должна удалять проведённые документы + +Mac-Claude на стенде 08.10: `POST /api/inventory/inventories/{id}/unpost` при ПРОВЕДЁННЫХ +связанных Оприходовании/Списании молча снимает их с проведения и УДАЛЯЕТ (204, номера О-/С- +исчезают). Хуже МойСклада (там связанные отменяют вручную, история сохраняется). Правило +владельца: «всегда как минимум как в МойСкладе, или лучше». + +## План / статус +1. [ ] `unpost` при ПРОВЕДЁННЫХ связанных → 409 с текстом и номерами О-/С-. Если связанные только + черновики — вернуть в черновик, черновики удалить (не влияли на остатки). +2. [ ] Новое `POST …/{id}/cancel-results` (InventoryEdit + EnterEdit/LossEdit): снимает связанные + Оприходование/Списание с проведения, НЕ удаляя (остаются черновиками, видны в истории/аудите). +3. [ ] Веб: кнопка «Отменить результаты» + диалог (перечислить документы и влияние на остатки). +4. [ ] Мобильное API: старый unpost больше НЕ удаляет проведённые → тот же 409; docs/mobile-api.md. +5. [ ] Аудит: снятие с проведения связанных документов — кто/когда/из какой инвентаризации. +6. [ ] Тесты unit/integration + full-chain шаг 10; деплой на стенд. + +## Что НЕ трогаем +Прод, shared-postgres, global.json, gateway nginx, sklad. + +## Журнал +- 08.10 — старт: разбор текущего unpost, план. diff --git a/docs/mobile-api.md b/docs/mobile-api.md index 63663bd..4e17201 100644 --- a/docs/mobile-api.md +++ b/docs/mobile-api.md @@ -88,10 +88,23 @@ advanced-only скрыты; обычная роль — её флаги (в пр - `POST /api/inventory/inventories/{id}/post` — **= завершить + создать и ПРОВЕСТИ** Оприходование (излишки) и Списание (недостача) одним вызовом. Идемпотентно: если связанные документы уже созданы — не дублирует. -- `POST /api/inventory/inventories/{id}/unpost` — отменить результаты: отменить - проведение и удалить связанные Оприходование/Списание (остатки вернутся), вернуть - инвентаризацию в черновик. +- `POST /api/inventory/inventories/{id}/unpost` — **вернуть в черновик**. Поведение + изменено (08.10): если по инвентаризации есть **проведённые** связанные Оприходование/ + Списание — возвращает **409** с текстом + *«По инвентаризации проведены Оприходование О-… и Списание С-…. Сначала отмените + результаты»* и полями `enters`/`losses` (id+номер). Больше **НЕ удаляет** проведённые + документы молча. Если связанные документы только черновики (или их нет) — удаляет + черновики (они не влияли на остатки) и возвращает инвентаризацию в черновик (204). +- `POST /api/inventory/inventories/{id}/cancel-results` — **отменить результаты**: снимает + проведённые связанные Оприходование/Списание с проведения (остатки возвращаются), + **НЕ удаляя** их — они становятся черновиками и остаются в «Связанных документах», + истории и аудите. Право: `InventoryEdit` + `EnterEdit` (если есть проведённые + Оприходования) и/или `LossEdit` (если есть Списания). 409, если снятие Оприходования + уведёт остаток в минус (часть излишка уже продана) — с полем `lines`. После отмены + результатов инвентаризацию можно вернуть в черновик (`unpost`) или снова провести + документы (`enters/{id}/post`, `losses/{id}/post`). **Статус: временное.** Рекомендуемый путь для новых версий мобильного приложения — -`complete` + `create-enter`/`create-loss` (как в вебе), чтобы кладовщик мог проверить -документы перед проведением. `post`/`unpost` будут помечены deprecated после обновления iOS. +`complete` + `create-enter`/`create-loss` (как в вебе), а для отмены — `cancel-results` +вместо удаления, чтобы история и аудит сохранялись. `post`/`unpost` будут помечены +deprecated после обновления iOS (iOS обновит Mac-Claude). diff --git a/src/food-market.api/Controllers/Inventory/InventoriesController.cs b/src/food-market.api/Controllers/Inventory/InventoriesController.cs index 18ba529..4e241f8 100644 --- a/src/food-market.api/Controllers/Inventory/InventoriesController.cs +++ b/src/food-market.api/Controllers/Inventory/InventoriesController.cs @@ -26,11 +26,16 @@ 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) + 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( @@ -630,45 +635,152 @@ public async Task Post(Guid id, CancellationToken ct) return NoContent(); } - /// Отменить результаты: отменить проведение и удалить связанные - /// Оприходование/Списание (вернуть остатки), затем вернуть инвентаризацию в - /// черновик. Веб предупреждает пользователя об удалении связанных документов. - /// Мобильный /unpost (совместимость) — эта же логика. + /// «Вернуть в черновик» инвентаризацию. Если по ней ПРОВЕДЕНЫ + /// связанные Оприходование/Списание — возвращаем 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(); - 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) + 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) { - 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); - } - var losses = await _db.Losses.Include(l => l.Lines).Where(l => l.InventoryDocId == id).ToListAsync(ct); - foreach (var ls in losses) - { - 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); + 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); await tx.CommitAsync(ct); } + 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); diff --git a/src/food-market.web/src/pages/InventoryEditPage.tsx b/src/food-market.web/src/pages/InventoryEditPage.tsx index 1e9fdae..6f0fe38 100644 --- a/src/food-market.web/src/pages/InventoryEditPage.tsx +++ b/src/food-market.web/src/pages/InventoryEditPage.tsx @@ -90,6 +90,8 @@ export function InventoryEditPage() { const related = existing.data?.related ?? [] const hasEnterDoc = related.some((d) => d.kind === 'enter') const hasLossDoc = related.some((d) => d.kind === 'loss') + const postedRelated = related.filter((d) => d.posted) + const hasPostedRelated = postedRelated.length > 0 const surplusValue = form.lines.filter((l) => l.diff > 0).reduce((s, l) => s + l.diff * l.unitCost, 0) const shortageValue = form.lines.filter((l) => l.diff < 0).reduce((s, l) => s + l.diff * l.unitCost, 0) @@ -169,7 +171,8 @@ export function InventoryEditPage() { meta: { successMessage: 'Списание создано' }, }) - // Вернуть в черновик: отменяет проведение и удаляет связанные документы. + // Вернуть в черновик: если есть ПРОВЕДЁННЫЕ связанные документы — сервер + // вернёт 409 (сначала «Отменить результаты»). Черновики удаляются. const unpost = useMutation({ mutationFn: async () => { await api.post(`/api/inventory/inventories/${id}/unpost`) }, onSuccess: () => { @@ -182,6 +185,20 @@ export function InventoryEditPage() { meta: { successMessage: 'Возвращено в черновик' }, }) + // Отменить результаты: снимает проведённые Оприходование/Списание с + // проведения (остаются черновиками), остатки возвращаются. Документы НЕ удаляются. + const cancelResults = useMutation({ + mutationFn: async () => { await api.post(`/api/inventory/inventories/${id}/cancel-results`) }, + onSuccess: () => { + qc.invalidateQueries({ queryKey: ['/api/inventory/inventories'] }) + qc.invalidateQueries({ queryKey: ['/api/inventory/stock'] }) + qc.invalidateQueries({ queryKey: ['/api/inventory/movements'] }) + existing.refetch() + }, + onError: (e: Error) => setError(errMsg(e)), + meta: { successMessage: 'Результаты отменены — документы стали черновиками' }, + }) + const remove = useMutation({ mutationFn: async () => { await api.delete(`/api/inventory/inventories/${id}`) }, onSuccess: () => navigate('/inventory/inventories'), @@ -361,14 +378,34 @@ export function InventoryEditPage() { title={hasLossDoc ? 'Списание уже создано' : (!hasShortage ? 'Нет недостачи' : '')}> − Создать списание недостачи - + )} +