Domain InventoryDoc+InventoryLine (productId, bookQty, actualQty, diff). EF, миграция Phase6d_Inventories. Контроллер api/inventory/inventories: Create без строк автоматически подгружает все товары склада с текущим Stock в bookQty (actual=0); Update пишет actualQty по строкам, пересчитывая diff. Post создаёт корректирующие движения InventoryAdjustment на diff (положительный — приход излишка, отрицательный — списание недостачи). Unpost атомарно откатывает; проверка «излишек уже расходован» → 409. Web: /inventory/inventories (list с разделением излишек/недостача) + edit с импортом CSV (productId|article;actualQty). Сайдбар «Инвентаризации». Тесты: 3 интеграционных (create-подгрузка bookQty + apply diff; post 400 если diff=0; tenant-изоляция). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
144 lines
6.8 KiB
C#
144 lines
6.8 KiB
C#
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 → откат к 10/5.
|
|
using var unpost = await api.Http.PostAsJsonAsync($"/api/inventory/inventories/{invId}/unpost", new { });
|
|
unpost.IsSuccessStatusCode.Should().BeTrue();
|
|
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(10m);
|
|
(await api.StockOfAsync(refs.StoreId, p2)).Should().Be(5m);
|
|
}
|
|
|
|
[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);
|
|
}
|
|
}
|