После рефактора модели МойСклада (839fcaf) авто-загруженные строки получают
ActualQty=BookQty (а не 0), и проведение без расхождений — no-op (204, без
документов, остаток не меняется). Обновил два устаревших ассерта + все 4 теста
InventoryPostUnpostTests зелёные (вкл. cancel-results).
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
209 lines
12 KiB
C#
209 lines
12 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);
|
||
// Авто-загруженные строки «посчитаны по учёту»: ActualQty = BookQty, Diff = 0
|
||
// (чтобы проведение не обнуляло непересчитанные позиции — см. 839fcaf).
|
||
lineP1.GetProperty("actualQty").GetDecimal().Should().Be(10m);
|
||
|
||
// Вносим фактические: 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_with_no_diffs_is_noop()
|
||
{
|
||
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 { });
|
||
post.IsSuccessStatusCode.Should().BeTrue($"post при нулевых расхождениях: {(int)post.StatusCode} {await post.Content.ReadAsStringAsync()}");
|
||
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(3m, "без расхождений остаток не меняется");
|
||
var invGet = await (await api.Http.GetAsync($"/api/inventory/inventories/{invId}")).Content.ReadFromJsonAsync<JsonElement>();
|
||
invGet.GetProperty("related").EnumerateArray().Should().BeEmpty("без расхождений связанные документы не создаются");
|
||
}
|
||
|
||
[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);
|
||
}
|
||
}
|