food-market/tests/food-market.IntegrationTests/Support/ApiActor.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

195 lines
9.9 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.Headers;
using System.Net.Http.Json;
using System.Text.Json;
namespace foodmarket.IntegrationTests.Support;
/// <summary>Тонкая обёртка над HttpClient для интеграционных тестов: логин,
/// JSON-хелперы, выборка справочников орг-бутстрапа. Один экземпляр = один
/// «актор» (свой токен/орг).</summary>
public sealed class ApiActor
{
public HttpClient Http { get; }
public ApiActor(HttpClient http) => Http = http;
public void UseToken(string token)
=> Http.DefaultRequestHeaders.Authorization = new AuthenticationHeaderValue("Bearer", token);
// ── Auth ────────────────────────────────────────────────────────────────
public Task<HttpResponseMessage> SignupAsync(string email, string password, string orgName, string phone = "+77001234567")
=> Http.PostAsJsonAsync("/api/auth/signup",
new { email, password, organizationName = orgName, phone, plan = (string?)null });
public async Task<string> TokenAsync(string email, string password)
{
using var resp = await Http.PostAsync("/connect/token", new FormUrlEncodedContent(new Dictionary<string, string>
{
["grant_type"] = "password",
["username"] = email,
["password"] = password,
["client_id"] = "food-market-web",
["scope"] = "openid profile email roles api offline_access",
}));
resp.EnsureSuccessStatusCode();
return (await resp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("access_token").GetString()!;
}
/// <summary>Регистрирует орг, логинит её администратора и проставляет токен.
/// Возвращает email/пароль администратора.</summary>
public async Task<(string Email, string Password)> SignupAndLoginAsync(string slug)
{
var email = $"{slug}@example.kz";
const string password = "Passw0rd!";
(await SignupAsync(email, password, $"Org {slug}")).EnsureSuccessStatusCode();
UseToken(await TokenAsync(email, password));
return (email, password);
}
// ── JSON helpers ──────────────────────────────────────────────────────────
public async Task<JsonElement> GetJsonAsync(string url)
{
using var resp = await Http.GetAsync(url);
resp.EnsureSuccessStatusCode();
return await resp.Content.ReadFromJsonAsync<JsonElement>();
}
/// <summary>items[] из PagedResult (или сам массив, если endpoint отдаёт список).</summary>
public async Task<List<JsonElement>> ListAsync(string url)
{
var json = await GetJsonAsync(url);
var arr = json.ValueKind == JsonValueKind.Array ? json
: json.TryGetProperty("items", out var items) ? items
: default;
return arr.ValueKind == JsonValueKind.Array ? arr.EnumerateArray().ToList() : new();
}
public async Task<string> FirstIdAsync(string url, Func<JsonElement, bool>? match = null)
{
var list = await ListAsync(url);
var hit = match is null ? list.FirstOrDefault() : list.FirstOrDefault(match);
return hit.ValueKind == JsonValueKind.Object ? hit.GetProperty("id").GetString()! : throw new InvalidOperationException($"No item in {url}");
}
// ── Org bootstrap refs ─────────────────────────────────────────────────────
public async Task<Refs> LoadRefsAsync()
{
var unit = await FirstIdAsync("/api/catalog/units-of-measure?pageSize=50");
var group = await FirstIdAsync("/api/catalog/product-groups?pageSize=200");
var store = await FirstIdAsync("/api/catalog/stores?pageSize=200");
var currency = await FirstIdAsync("/api/catalog/currencies?pageSize=50",
e => e.GetProperty("code").GetString() == "KZT");
// Системный/розничный тип цены.
var priceType = await FirstIdAsync("/api/catalog/price-types?pageSize=200",
e => e.TryGetProperty("isSystem", out var s) && s.GetBoolean());
return new Refs(unit, group, store, currency, priceType);
}
public async Task<string> CreateCounterpartyAsync(string name)
{
var resp = await Http.PostAsJsonAsync("/api/catalog/counterparties", new
{
name,
legalName = name,
type = 0,
bin = "987654321098",
iin = (string?)null,
taxNumber = (string?)null,
countryId = (string?)null,
address = "Алматы",
phone = "+77003332211",
email = "cp@example.kz",
bankName = (string?)null,
bankAccount = (string?)null,
bik = (string?)null,
contactPerson = "Иванов",
notes = (string?)null,
});
resp.EnsureSuccessStatusCode();
return (await resp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
}
public async Task<string> CreateProductAsync(Refs refs, string name, decimal retailPrice, string barcode)
{
var resp = await Http.PostAsJsonAsync("/api/catalog/products", new
{
name,
article = (string?)null,
description = (string?)null,
unitOfMeasureId = refs.UnitId,
vat = 12,
vatEnabled = true,
productGroupId = refs.GroupId,
defaultSupplierId = (string?)null,
countryOfOriginId = (string?)null,
isService = false,
packaging = 0,
isMarked = false,
minStock = (decimal?)null,
maxStock = (decimal?)null,
referencePrice = retailPrice * 0.7m,
purchaseCurrencyId = refs.CurrencyId,
imageUrl = (string?)null,
prices = new[] { new { priceTypeId = refs.PriceTypeId, amount = retailPrice, currencyId = refs.CurrencyId } },
barcodes = new[] { new { code = barcode, type = 0, isPrimary = true } },
});
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode)
throw new InvalidOperationException($"CreateProduct failed {(int)resp.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.GetProperty("id").GetString()!;
}
// ── Money module helpers (Sprint 33) ───────────────────────────────────
public async Task<string> CreateRetailPointAsync(string storeId, string name)
{
var resp = await Http.PostAsJsonAsync("/api/catalog/retail-points", new { name, code = (string?)null, storeId });
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode) throw new InvalidOperationException($"CreateRetailPoint failed {(int)resp.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.GetProperty("id").GetString()!;
}
/// <summary>Создаёт счёт/кассу. kind: 1=Cash,2=Safe,3=Bank,4=Acquiring.</summary>
public async Task<string> CreateMoneyAccountAsync(string name, int kind, string currencyId,
string? storeId = null, decimal openingBalance = 0m, string? accountNumber = null)
{
var resp = await Http.PostAsJsonAsync("/api/money/accounts", new
{
name, kind, bankName = (string?)null, accountNumber,
storeId, currencyId, openingBalance,
openingDate = DateTime.UtcNow, isActive = true, isDefault = false,
});
var body = await resp.Content.ReadAsStringAsync();
if (!resp.IsSuccessStatusCode) throw new InvalidOperationException($"CreateMoneyAccount failed {(int)resp.StatusCode}: {body}");
return JsonDocument.Parse(body).RootElement.GetProperty("id").GetString()!;
}
/// <summary>Создаёт и проводит приёмку, возвращает (supplyId, total).</summary>
public async Task<(string Id, decimal Total)> CreatePostedSupplyAsync(Refs refs, string supplierId,
string productId, decimal qty, decimal unitPrice)
{
var draft = await Http.PostAsJsonAsync("/api/purchases/supplies", new
{
date = DateTime.UtcNow, supplierId, storeId = refs.StoreId, currencyId = refs.CurrencyId,
notes = (string?)null, lines = new[] { new { productId, quantity = qty, unitPrice } },
});
draft.EnsureSuccessStatusCode();
var supplyId = (await draft.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
using var post = await Http.PostAsJsonAsync($"/api/purchases/supplies/{supplyId}/post", new { });
post.EnsureSuccessStatusCode();
return (supplyId, qty * unitPrice);
}
public async Task<decimal> StockOfAsync(string storeId, string productId)
{
// Остаток товара на конкретном складе: фильтруем по складу (без storeId
// список отдаёт агрегат «Все склады» с storeId = Guid.Empty).
var list = await ListAsync($"/api/inventory/stock?productId={productId}&storeId={storeId}&includeZero=true&pageSize=200");
var row = list.FirstOrDefault(i =>
i.GetProperty("productId").GetString() == productId &&
i.GetProperty("storeId").GetString() == storeId);
return row.ValueKind == JsonValueKind.Object ? row.GetProperty("quantity").GetDecimal() : 0m;
}
public sealed record Refs(string UnitId, string GroupId, string StoreId, string CurrencyId, string PriceTypeId);
}