feat(inventory): отмена результатов не удаляет проведённые документы
Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions

Было: unpost при проведённых связанных Оприходовании/Списании молча снимал их с
проведения и УДАЛЯЛ (204, номера исчезали). Хуже МойСклада, теряется история.

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

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
nns 2026-10-09 00:51:00 +05:00
parent 447568fbda
commit ab416d720c
6 changed files with 306 additions and 38 deletions

View file

@ -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, план.

View file

@ -88,10 +88,23 @@ advanced-only скрыты; обычная роль — её флаги (в пр
- `POST /api/inventory/inventories/{id}/post` — **= завершить + создать и ПРОВЕСТИ** - `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` (как в вебе), чтобы кладовщик мог проверить `complete` + `create-enter`/`create-loss` (как в вебе), а для отмены — `cancel-results`
документы перед проведением. `post`/`unpost` будут помечены deprecated после обновления iOS. вместо удаления, чтобы история и аудит сохранялись. `post`/`unpost` будут помечены
deprecated после обновления iOS (iOS обновит Mac-Claude).

View file

@ -26,11 +26,16 @@ public class InventoriesController : ControllerBase
{ {
private readonly AppDbContext _db; private readonly AppDbContext _db;
private readonly IStockService _stock; 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; _db = db;
_stock = stock; _stock = stock;
_authz = authz;
_audit = audit;
} }
public record InventoryListRow( public record InventoryListRow(
@ -630,45 +635,152 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
return NoContent(); return NoContent();
} }
/// <summary>Отменить результаты: отменить проведение и удалить связанные /// <summary>«Вернуть в черновик» инвентаризацию. Если по ней ПРОВЕДЕНЫ
/// Оприходование/Списание (вернуть остатки), затем вернуть инвентаризацию в /// связанные Оприходование/Списание — возвращаем 409 и просим сначала
/// черновик. Веб предупреждает пользователя об удалении связанных документов. /// «Отменить результаты»: мы НЕ удаляем проведённые документы молча (это
/// Мобильный /unpost (совместимость) — эта же логика.</summary> /// хуже МойСклада и теряет историю). Если связанные документы только
/// черновики (или их нет) — удаляем черновики (они не влияли на остатки) и
/// возвращаем инвентаризацию в черновик.
///
/// Мобильный /unpost (совместимость, см. docs/mobile-api.md) — та же логика:
/// при проведённых связанных документах возвращает тот же 409.</summary>
[HttpPost("{id:guid}/unpost"), RequiresPermission("InventoryEdit")] [HttpPost("{id:guid}/unpost"), RequiresPermission("InventoryEdit")]
public async Task<IActionResult> Unpost(Guid id, CancellationToken ct) public async Task<IActionResult> Unpost(Guid id, CancellationToken ct)
{ {
var doc = await _db.InventoryDocs.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 is null) return NotFound();
await using var tx = await _db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, ct); var postedEnters = await _db.Enters
var enters = await _db.Enters.Include(e => e.Lines).Where(e => e.InventoryDocId == id).ToListAsync(ct); .Where(e => e.InventoryDocId == id && e.Status == EnterStatus.Posted)
foreach (var e in enters) .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) var parts = new List<string>();
foreach (var l in e.Lines) if (postedEnters.Count > 0) parts.Add("Оприходование " + string.Join(", ", postedEnters.Select(e => e.Number)));
await _stock.ApplyMovementAsync(new StockMovementDraft(l.ProductId, e.StoreId, -l.Quantity, if (postedLosses.Count > 0) parts.Add("Списание " + string.Join(", ", postedLosses.Select(l => l.Number)));
MovementType.Enter, "enter-reversal", e.Id, e.Number, l.UnitCost, DateTime.UtcNow, return Conflict(new
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) error = $"По инвентаризации проведены {string.Join(" и ", parts)}. Сначала отмените результаты.",
foreach (var l in ls.Lines) enters = postedEnters,
await _stock.ApplyMovementAsync(new StockMovementDraft(l.ProductId, ls.StoreId, l.Quantity, losses = postedLosses,
MovementType.WriteOff, "loss-reversal", ls.Id, ls.Number, l.UnitCost, DateTime.UtcNow, });
Notes: $"Отмена {ls.Number} (инвентаризация {doc.Number})"), ct);
_db.Losses.Remove(ls);
} }
// Проведённых связанных нет — удаляем черновики (они не влияли на остатки)
// и возвращаем инвентаризацию в черновик.
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.Status = InventoryStatus.Draft;
doc.PostedAt = null; 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 = "Документ обрабатывается параллельно. Повторите попытку." }); } catch (Exception ex) when (IsSerializationConflict(ex)) { return Conflict(new { error = "Документ обрабатывается параллельно. Повторите попытку." }); }
return NoContent(); return NoContent();
} }
/// <summary>«Отменить результаты»: снять с проведения связанные Оприходование
/// и Списание, НЕ удаляя их — они становятся черновиками, остаются в
/// «Связанных документах», истории и аудите. Остатки возвращаются (как при
/// обычной отмене проведения Оприходования/Списания). После этого
/// инвентаризацию можно вернуть в черновик или снова провести документы.
///
/// Право: InventoryEdit (эндпоинт) + EnterEdit (если есть проведённые
/// Оприходования) и/или LossEdit (если есть проведённые Списания).</summary>
[HttpPost("{id:guid}/cancel-results"), RequiresPermission("InventoryEdit")]
public async Task<IActionResult> CancelResults(Guid id, CancellationToken ct)
{
var doc = await _db.InventoryDocs.FirstOrDefaultAsync(d => d.Id == id, ct);
if (doc is null) return NotFound();
var enters = await _db.Enters.Include(e => e.Lines)
.Where(e => e.InventoryDocId == id && e.Status == EnterStatus.Posted).ToListAsync(ct);
var losses = await _db.Losses.Include(l => l.Lines)
.Where(l => l.InventoryDocId == id && l.Status == LossStatus.Posted).ToListAsync(ct);
if (enters.Count == 0 && losses.Count == 0)
return Conflict(new { error = "Нет проведённых связанных документов — отменять нечего." });
// Доп. права под конкретные типы связанных документов.
if (enters.Count > 0 && !(await _authz.AuthorizeAsync(User, "perm:EnterEdit")).Succeeded)
return Forbid();
if (losses.Count > 0 && !(await _authz.AuthorizeAsync(User, "perm:LossEdit")).Succeeded)
return Forbid();
await using var tx = await _db.Database.BeginTransactionAsync(System.Data.IsolationLevel.Serializable, ct);
// Для Оприходований проверяем, что снятие не уведёт остаток в минус
// (часть оприходованного излишка могла быть уже продана).
var conflicts = new List<object>();
foreach (var e in enters)
{
var reverseByProduct = e.Lines.GroupBy(l => l.ProductId)
.Select(g => new { ProductId = g.Key, Quantity = g.Sum(x => x.Quantity) }).ToList();
var pids = reverseByProduct.Select(r => r.ProductId).ToList();
var stocks = await _db.Stocks.Where(s => s.StoreId == e.StoreId && pids.Contains(s.ProductId))
.ToDictionaryAsync(s => s.ProductId, s => s.Quantity, ct);
foreach (var r in reverseByProduct)
{
stocks.TryGetValue(r.ProductId, out var available);
if (available < r.Quantity)
{
var name = await _db.Products.Where(p => p.Id == r.ProductId).Select(p => p.Name).FirstOrDefaultAsync(ct);
conflicts.Add(new { document = e.Number, productId = r.ProductId, productName = name, reverseQty = r.Quantity, available });
}
}
}
if (conflicts.Count > 0)
return Conflict(new { error = "Нельзя отменить результаты: остаток уйдёт в минус (часть оприходованного товара уже расходована).", lines = conflicts });
// Снимаем Оприходования с проведения (−остаток), оставляем черновиками.
foreach (var e in enters)
{
foreach (var l in e.Lines)
await _stock.ApplyMovementAsync(new StockMovementDraft(l.ProductId, e.StoreId, -l.Quantity,
MovementType.Enter, "enter-reversal", e.Id, e.Number, l.UnitCost, DateTime.UtcNow,
Notes: $"Отмена результатов инвентаризации {doc.Number}"), ct);
e.Status = EnterStatus.Draft;
e.PostedAt = null;
}
// Снимаем Списания с проведения (+остаток обратно), оставляем черновиками.
foreach (var ls in losses)
{
foreach (var l in ls.Lines)
await _stock.ApplyMovementAsync(new StockMovementDraft(l.ProductId, ls.StoreId, l.Quantity,
MovementType.WriteOff, "loss-reversal", ls.Id, ls.Number, l.UnitCost, DateTime.UtcNow,
Notes: $"Отмена результатов инвентаризации {doc.Number}"), ct);
ls.Status = LossStatus.Draft;
ls.PostedAt = null;
}
try
{
await _db.SaveChangesAsync(ct);
// Аудит в той же транзакции: кто/когда/из какой инвентаризации снял проведение.
await _audit.LogAsync("InventoryCancelResults", "inventory", doc.Id, new
{
inventoryNumber = doc.Number,
enters = enters.Select(e => new { e.Id, e.Number }).ToList(),
losses = losses.Select(l => new { l.Id, l.Number }).ToList(),
}, ct);
await tx.CommitAsync(ct);
}
catch (Exception ex) when (IsSerializationConflict(ex)) { return Conflict(new { error = "Документ обрабатывается параллельно. Повторите попытку." }); }
return Ok(new
{
cancelled = new
{
enters = enters.Select(e => new { e.Id, e.Number }).ToList(),
losses = losses.Select(l => new { l.Id, l.Number }).ToList(),
},
});
}
private async Task<Guid> DefaultCurrencyIdAsync(CancellationToken ct) private async Task<Guid> DefaultCurrencyIdAsync(CancellationToken ct)
{ {
var orgCur = await _db.Organizations.Select(o => o.DefaultCurrencyId).FirstOrDefaultAsync(ct); var orgCur = await _db.Organizations.Select(o => o.DefaultCurrencyId).FirstOrDefaultAsync(ct);

View file

@ -90,6 +90,8 @@ export function InventoryEditPage() {
const related = existing.data?.related ?? [] const related = existing.data?.related ?? []
const hasEnterDoc = related.some((d) => d.kind === 'enter') const hasEnterDoc = related.some((d) => d.kind === 'enter')
const hasLossDoc = related.some((d) => d.kind === 'loss') 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 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) 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: 'Списание создано' }, meta: { successMessage: 'Списание создано' },
}) })
// Вернуть в черновик: отменяет проведение и удаляет связанные документы. // Вернуть в черновик: если есть ПРОВЕДЁННЫЕ связанные документы — сервер
// вернёт 409 (сначала «Отменить результаты»). Черновики удаляются.
const unpost = useMutation({ const unpost = useMutation({
mutationFn: async () => { await api.post(`/api/inventory/inventories/${id}/unpost`) }, mutationFn: async () => { await api.post(`/api/inventory/inventories/${id}/unpost`) },
onSuccess: () => { onSuccess: () => {
@ -182,6 +185,20 @@ export function InventoryEditPage() {
meta: { successMessage: 'Возвращено в черновик' }, 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({ const remove = useMutation({
mutationFn: async () => { await api.delete(`/api/inventory/inventories/${id}`) }, mutationFn: async () => { await api.delete(`/api/inventory/inventories/${id}`) },
onSuccess: () => navigate('/inventory/inventories'), onSuccess: () => navigate('/inventory/inventories'),
@ -361,13 +378,33 @@ export function InventoryEditPage() {
title={hasLossDoc ? 'Списание уже создано' : (!hasShortage ? 'Нет недостачи' : '')}> title={hasLossDoc ? 'Списание уже создано' : (!hasShortage ? 'Нет недостачи' : '')}>
− Создать списание недостачи − Создать списание недостачи
</Button> </Button>
{hasPostedRelated && (
<Button type="button" variant="danger" <Button type="button" variant="danger"
disabled={cancelResults.isPending}
title="Снять проведённые Оприходование/Списание с проведения (останутся черновиками)"
onClick={async () => {
if (await confirm({
title: 'Отменить результаты инвентаризации?',
description:
'Будут сняты с проведения: ' +
postedRelated.map((d) => (d.kind === 'enter' ? 'Оприходование' : 'Списание') + ' ' + d.number).join(', ') +
'. Остатки вернутся к значениям до проведения. Документы НЕ удаляются — останутся черновиками и в истории.',
confirmLabel: 'Отменить результаты',
tone: 'warning',
})) cancelResults.mutate()
}}>
{cancelResults.isPending ? 'Отменяю…' : 'Отменить результаты'}
</Button>
)}
<Button type="button" variant="secondary"
disabled={unpost.isPending} disabled={unpost.isPending}
onClick={async () => { onClick={async () => {
if (await confirm({ if (await confirm({
title: 'Вернуть в черновик?', title: 'Вернуть в черновик?',
description: related.length > 0 description: hasPostedRelated
? 'Связанные Оприходование/Списание будут отменены и удалены, остатки вернутся. Затем можно снова править.' ? 'По инвентаризации есть проведённые документы. Сначала нажмите «Отменить результаты» — вернуть в черновик не получится, пока они проведены.'
: related.length > 0
? 'Черновики связанных Оприходования/Списания будут удалены (они не влияли на остатки). Инвентаризация вернётся в черновик.'
: 'Инвентаризация вернётся в черновик для правки.', : 'Инвентаризация вернётся в черновик для правки.',
confirmLabel: 'Вернуть в черновик', confirmLabel: 'Вернуть в черновик',
tone: 'warning', tone: 'warning',

View file

@ -1180,6 +1180,31 @@ export async function step10_warehouse_ops({ ctx, step, report }: StepCtx) {
rec(step, 'api', 'Излишек применён оприходованием (+3)', approxEq(nmAfter - nmBefore, 3), 3, round2(nmAfter - nmBefore)) rec(step, 'api', 'Излишек применён оприходованием (+3)', approxEq(nmAfter - nmBefore, 3), 3, round2(nmAfter - nmBefore))
const enterGet = await api.get(`/api/inventory/enters/${nmEnterId}`) const enterGet = await api.get(`/api/inventory/enters/${nmEnterId}`)
rec(step, 'api', 'Оприходование «на основании инвентаризации №…»', !!enterGet.data?.inventoryNumber, 'инв.№', enterGet.data?.inventoryNumber ?? '—') rec(step, 'api', 'Оприходование «на основании инвентаризации №…»', !!enterGet.data?.inventoryNumber, 'инв.№', enterGet.data?.inventoryNumber ?? '—')
// Отмена результатов: unpost при проведённом связанном → 409 (не удаляет);
// cancel-results снимает проведение, документ остаётся черновиком, остаток возвращается.
const badUnpost = await api.post(`/api/inventory/inventories/${nmId}/unpost`, {})
rec(step, 'api', 'unpost при проведённом связанном → 409 (не удаляет)', badUnpost.status === 409, 409, badUnpost.status)
if (badUnpost.status !== 409) report.bug({ step: '10', severity: 'critical', title: 'unpost снял/удалил проведённый документ без cancel-results', detail: `status=${badUnpost.status}` })
const cancel = await api.post(`/api/inventory/inventories/${nmId}/cancel-results`, {})
rec(step, 'api', 'Отмена результатов (cancel-results) → 200', cancel.status === 200, 200, cancel.status)
const afterCancel = await stockOf(api, ctx.storeId, nmProd.id)
rec(step, 'api', 'После отмены результатов остаток вернулся', approxEq(afterCancel, nmBefore), nmBefore, afterCancel)
const enterAfter = await api.get(`/api/inventory/enters/${nmEnterId}`)
const enterDraft = enterAfter.data?.status === 0 || `${enterAfter.data?.status}`.toLowerCase() === 'draft'
rec(step, 'api', 'Оприходование НЕ удалено, стало черновиком', enterAfter.status === 200 && enterDraft, 'draft', `${enterAfter.status}/${enterAfter.data?.status}`)
if (enterAfter.status === 404) report.bug({ step: '10', severity: 'critical', title: 'cancel-results удалил проведённый документ (потеря истории)', detail: 'enter 404' })
const invRel2 = await api.get(`/api/inventory/inventories/${nmId}`)
const stillLinked = ((invRel2.data?.related ?? []) as { kind: string; posted: boolean }[]).some((r) => r.kind === 'enter' && !r.posted)
rec(step, 'api', 'Связанный документ остался (черновик)', stillLinked, true, stillLinked)
// Повторное проведение → остаток снова = факт (+3).
const rePost = await api.post(`/api/inventory/enters/${nmEnterId}/post`, {})
rec(step, 'api', 'Повторное проведение оприходования', rePost.status === 204 || rePost.status === 200, '204', `${rePost.status}`)
const reAfter = await stockOf(api, ctx.storeId, nmProd.id)
rec(step, 'api', 'После повторного проведения остаток снова = факт (+3)', approxEq(reAfter - nmBefore, 3), 3, round2(reAfter - nmBefore))
} }
} }

View file

@ -75,11 +75,70 @@ public async Task Create_loads_book_qty_post_applies_diff_to_stock()
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(12m); (await api.StockOfAsync(refs.StoreId, p1)).Should().Be(12m);
(await api.StockOfAsync(refs.StoreId, p2)).Should().Be(3m); (await api.StockOfAsync(refs.StoreId, p2)).Should().Be(3m);
// Unpost → откат к 10/5. // Unpost при ПРОВЕДЁННЫХ связанных документах → 409 (не удаляет молча).
using var unpost = await api.Http.PostAsJsonAsync($"/api/inventory/inventories/{invId}/unpost", new { }); using var badUnpost = await api.Http.PostAsJsonAsync($"/api/inventory/inventories/{invId}/unpost", new { });
unpost.IsSuccessStatusCode.Should().BeTrue(); ((int)badUnpost.StatusCode).Should().Be(409, "нельзя вернуть в черновик, пока связанные документы проведены");
// Cancel-results → откат к 10/5; связанные документы остаются ЧЕРНОВИКАМИ (не удалены).
using var cancel = await api.Http.PostAsJsonAsync($"/api/inventory/inventories/{invId}/cancel-results", new { });
cancel.IsSuccessStatusCode.Should().BeTrue($"cancel-results вернул {(int)cancel.StatusCode}: {await cancel.Content.ReadAsStringAsync()}");
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(10m); (await api.StockOfAsync(refs.StoreId, p1)).Should().Be(10m);
(await api.StockOfAsync(refs.StoreId, p2)).Should().Be(5m); (await api.StockOfAsync(refs.StoreId, p2)).Should().Be(5m);
var invGet = await (await api.Http.GetAsync($"/api/inventory/inventories/{invId}")).Content.ReadFromJsonAsync<JsonElement>();
var related = invGet.GetProperty("related").EnumerateArray().ToList();
related.Should().NotBeEmpty("связанные Оприходование/Списание не удалены, остались черновиками");
related.Should().OnlyContain(r => r.GetProperty("posted").GetBoolean() == false);
// Теперь связанные — только черновики, unpost возвращает инвентаризацию в черновик.
using var unpost2 = await api.Http.PostAsJsonAsync($"/api/inventory/inventories/{invId}/unpost", new { });
unpost2.IsSuccessStatusCode.Should().BeTrue();
}
[Fact]
public async Task CancelResults_then_repost_restores_stock_to_actual()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"inv-cancel-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
// 10 шт на складе.
var seed = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
notes = "seed", lines = new[] { new { productId = p1, quantity = 10m, unitCost = 50m } },
});
seed.EnsureSuccessStatusCode();
var seedId = (await seed.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{seedId}/post", new { })).EnsureSuccessStatusCode();
// Инвентаризация: факт 13 (излишек +3). complete → create-enter → post.
var create = await api.Http.PostAsJsonAsync("/api/inventory/inventories", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, notes = "излишек",
lines = new[] { new { productId = p1, actualQty = 13m } },
});
create.EnsureSuccessStatusCode();
var invId = (await create.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/inventory/inventories/{invId}/complete", new { })).EnsureSuccessStatusCode();
var ce = await api.Http.PostAsJsonAsync($"/api/inventory/inventories/{invId}/create-enter", new { });
ce.EnsureSuccessStatusCode();
var enterId = (await ce.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("enterId").GetString();
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{enterId}/post", new { })).EnsureSuccessStatusCode();
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(13m, "излишек проведён");
// Отмена результатов → остаток 10, оприходование НЕ удалено (черновик).
using var cancel = await api.Http.PostAsJsonAsync($"/api/inventory/inventories/{invId}/cancel-results", new { });
cancel.IsSuccessStatusCode.Should().BeTrue($"cancel-results: {(int)cancel.StatusCode} {await cancel.Content.ReadAsStringAsync()}");
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(10m);
using var enterStill = await api.Http.GetAsync($"/api/inventory/enters/{enterId}");
enterStill.StatusCode.Should().Be(System.Net.HttpStatusCode.OK, "оприходование не удалено");
// Повторное проведение → остаток снова = факт (13).
using var repost = await api.Http.PostAsJsonAsync($"/api/inventory/enters/{enterId}/post", new { });
repost.IsSuccessStatusCode.Should().BeTrue();
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(13m);
} }
[Fact] [Fact]