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>
118 lines
5.9 KiB
C#
118 lines
5.9 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 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);
|
||
}
|
||
}
|