food-market/tests/food-market.IntegrationTests/NegativeStockTests.cs
nns 9ba1edb236
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
feat(stock): разрешение продажи в минус + предупреждения об отрицательных остатках
Решение владельца 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>
2026-10-09 02:18:29 +05:00

108 lines
5.7 KiB
C#
Raw 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 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);
}
}