food-market/tests/food-market.IntegrationTests/LossPostUnpostTests.cs
nns 3172b0ea72 feat(losses): списание со склада с указанием причины (P1-2)
Domain Loss+LossLine + enum LossReason (Defect/Expired/Damage/Shortage/Other).
EF, миграция Phase6b_Losses. Контроллер api/inventory/losses: CRUD +
Post/Unpost. Post создаёт StockMovement тип WriteOff с -Quantity; защита
от ухода в минус (409 со списком конфликтов). Unpost возвращает товар.
Web: /inventory/losses (list+edit) с фильтром по причине и колонкой
текущего остатка в строке. Сайдбар: «Списания» (Admin/Storekeeper).

Тесты: 3 интеграционных (post→stock падает, unpost восстанавливает;
списание сверх остатка → 409; tenant-изоляция).

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-28 09:24:40 +05:00

118 lines
5.9 KiB
C#
Raw Permalink 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 LossPostUnpostTests
{
private readonly ApiFactory _factory;
public LossPostUnpostTests(ApiFactory factory) => _factory = factory;
private static string RandomBarcode()
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
/// <summary>Положить через Enter, потом списать, проверить остаток и обратимость.</summary>
[Fact]
public async Task Posting_loss_decrements_stock_unpost_restores()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"loss-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 200m, RandomBarcode());
// Сначала оприходуем 10 шт чтобы было что списывать.
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, quantity = 10m, unitCost = 50m } },
});
enter.EnsureSuccessStatusCode();
var enterId = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{enterId}/post", new { }))
.IsSuccessStatusCode.Should().BeTrue();
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(10m);
// Списываем 3 шт с причиной Expired.
var loss = await api.Http.PostAsJsonAsync("/api/inventory/losses", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
reason = 1, // Expired
notes = "просрочка партии",
lines = new[] { new { productId, quantity = 3m, unitCost = 50m } },
});
loss.EnsureSuccessStatusCode();
var lossId = (await loss.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
using var post = await api.Http.PostAsJsonAsync($"/api/inventory/losses/{lossId}/post", new { });
post.IsSuccessStatusCode.Should().BeTrue($"post вернул {(int)post.StatusCode}: {await post.Content.ReadAsStringAsync()}");
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(7m);
// Unpost → возвращаем на склад.
using var unpost = await api.Http.PostAsJsonAsync($"/api/inventory/losses/{lossId}/unpost", new { });
unpost.IsSuccessStatusCode.Should().BeTrue();
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(10m);
}
[Fact]
public async Task Cannot_write_off_more_than_available()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"loss-over-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 200m, RandomBarcode());
// 2 шт на остатке.
var enter = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
notes = "seed-small", lines = new[] { new { productId, quantity = 2m, unitCost = 50m } },
});
enter.EnsureSuccessStatusCode();
var enterId = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{enterId}/post", new { })).EnsureSuccessStatusCode();
// Списать 5 шт — больше чем есть.
var loss = await api.Http.PostAsJsonAsync("/api/inventory/losses", new
{
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId, reason = 0,
notes = "over", lines = new[] { new { productId, quantity = 5m, unitCost = 50m } },
});
loss.EnsureSuccessStatusCode();
var lossId = (await loss.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
using var post = await api.Http.PostAsJsonAsync($"/api/inventory/losses/{lossId}/post", new { });
((int)post.StatusCode).Should().Be(409);
// Остаток не должен измениться.
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(2m);
}
[Fact]
public async Task Tenant_isolation_loss()
{
var a = new ApiActor(_factory.CreateClient());
var b = new ApiActor(_factory.CreateClient());
await a.SignupAndLoginAsync($"loss-iso-a-{Guid.NewGuid():N}");
await b.SignupAndLoginAsync($"loss-iso-b-{Guid.NewGuid():N}");
var refsA = await a.LoadRefsAsync();
var productA = await a.CreateProductAsync(refsA, $"Prod-{Guid.NewGuid():N}", 100m, RandomBarcode());
var resp = await a.Http.PostAsJsonAsync("/api/inventory/losses", new
{
date = DateTime.UtcNow, storeId = refsA.StoreId, currencyId = refsA.CurrencyId, reason = 0,
notes = "iso", lines = new[] { new { productId = productA, quantity = 1m, unitCost = 10m } },
});
resp.EnsureSuccessStatusCode();
var lossId = (await resp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
var bList = await b.ListAsync("/api/inventory/losses?pageSize=200");
bList.Should().NotContain(x => x.GetProperty("id").GetString() == lossId);
using var direct = await b.Http.GetAsync($"/api/inventory/losses/{lossId}");
direct.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound);
}
}