Domain Demand+DemandLine - зеркалит RetailSale, но всегда с CustomerId
(обязателен, не nullable), способ оплаты DemandPayment с Credit
(постоплата = дебиторка), без RetailPoint/Cashier.
EF + миграция Phase8a_Demands (idempotent CREATE TABLE).
Контроллер api/sales/demands - CRUD + Post/Unpost. Post создаёт
StockMovement тип WholesaleSale с -Quantity; защита от ухода в минус
(409 со списком конфликтов). Unpost возвращает товар.
ApplyLines пишет в DbSet напрямую (не через nav-collection) и Update
использует ExecuteDelete для старых строк - тот же fix-паттерн что в
RetailSalesController (избегает DbUpdateConcurrency на client-side Id).
Permissions переиспользуют DemandsEdit/DemandsPost (уже в RolePermissions).
Метрики observability: food_market_documents_posted_total{type="demand"}
и documents_error_total{type="demand", reason="serialization"}.
Web: /sales/demands (list+edit) с AsyncSelect контрагентов, способом
оплаты включая Credit, PaidAmount-полем для дебиторки. Сайдбар:
"Оптовые отгрузки" в группе Продажи для Admin.
Тесты: 3 интеграционных (post снижает stock + unpost восстанавливает,
over-stock posting -> 409 без побочных эффектов, tenant-изоляция).
Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
111 lines
5.6 KiB
C#
111 lines
5.6 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 DemandPostUnpostTests
|
|
{
|
|
private readonly ApiFactory _factory;
|
|
public DemandPostUnpostTests(ApiFactory factory) => _factory = factory;
|
|
private static string RandomBarcode()
|
|
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
|
|
|
|
[Fact]
|
|
public async Task Post_decrements_stock_unpost_restores()
|
|
{
|
|
var api = new ApiActor(_factory.CreateClient());
|
|
await api.SignupAndLoginAsync($"dem-{Guid.NewGuid():N}");
|
|
var refs = await api.LoadRefsAsync();
|
|
var customerId = await api.CreateCounterpartyAsync($"Cust-{Guid.NewGuid():N}");
|
|
var p1 = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 200m, 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 = 20m, unitCost = 100m } },
|
|
});
|
|
enter.EnsureSuccessStatusCode();
|
|
var eid = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
|
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{eid}/post", new { })).EnsureSuccessStatusCode();
|
|
|
|
var demand = await api.Http.PostAsJsonAsync("/api/sales/demands", new
|
|
{
|
|
date = DateTime.UtcNow, customerId, storeId = refs.StoreId, currencyId = refs.CurrencyId,
|
|
payment = 2, // BankTransfer
|
|
paidAmount = 0m, notes = "test demand",
|
|
lines = new[] { new { productId = p1, quantity = 5m, unitPrice = 180m, discount = 0m, vatPercent = 12m } },
|
|
});
|
|
demand.EnsureSuccessStatusCode();
|
|
var did = (await demand.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
|
|
|
using var post = await api.Http.PostAsJsonAsync($"/api/sales/demands/{did}/post", new { });
|
|
post.IsSuccessStatusCode.Should().BeTrue($"post вернул {(int)post.StatusCode}: {await post.Content.ReadAsStringAsync()}");
|
|
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(15m);
|
|
|
|
using var unpost = await api.Http.PostAsJsonAsync($"/api/sales/demands/{did}/unpost", new { });
|
|
unpost.IsSuccessStatusCode.Should().BeTrue();
|
|
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(20m);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Cannot_post_when_stock_insufficient()
|
|
{
|
|
var api = new ApiActor(_factory.CreateClient());
|
|
await api.SignupAndLoginAsync($"dem-short-{Guid.NewGuid():N}");
|
|
var refs = await api.LoadRefsAsync();
|
|
var customerId = await api.CreateCounterpartyAsync($"Cust-{Guid.NewGuid():N}");
|
|
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 = 2m, 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 demand = await api.Http.PostAsJsonAsync("/api/sales/demands", new
|
|
{
|
|
date = DateTime.UtcNow, customerId, storeId = refs.StoreId, currencyId = refs.CurrencyId,
|
|
payment = 2, paidAmount = 0m, notes = "over",
|
|
lines = new[] { new { productId = p1, quantity = 5m, unitPrice = 100m, discount = 0m, vatPercent = 12m } },
|
|
});
|
|
demand.EnsureSuccessStatusCode();
|
|
var did = (await demand.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
|
using var post = await api.Http.PostAsJsonAsync($"/api/sales/demands/{did}/post", new { });
|
|
((int)post.StatusCode).Should().Be(409);
|
|
(await api.StockOfAsync(refs.StoreId, p1)).Should().Be(2m);
|
|
}
|
|
|
|
[Fact]
|
|
public async Task Tenant_isolation_demand()
|
|
{
|
|
var a = new ApiActor(_factory.CreateClient());
|
|
var b = new ApiActor(_factory.CreateClient());
|
|
await a.SignupAndLoginAsync($"dem-iso-a-{Guid.NewGuid():N}");
|
|
await b.SignupAndLoginAsync($"dem-iso-b-{Guid.NewGuid():N}");
|
|
var refsA = await a.LoadRefsAsync();
|
|
var customerA = await a.CreateCounterpartyAsync($"C-{Guid.NewGuid():N}");
|
|
var pA = await a.CreateProductAsync(refsA, $"PA-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
|
|
|
var demand = await a.Http.PostAsJsonAsync("/api/sales/demands", new
|
|
{
|
|
date = DateTime.UtcNow, customerId = customerA, storeId = refsA.StoreId, currencyId = refsA.CurrencyId,
|
|
payment = 2, paidAmount = 0m, notes = "iso",
|
|
lines = new[] { new { productId = pA, quantity = 1m, unitPrice = 10m, discount = 0m, vatPercent = 12m } },
|
|
});
|
|
demand.EnsureSuccessStatusCode();
|
|
var did = (await demand.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
|
|
|
var bList = await b.ListAsync("/api/sales/demands?pageSize=200");
|
|
bList.Should().NotContain(x => x.GetProperty("id").GetString() == did);
|
|
using var direct = await b.Http.GetAsync($"/api/sales/demands/{did}");
|
|
direct.StatusCode.Should().Be(System.Net.HttpStatusCode.NotFound);
|
|
}
|
|
}
|