Остатки строились ОТ таблицы 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>
195 lines
9.9 KiB
C#
195 lines
9.9 KiB
C#
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);
|
||
}
|