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
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:
parent
447568fbda
commit
ab416d720c
22
docs/inventory-cancel-results-progress.md
Normal file
22
docs/inventory-cancel-results-progress.md
Normal 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, план.
|
||||||
|
|
@ -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).
|
||||||
|
|
|
||||||
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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',
|
||||||
|
|
|
||||||
|
|
@ -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))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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]
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue