Domain Enter+EnterLine (мирорит Supply, но без SupplierId и без cost rollup). EF-конфигурация, миграция Phase6a_Enters (idempotent CREATE TABLE). Контроллер api/inventory/enters: CRUD + Post/Unpost. Post создаёт StockMovement тип Enter; Unpost блокируется, если остаток ушёл бы в минус. Web: /inventory/enters (list + edit), пункт «Оприходования» в сайдбаре Admin/Storekeeper. Тесты: 4 интеграционных (post раздаёт stock, unpost откатывает, double post→409, tenant-изоляция A/B, unpost блокируется при минусе после продажи). Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
157 lines
7.4 KiB
C#
157 lines
7.4 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 EnterPostUnpostTests
|
|
{
|
|
private readonly ApiFactory _factory;
|
|
public EnterPostUnpostTests(ApiFactory factory) => _factory = factory;
|
|
|
|
private static string RandomBarcode()
|
|
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
|
|
|
|
[Fact]
|
|
public async Task Posting_enter_raises_stock_unpost_reverts()
|
|
{
|
|
var api = new ApiActor(_factory.CreateClient());
|
|
await api.SignupAndLoginAsync($"enter-{Guid.NewGuid():N}");
|
|
var refs = await api.LoadRefsAsync();
|
|
var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 200m, RandomBarcode());
|
|
|
|
// Draft enter: 7 шт по 50 (балансовая цена).
|
|
var draftResp = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
|
|
{
|
|
date = DateTime.UtcNow,
|
|
storeId = refs.StoreId,
|
|
currencyId = refs.CurrencyId,
|
|
notes = "начальный остаток",
|
|
lines = new[] { new { productId, quantity = 7m, unitCost = 50m } },
|
|
});
|
|
draftResp.EnsureSuccessStatusCode();
|
|
var enterId = (await draftResp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
|
|
|
// Стартовый остаток 0.
|
|
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(0m);
|
|
|
|
// Post → остаток 7. Cost не пересчитывается — оприходование не имеет «закупочной цены».
|
|
using var post = await api.Http.PostAsJsonAsync($"/api/inventory/enters/{enterId}/post", new { });
|
|
post.IsSuccessStatusCode.Should().BeTrue($"post вернул {(int)post.StatusCode}: {await post.Content.ReadAsStringAsync()}");
|
|
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(7m);
|
|
|
|
// Unpost → остаток обратно 0.
|
|
using var unpost = await api.Http.PostAsJsonAsync($"/api/inventory/enters/{enterId}/unpost", new { });
|
|
unpost.IsSuccessStatusCode.Should().BeTrue($"unpost вернул {(int)unpost.StatusCode}: {await unpost.Content.ReadAsStringAsync()}");
|
|
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(0m);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Double_post_is_rejected()
|
|
{
|
|
var api = new ApiActor(_factory.CreateClient());
|
|
await api.SignupAndLoginAsync($"enter-dbl-{Guid.NewGuid():N}");
|
|
var refs = await api.LoadRefsAsync();
|
|
var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
|
|
|
var draftResp = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
|
|
{
|
|
date = DateTime.UtcNow,
|
|
storeId = refs.StoreId,
|
|
currencyId = refs.CurrencyId,
|
|
notes = "dbl",
|
|
lines = new[] { new { productId, quantity = 2m, unitCost = 30m } },
|
|
});
|
|
draftResp.EnsureSuccessStatusCode();
|
|
var enterId = (await draftResp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
|
|
|
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{enterId}/post", new { }))
|
|
.IsSuccessStatusCode.Should().BeTrue();
|
|
using var second = await api.Http.PostAsJsonAsync($"/api/inventory/enters/{enterId}/post", new { });
|
|
((int)second.StatusCode).Should().Be(409);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Tenant_isolation_enter()
|
|
{
|
|
var a = new ApiActor(_factory.CreateClient());
|
|
var b = new ApiActor(_factory.CreateClient());
|
|
await a.SignupAndLoginAsync($"enter-iso-a-{Guid.NewGuid():N}");
|
|
await b.SignupAndLoginAsync($"enter-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/enters", new
|
|
{
|
|
date = DateTime.UtcNow,
|
|
storeId = refsA.StoreId,
|
|
currencyId = refsA.CurrencyId,
|
|
notes = "iso",
|
|
lines = new[] { new { productId = productA, quantity = 1m, unitCost = 10m } },
|
|
});
|
|
resp.EnsureSuccessStatusCode();
|
|
var enterId = (await resp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
|
|
|
// B список не показывает документ A.
|
|
var bList = await b.ListAsync("/api/inventory/enters?pageSize=200");
|
|
bList.Should().NotContain(x => x.GetProperty("id").GetString() == enterId);
|
|
|
|
// B напрямую → 404.
|
|
using var direct = await b.Http.GetAsync($"/api/inventory/enters/{enterId}");
|
|
direct.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Unpost_blocked_when_stock_would_go_negative()
|
|
{
|
|
var api = new ApiActor(_factory.CreateClient());
|
|
await api.SignupAndLoginAsync($"enter-neg-{Guid.NewGuid():N}");
|
|
var refs = await api.LoadRefsAsync();
|
|
var productId = await api.CreateProductAsync(refs, $"Prod-{Guid.NewGuid():N}", 200m, RandomBarcode());
|
|
|
|
// Оприходовали 5 шт.
|
|
var create = await api.Http.PostAsJsonAsync("/api/inventory/enters", new
|
|
{
|
|
date = DateTime.UtcNow,
|
|
storeId = refs.StoreId,
|
|
currencyId = refs.CurrencyId,
|
|
notes = "stock-guard",
|
|
lines = new[] { new { productId, quantity = 5m, unitCost = 100m } },
|
|
});
|
|
create.EnsureSuccessStatusCode();
|
|
var enterId = (await create.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
|
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{enterId}/post", new { }))
|
|
.IsSuccessStatusCode.Should().BeTrue();
|
|
|
|
// Списали 5 шт — оставшийся стартовый остаток = 0, при unpost ушёл бы в -5.
|
|
// Делаем розничную продажу 5 единиц и проводим.
|
|
// Сначала надо чтобы товар имел розничную цену — она задана при CreateProductAsync.
|
|
var sale = await api.Http.PostAsJsonAsync("/api/sales/retail", new
|
|
{
|
|
date = DateTime.UtcNow,
|
|
storeId = refs.StoreId,
|
|
retailPointId = (string?)null,
|
|
customerId = (string?)null,
|
|
currencyId = refs.CurrencyId,
|
|
payment = 0, // Cash
|
|
paidCash = 1000m,
|
|
paidCard = 0m,
|
|
lines = new[] { new { productId, quantity = 5m, unitPrice = 200m, discount = 0m, vatPercent = 12m } },
|
|
notes = "drain",
|
|
});
|
|
sale.EnsureSuccessStatusCode();
|
|
var saleId = (await sale.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
|
(await api.Http.PostAsJsonAsync($"/api/sales/retail/{saleId}/post", new { }))
|
|
.IsSuccessStatusCode.Should().BeTrue();
|
|
|
|
(await api.StockOfAsync(refs.StoreId, productId)).Should().Be(0m);
|
|
|
|
// Unpost оприходования должен быть отбит (остаток уйдёт в минус).
|
|
using var unpost = await api.Http.PostAsJsonAsync($"/api/inventory/enters/{enterId}/unpost", new { });
|
|
((int)unpost.StatusCode).Should().Be(409);
|
|
}
|
|
}
|