Some checks are pending
Auto-tag / Create date-tag (push) Waiting to run
CI / Backend (.NET 8) (push) Waiting to run
CI / Web (React + Vite) (push) Waiting to run
CI / POS (WPF, Windows) (push) Waiting to run
Docker API / Build + push API (push) Waiting to run
Docker API / Deploy API on stage (push) Blocked by required conditions
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
Решение владельца 08.10: продавать/списывать в минус РАЗРЕШЕНО, но с постоянным предупреждением и требованием провести инвентаризацию. - Снята блокировка «недостаточно остатка» (409 oversell): розница, касса (POS-синк), опт-отгрузка, списание, перемещение, возврат поставщику, отмена приёмки. Деньги (минус счёта) — по-прежнему запрещены (другое правило). - NegativeStockAlert (сущность + миграция Phase39): создаётся/обновляется при уходе остатка < 0 (единая точка — StockService), авто-закрытие при возврате ≥ 0. Частичный уникальный индекс: один открытый алерт на (org, store, product). Причина: тип/номер документа/автор/время. - API /api/negative-stock: список (с подсказкой «возможно, не проведена приёмка П-…»), /count (для баннеров), /recount (черновик инвентаризации по минусам склада). Отчёт «Остатки» — фильтр negativeOnly. - Веб: постоянный баннер «N товаров в минусе — нужна инвентаризация» (сворачивается, крестиком не закрыть) + «Пересчитать эти товары»; в остатках — красная пометка и фильтр «Только минусы». - Тесты: integration 2/2 (минус разрешён → алерт → recount → авто-закрытие; фильтр); full-chain шаг 9/13 переписаны (oversell/отмена приёмки → проведено + предупреждение). Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
108 lines
5.7 KiB
C#
108 lines
5.7 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 NegativeStockTests
|
||
{
|
||
private readonly ApiFactory _factory;
|
||
public NegativeStockTests(ApiFactory factory) => _factory = factory;
|
||
|
||
private static string RandomBarcode()
|
||
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
|
||
|
||
private async Task SeedStockAsync(ApiActor api, ApiActor.Refs refs, string productId, decimal qty)
|
||
{
|
||
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 = productId, quantity = qty, 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();
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Loss_into_negative_is_allowed_creates_alert_recount_and_resolves_on_restock()
|
||
{
|
||
var api = new ApiActor(_factory.CreateClient());
|
||
await api.SignupAndLoginAsync($"neg-{Guid.NewGuid():N}");
|
||
var refs = await api.LoadRefsAsync();
|
||
var p = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||
|
||
// 5 шт на складе, минусов нет.
|
||
await SeedStockAsync(api, refs, p, 5m);
|
||
(await api.StockOfAsync(refs.StoreId, p)).Should().Be(5m);
|
||
var c0 = await api.GetJsonAsync("/api/negative-stock/count");
|
||
c0.GetProperty("total").GetInt32().Should().Be(0);
|
||
|
||
// Списание 8 → остаток −3. Списание в минус РАЗРЕШЕНО (решение владельца).
|
||
var loss = await api.Http.PostAsJsonAsync("/api/inventory/losses", new
|
||
{
|
||
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
|
||
reason = 0, notes = "тест минус",
|
||
lines = new[] { new { productId = p, quantity = 8m, unitCost = 50m } },
|
||
});
|
||
loss.EnsureSuccessStatusCode();
|
||
var lid = (await loss.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||
using var lpost = await api.Http.PostAsJsonAsync($"/api/inventory/losses/{lid}/post", new { });
|
||
lpost.IsSuccessStatusCode.Should().BeTrue($"списание в минус разрешено: {(int)lpost.StatusCode} {await lpost.Content.ReadAsStringAsync()}");
|
||
(await api.StockOfAsync(refs.StoreId, p)).Should().Be(-3m);
|
||
|
||
// Предупреждение создано.
|
||
var c1 = await api.GetJsonAsync("/api/negative-stock/count");
|
||
c1.GetProperty("total").GetInt32().Should().Be(1);
|
||
var alerts = await api.ListAsync("/api/negative-stock");
|
||
alerts.Should().ContainSingle();
|
||
alerts[0].GetProperty("quantity").GetDecimal().Should().Be(-3m);
|
||
alerts[0].GetProperty("productId").GetString().Should().Be(p);
|
||
alerts[0].GetProperty("causeDocType").GetString().Should().Be("loss");
|
||
|
||
// «Пересчитать эти товары» → черновик инвентаризации с этим товаром.
|
||
var rc = await api.Http.PostAsJsonAsync("/api/negative-stock/recount", new { storeId = refs.StoreId });
|
||
rc.EnsureSuccessStatusCode();
|
||
var rcj = await rc.Content.ReadFromJsonAsync<JsonElement>();
|
||
rcj.GetProperty("lines").GetInt32().Should().Be(1);
|
||
var invId = rcj.GetProperty("inventoryId").GetString();
|
||
var inv = await api.GetJsonAsync($"/api/inventory/inventories/{invId}");
|
||
inv.GetProperty("lines").EnumerateArray()
|
||
.Should().ContainSingle(l => l.GetProperty("productId").GetString() == p);
|
||
|
||
// Довоз 3 → остаток 0 → предупреждение закрылось автоматически.
|
||
await SeedStockAsync(api, refs, p, 3m);
|
||
(await api.StockOfAsync(refs.StoreId, p)).Should().Be(0m);
|
||
var c2 = await api.GetJsonAsync("/api/negative-stock/count");
|
||
c2.GetProperty("total").GetInt32().Should().Be(0);
|
||
(await api.ListAsync("/api/negative-stock")).Should().BeEmpty("минус закрыт — алерт resolved");
|
||
}
|
||
|
||
[Fact]
|
||
public async Task Negative_stock_report_filter_returns_only_negatives()
|
||
{
|
||
var api = new ApiActor(_factory.CreateClient());
|
||
await api.SignupAndLoginAsync($"neg-rep-{Guid.NewGuid():N}");
|
||
var refs = await api.LoadRefsAsync();
|
||
var p = await api.CreateProductAsync(refs, $"P-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||
|
||
await SeedStockAsync(api, refs, p, 2m);
|
||
var loss = await api.Http.PostAsJsonAsync("/api/inventory/losses", new
|
||
{
|
||
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
|
||
reason = 0, notes = "minus", lines = new[] { new { productId = p, quantity = 5m, unitCost = 50m } },
|
||
});
|
||
loss.EnsureSuccessStatusCode();
|
||
var lid = (await loss.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||
(await api.Http.PostAsJsonAsync($"/api/inventory/losses/{lid}/post", new { })).EnsureSuccessStatusCode();
|
||
|
||
var neg = await api.GetJsonAsync("/api/inventory/stock?negativeOnly=true&pageSize=200");
|
||
var items = neg.GetProperty("items").EnumerateArray().ToList();
|
||
items.Should().NotBeEmpty();
|
||
items.Should().OnlyContain(x => x.GetProperty("quantity").GetDecimal() < 0);
|
||
}
|
||
}
|