food-market/tests/food-market.IntegrationTests/InventoryPostUnpostTests.cs
nns ab416d720c
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
feat(inventory): отмена результатов не удаляет проведённые документы
Было: 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>
2026-10-09 00:51:00 +05:00

203 lines
11 KiB
C#
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
[Collection(ApiCollection.Name)]
public class InventoryPostUnpostTests
{
private readonly ApiFactory _factory;
public InventoryPostUnpostTests(ApiFactory factory) => _factory = factory;
private static string RandomBarcode()
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
[Fact]
public async Task Create_loads_book_qty_post_applies_diff_to_stock()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"inv-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var p1 = await api.CreateProductAsync(refs, $"P1-{Guid.NewGuid():N}", 100m, RandomBarcode());
var p2 = await api.CreateProductAsync(refs, $"P2-{Guid.NewGuid():N}", 200m, RandomBarcode());
// Подкладываем 10 шт p1 и 5 шт p2 через Enter.
foreach (var (pid, qty, cost) in new[] { (p1, 10m, 50m), (p2, 5m, 100m) })
{
var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
notes = "seed", lines = new[] { new { productId = pid, quantity = qty, unitCost = cost } },
});
enter.EnsureSuccessStatusCode();
var eid = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
}
// Создаём инвентаризацию без строк — контроллер подгрузит сам.
var create = await api.Http.PostAsJsonAsync("/api/inventory/inventories", new
{
date = DateTime.UtcNow,
storeId = refs.StoreId,
notes = "ежемесячный пересчёт",
lines = (object[])Array.Empty<object>(),
});
create.EnsureSuccessStatusCode();
var inv = await create.Content.ReadFromJsonAsync<JsonElement>();
var invId = inv.GetProperty("id").GetString();
var lines = inv.GetProperty("lines").EnumerateArray().ToList();
lines.Should().HaveCountGreaterOrEqualTo(2, "контроллер подгрузил bookQty для всех товаров склада");
var lineP1 = lines.First(l => l.GetProperty("productId").GetString() == p1);
lineP1.GetProperty("bookQty").GetDecimal().Should().Be(10m);
lineP1.GetProperty("actualQty").GetDecimal().Should().Be(0m);
// Вносим фактические: 12 шт p1 (излишек +2), 3 шт p2 (недостача -2).
var put = await api.Http.PutAsJsonAsync($"/api/inventory/inventories/{invId}", new
{
date = DateTime.UtcNow,
storeId = refs.StoreId,
notes = "проведено",
lines = new[]
{
new { productId = p1, actualQty = 12m },
new { productId = p2, actualQty = 3m },
},
});
put.EnsureSuccessStatusCode();
// Post → стоки 12 / 3.
using var post = await api.Http.PostAsJsonAsync($"/api/inventory/inventories/{invId}/post", new { });
post.IsSuccessStatusCode.Should().BeTrue($"post вернул {(int)post.StatusCode}: {await post.Content.ReadAsStringAsync()}");
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(12m);
(await api.StockOfAsync(refs.StoreId, p2)).Should().Be(3m);
// Unpost при ПРОВЕДЁННЫХ связанных документах → 409 (не удаляет молча).
using var badUnpost = await api.Http.PostAsJsonAsync($"/api/inventory/inventories/{invId}/unpost", new { });
((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, 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]
public async Task Post_rejected_if_no_diffs()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"inv-nodiff-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
var enter = 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 = 3m, unitCost = 50m } },
});
enter.EnsureSuccessStatusCode();
var eid = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
var create = await api.Http.PostAsJsonAsync("/api/inventory/inventories", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, notes = "match", lines = (object[])Array.Empty<object>(),
});
create.EnsureSuccessStatusCode();
var inv = await create.Content.ReadFromJsonAsync<JsonElement>();
var invId = inv.GetProperty("id").GetString();
// Фактическое = учётному (3 == 3, diff = 0).
await api.Http.PutAsJsonAsync($"/api/inventory/inventories/{invId}", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, notes = "no-diff",
lines = new[] { new { productId = p1, actualQty = 3m } },
});
using var post = await api.Http.PostAsJsonAsync($"/api/inventory/inventories/{invId}/post", new { });
((int)post.StatusCode).Should().Be(400);
}
[Fact]
public async Task Tenant_isolation_inventory()
{
var a = new ApiActor(_factory.CreateClient());
var b = new ApiActor(_factory.CreateClient());
await a.SignupAndLoginAsync($"inv-iso-a-{Guid.NewGuid():N}");
await b.SignupAndLoginAsync($"inv-iso-b-{Guid.NewGuid():N}");
var refsA = await a.LoadRefsAsync();
var resp = await a.Http.PostAsJsonAsync("/api/inventory/inventories", new
{
date = DateTime.UtcNow, storeId = refsA.StoreId, notes = "iso", lines = (object[])Array.Empty<object>(),
});
resp.EnsureSuccessStatusCode();
var invId = (await resp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
var bList = await b.ListAsync("/api/inventory/inventories?pageSize=200");
bList.Should().NotContain(x => x.GetProperty("id").GetString() == invId);
using var direct = await b.Http.GetAsync($"/api/inventory/inventories/{invId}");
direct.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound);
}
}