food-market/tests/food-market.IntegrationTests/MobileApiTests.cs
nns 193ba49ecb fix(stock): «показать нулевые» и фильтр по группе показывают товары без прихода
Остатки строились ОТ таблицы Stocks (JOIN Products), поэтому товары без строки
Stock (ни разу не приходованные) не попадали в список вообще, а includeZero лишь
снимал фильтр Quantity!=0 у существующих строк. То же в отчёте «Остатки на дату»,
в экспорте и в фильтре по группе (iOS получал total 0).

- GetStock/ExportStock: выборка ОТ Products (не-услуги, не-архивные) с остатком
  коррелированным подзапросом (товар без Stock = 0). Склад выбран → строка склада;
  без склада → агрегат «Все склады». Нулевые видны при includeZero; negativeOnly и
  пагинация/сортировка/поиск/экспорт работают и для нулевых. Остаток/ед./штрихкод
  дотягиваются batch-запросом по id страницы (EF8 не переводит подзапросы в SELECT+ORDER BY).
- Отчёт «Остатки на дату»: includeZero добавляет товары без движений (Qty 0);
  фильтр по группе теперь с вложенностью.
- groupId/productGroupId — синонимы с вложенными группами на всех списках остатков
  и в каталоге (List + Export). Раньше catalog?productGroupId молча игнорировался.
- Штрихкод рядом с артикулом в списке/экспорте — различать одноимённые товары
  (4× «BIOBLAS шампунь» с разными артикулами из МойСклада — не удалять).
- Demo (30 894 товара): без флага 12, includeZero → 30 894, <500мс/страница.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-10-09 09:44:50 +05:00

212 lines
13 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;
using System.Net.Http.Json;
using System.Text.Json;
using FluentAssertions;
using foodmarket.IntegrationTests.Support;
using Xunit;
namespace foodmarket.IntegrationTests;
/// <summary>Доработки сервера для iOS «FM Склад»: фильтр остатков по группе
/// (вложенной), добавление штрихкода, права в /api/me, весовые этикетки.</summary>
[Collection(ApiCollection.Name)]
public class MobileApiTests
{
private readonly ApiFactory _factory;
public MobileApiTests(ApiFactory factory) => _factory = factory;
private static string RandomBarcode()
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
private static async Task<string> CreateGroupAsync(ApiActor api, string name, string? parentId)
{
var resp = await api.Http.PostAsJsonAsync("/api/catalog/product-groups", new { name, parentId });
var body = await resp.Content.ReadAsStringAsync();
resp.IsSuccessStatusCode.Should().BeTrue($"create group: {(int)resp.StatusCode} {body}");
return JsonDocument.Parse(body).RootElement.GetProperty("id").GetString()!;
}
private static async Task<string> CreateProductInGroupAsync(
ApiActor api, ApiActor.Refs refs, string name, decimal price, string barcode, string groupId)
{
var resp = await api.Http.PostAsJsonAsync("/api/catalog/products", new
{
name, article = (string?)null, unitOfMeasureId = refs.UnitId, vat = 12, vatEnabled = true,
productGroupId = groupId, isService = false, packaging = 0, isMarked = false,
referencePrice = price * 0.7m, purchaseCurrencyId = refs.CurrencyId,
prices = new[] { new { priceTypeId = refs.PriceTypeId, amount = price, currencyId = refs.CurrencyId } },
barcodes = new[] { new { code = barcode, type = 0, isPrimary = true } },
});
var body = await resp.Content.ReadAsStringAsync();
resp.IsSuccessStatusCode.Should().BeTrue($"create product: {(int)resp.StatusCode} {body}");
return JsonDocument.Parse(body).RootElement.GetProperty("id").GetString()!;
}
private static 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, 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();
}
// ── 1. Фильтр остатков по группе (с вложенными) ────────────────────────
[Fact]
public async Task Stock_filter_by_group_includes_nested_groups()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"mob-grp-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var parent = await CreateGroupAsync(api, $"Напитки-{Guid.NewGuid():N}", null);
var child = await CreateGroupAsync(api, $"Соки-{Guid.NewGuid():N}", parent);
var pInParent = await CreateProductInGroupAsync(api, refs, $"Вода-{Guid.NewGuid():N}", 100m, RandomBarcode(), parent);
var pInChild = await CreateProductInGroupAsync(api, refs, $"Сок-{Guid.NewGuid():N}", 200m, RandomBarcode(), child);
await SeedStockAsync(api, refs, pInParent, 5m);
await SeedStockAsync(api, refs, pInChild, 7m);
// Фильтр по родителю → оба товара (вложенность).
var byParent = await api.Http.GetFromJsonAsync<JsonElement>($"/api/inventory/stock?groupId={parent}&pageSize=100");
var parentIds = byParent.GetProperty("items").EnumerateArray()
.Select(x => x.GetProperty("productId").GetString()).ToHashSet();
parentIds.Should().Contain(pInParent);
parentIds.Should().Contain(pInChild, "вложенная группа входит в фильтр родителя");
// Фильтр по дочерней → только её товар.
var byChild = await api.Http.GetFromJsonAsync<JsonElement>($"/api/inventory/stock?groupId={child}&pageSize=100");
var childIds = byChild.GetProperty("items").EnumerateArray()
.Select(x => x.GetProperty("productId").GetString()).ToHashSet();
childIds.Should().Contain(pInChild);
childIds.Should().NotContain(pInParent);
}
// ── 1b. «Показать нулевые»: товары без прихода тоже видны ──────────────
[Fact]
public async Task Stock_includeZero_shows_products_never_received()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"mob-zero-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var withStock = await api.CreateProductAsync(refs, $"Приход-{Guid.NewGuid():N}", 100m, RandomBarcode());
var neverReceived = await api.CreateProductAsync(refs, $"Нольный-{Guid.NewGuid():N}", 150m, RandomBarcode());
await SeedStockAsync(api, refs, withStock, 5m);
// Без includeZero — только товар с остатком.
var def = await api.Http.GetFromJsonAsync<JsonElement>("/api/inventory/stock?pageSize=500");
var defIds = def.GetProperty("items").EnumerateArray()
.Select(x => x.GetProperty("productId").GetString()).ToHashSet();
defIds.Should().Contain(withStock);
defIds.Should().NotContain(neverReceived, "без includeZero товар без прихода не показывается");
// С includeZero=true — виден и товар без прихода, с quantity 0 и штрихкодом.
var all = await api.Http.GetFromJsonAsync<JsonElement>("/api/inventory/stock?includeZero=true&pageSize=500");
var zeroRow = all.GetProperty("items").EnumerateArray()
.FirstOrDefault(x => x.GetProperty("productId").GetString() == neverReceived);
zeroRow.ValueKind.Should().NotBe(JsonValueKind.Undefined, "товар без прихода виден при includeZero");
zeroRow.GetProperty("quantity").GetDecimal().Should().Be(0m);
zeroRow.GetProperty("barcode").GetString().Should().NotBeNullOrEmpty("штрихкод отдаётся для различения дублей");
}
// ── 1c. Фильтр по группе + нулевые (корневая причина iOS-бага) ─────────
[Fact]
public async Task Stock_group_filter_with_includeZero_shows_group_products_without_stock()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"mob-gz-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var grp = await CreateGroupAsync(api, $"Группа-{Guid.NewGuid():N}", null);
var p = await CreateProductInGroupAsync(api, refs, $"БезПрихода-{Guid.NewGuid():N}", 100m, RandomBarcode(), grp);
// Остаток НЕ заводим.
// Без includeZero — группа «пустая».
var def = await api.Http.GetFromJsonAsync<JsonElement>($"/api/inventory/stock?groupId={grp}&pageSize=100");
def.GetProperty("total").GetInt32().Should().Be(0);
// С includeZero — товар группы виден с нулём. И productGroupId — синоним groupId.
foreach (var param in new[] { $"groupId={grp}", $"productGroupId={grp}" })
{
var all = await api.Http.GetFromJsonAsync<JsonElement>($"/api/inventory/stock?{param}&includeZero=true&pageSize=100");
var ids = all.GetProperty("items").EnumerateArray()
.Select(x => x.GetProperty("productId").GetString()).ToHashSet();
ids.Should().Contain(p, $"параметр {param} должен фильтровать по группе и показывать нулевые");
}
}
// ── 2. Добавление штрихкода к товару ───────────────────────────────────
[Fact]
public async Task Add_barcode_creates_and_rejects_duplicate()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"mob-bc-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
var p1 = await api.CreateProductAsync(refs, $"A-{Guid.NewGuid():N}", 100m, RandomBarcode());
var p2 = await api.CreateProductAsync(refs, $"B-{Guid.NewGuid():N}", 100m, RandomBarcode());
var newCode = RandomBarcode();
// Добавляем новый код к p1.
using var add = await api.Http.PostAsJsonAsync($"/api/catalog/products/{p1}/barcodes", new { code = newCode, kind = "ean13" });
add.StatusCode.Should().Be(HttpStatusCode.Created);
var created = await add.Content.ReadFromJsonAsync<JsonElement>();
created.GetProperty("code").GetString().Should().Be(newCode);
created.GetProperty("type").GetString().Should().Be("Ean13");
// Повтор того же кода тому же товару → идемпотентно 200.
using var again = await api.Http.PostAsJsonAsync($"/api/catalog/products/{p1}/barcodes", new { code = newCode, kind = (string?)null });
again.StatusCode.Should().Be(HttpStatusCode.OK);
// Тот же код другому товару → 409 с названием владельца.
using var dup = await api.Http.PostAsJsonAsync($"/api/catalog/products/{p2}/barcodes", new { code = newCode, kind = (string?)null });
dup.StatusCode.Should().Be(HttpStatusCode.Conflict);
var err = await dup.Content.ReadFromJsonAsync<JsonElement>();
err.GetProperty("code").GetString().Should().Be("barcode-duplicate");
err.GetProperty("error").GetString().Should().Contain("уже у товара");
// Пустой код → 400.
using var bad = await api.Http.PostAsJsonAsync($"/api/catalog/products/{p1}/barcodes", new { code = "", kind = (string?)null });
bad.StatusCode.Should().Be(HttpStatusCode.BadRequest);
}
// ── 3. Права текущего пользователя в /api/me ───────────────────────────
[Fact]
public async Task Me_returns_effective_permissions()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"mob-me-{Guid.NewGuid():N}");
var me = await api.Http.GetFromJsonAsync<JsonElement>("/api/me");
me.GetProperty("roles").EnumerateArray().Select(r => r.GetString()).Should().Contain("Admin");
var perms = me.GetProperty("permissions");
perms.GetProperty("ProductsEdit").GetBoolean().Should().BeTrue();
perms.GetProperty("StocksView").GetBoolean().Should().BeTrue();
// Новая орг по умолчанию в простом режиме → advanced-only скрыты даже у Admin.
me.GetProperty("advancedMode").GetBoolean().Should().BeFalse();
perms.GetProperty("DemandsView").GetBoolean().Should().BeFalse();
perms.GetProperty("TransferEdit").GetBoolean().Should().BeFalse();
}
// ── 5. Весовые этикетки в поиске по штрихкоду ──────────────────────────
[Fact]
public async Task ByBarcode_parses_weighted_label_returns_product_and_quantity()
{
var api = new ApiActor(_factory.CreateClient());
await api.SignupAndLoginAsync($"mob-w-{Guid.NewGuid():N}");
var refs = await api.LoadRefsAsync();
// PLU = 12345 хранится как короткий штрихкод товара.
var pid = await api.CreateProductAsync(refs, $"Яблоки-{Guid.NewGuid():N}", 690m, "12345");
// Весовая этикетка: 21 | 12345 | 01500 | K → вес 1.500 кг.
var resp = await api.Http.GetFromJsonAsync<JsonElement>("/api/catalog/products/by-barcode/2112345015007");
resp.GetProperty("weighted").GetBoolean().Should().BeTrue();
resp.GetProperty("valueKind").GetString().Should().Be("WeightKg");
resp.GetProperty("quantity").GetDecimal().Should().Be(1.500m);
resp.GetProperty("product").GetProperty("id").GetString().Should().Be(pid);
}
}