feat(stock): фильтры остатков «В наличии / Нулевые / В минусе» (states, объединение ИЛИ)
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
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
Поручение владельца (задача 11): галочки состояния остатка объединяются (ИЛИ), группа/склад/поиск сужают (И). Пусто → positive+negative (нулевые скрыты). - API StockController: параметр states (positive|zero|negative; через запятую или повтором) на /api/inventory/stock и /stock/export. Один WHERE: (positive И qty>0) ИЛИ (zero И qty=0) ИЛИ (negative И qty<0) — по остатку (COALESCE подзапрос; товар без строки Stock = 0). total/пагинация/сортировка корректны. Старые includeZero/negativeOnly оставлены для совместимости (includeZero ≡ все три; negativeOnly ≡ negative); states приоритетнее. - Веб-админка StockPage: три галочки «В наличии» (>0), «Нулевые» (=0), «В минусе» (<0) вместо прежних двух; состояние в URL (states=…), экспорт учитывает галочки. Ссылки NegativeStockBanner/OverdueNegativeStockBlock/NotificationCenter на минусовые → states=negative. - Интеграционный тест: сочетания (zero; negative; positive; zero+negative; +группа; все три = весь каталог; пусто = positive+negative; со складом) и числовые тождества (positive+negative=пусто; positive+zero+negative=весь каталог) + совместимость старых флагов. 1/1 зелёный (EF8 переводит предикат). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
parent
8371ef6683
commit
d49b586904
|
|
@ -27,6 +27,7 @@ public record StockRow(
|
|||
[FromQuery] Guid? groupId,
|
||||
[FromQuery] Guid? productGroupId,
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] List<string>? states = null,
|
||||
[FromQuery] bool includeZero = false,
|
||||
[FromQuery] bool negativeOnly = false,
|
||||
[FromQuery] int page = 1,
|
||||
|
|
@ -35,7 +36,8 @@ public record StockRow(
|
|||
[FromQuery] string? order = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var products = await FilteredProductsAsync(storeId, productId, groupId ?? productGroupId, search, includeZero, negativeOnly, ct);
|
||||
var (pos, zero, neg) = ResolveStates(states, includeZero, negativeOnly);
|
||||
var products = await FilteredProductsAsync(storeId, productId, groupId ?? productGroupId, search, pos, zero, neg, ct);
|
||||
var total = await products.CountAsync(ct);
|
||||
|
||||
var desc = string.Equals(order, "desc", StringComparison.OrdinalIgnoreCase);
|
||||
|
|
@ -68,7 +70,7 @@ public record StockRow(
|
|||
/// чтобы не тащить подзапросы в SELECT (EF8 их не переводит вместе с ORDER BY).</summary>
|
||||
private async Task<IQueryable<Domain.Catalog.Product>> FilteredProductsAsync(
|
||||
Guid? storeId, Guid? productId, Guid? groupId, string? search,
|
||||
bool includeZero, bool negativeOnly, CancellationToken ct)
|
||||
bool pos, bool zero, bool neg, CancellationToken ct)
|
||||
{
|
||||
var products = _db.Products.Where(p => !p.IsService && !p.IsArchived);
|
||||
if (productId.HasValue) products = products.Where(p => p.Id == productId.Value);
|
||||
|
|
@ -85,26 +87,45 @@ public record StockRow(
|
|||
|| p.Barcodes.Any(b => EF.Functions.ILike(b.Code, term)));
|
||||
}
|
||||
|
||||
// Остаток товара как скалярный подзапрос с COALESCE(...,0). Фильтр по нему в WHERE
|
||||
// EF8 переводит (ORDER BY/SELECT по тому же выражению — нет, поэтому там in-memory).
|
||||
// Фильтр по состоянию остатка — ОДНИМ WHERE: объединение (ИЛИ) отмеченных состояний.
|
||||
// Остаток = скалярный подзапрос с COALESCE(...,0) (товар без строки Stock = 0).
|
||||
// pos/zero/neg — булевы, EF8 переводит их в параметры (@pos AND qty>0) OR ... .
|
||||
// Группа/склад/поиск сужают (И), галочки состояния объединяются (ИЛИ).
|
||||
var sid = storeId ?? Guid.Empty;
|
||||
if (storeId.HasValue)
|
||||
{
|
||||
if (negativeOnly)
|
||||
products = products.Where(p => (_db.Stocks.Where(s => s.ProductId == p.Id && s.StoreId == sid).Select(s => (decimal?)s.Quantity).FirstOrDefault() ?? 0m) < 0m);
|
||||
else if (!includeZero)
|
||||
products = products.Where(p => (_db.Stocks.Where(s => s.ProductId == p.Id && s.StoreId == sid).Select(s => (decimal?)s.Quantity).FirstOrDefault() ?? 0m) != 0m);
|
||||
}
|
||||
products = products.Where(p =>
|
||||
(pos && (_db.Stocks.Where(s => s.ProductId == p.Id && s.StoreId == sid).Select(s => (decimal?)s.Quantity).FirstOrDefault() ?? 0m) > 0m)
|
||||
|| (zero && (_db.Stocks.Where(s => s.ProductId == p.Id && s.StoreId == sid).Select(s => (decimal?)s.Quantity).FirstOrDefault() ?? 0m) == 0m)
|
||||
|| (neg && (_db.Stocks.Where(s => s.ProductId == p.Id && s.StoreId == sid).Select(s => (decimal?)s.Quantity).FirstOrDefault() ?? 0m) < 0m));
|
||||
else
|
||||
{
|
||||
if (negativeOnly)
|
||||
products = products.Where(p => (_db.Stocks.Where(s => s.ProductId == p.Id).Sum(s => (decimal?)s.Quantity) ?? 0m) < 0m);
|
||||
else if (!includeZero)
|
||||
products = products.Where(p => (_db.Stocks.Where(s => s.ProductId == p.Id).Sum(s => (decimal?)s.Quantity) ?? 0m) != 0m);
|
||||
}
|
||||
products = products.Where(p =>
|
||||
(pos && (_db.Stocks.Where(s => s.ProductId == p.Id).Sum(s => (decimal?)s.Quantity) ?? 0m) > 0m)
|
||||
|| (zero && (_db.Stocks.Where(s => s.ProductId == p.Id).Sum(s => (decimal?)s.Quantity) ?? 0m) == 0m)
|
||||
|| (neg && (_db.Stocks.Where(s => s.ProductId == p.Id).Sum(s => (decimal?)s.Quantity) ?? 0m) < 0m));
|
||||
return products;
|
||||
}
|
||||
|
||||
/// <summary>Разбор набора состояний остатка. states (positive|zero|negative, через запятую
|
||||
/// или повтором) приоритетнее старых флагов. Пусто → по умолчанию positive+negative
|
||||
/// (товары с остатком, нулевые скрыты). Совместимость: includeZero=true ≡ все три;
|
||||
/// negativeOnly=true ≡ только negative.</summary>
|
||||
internal static (bool pos, bool zero, bool neg) ResolveStates(
|
||||
IEnumerable<string>? states, bool includeZero, bool negativeOnly)
|
||||
{
|
||||
var set = new HashSet<string>(StringComparer.OrdinalIgnoreCase);
|
||||
if (states is not null)
|
||||
foreach (var raw in states)
|
||||
foreach (var tok in (raw ?? "").Split(',', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries))
|
||||
if (tok is "positive" or "zero" or "negative")
|
||||
set.Add(tok.ToLowerInvariant());
|
||||
|
||||
if (set.Count > 0)
|
||||
return (set.Contains("positive"), set.Contains("zero"), set.Contains("negative"));
|
||||
if (negativeOnly) return (false, false, true);
|
||||
if (includeZero) return (true, true, true);
|
||||
return (true, false, true); // по умолчанию: положительные + минусовые, нулевые скрыты
|
||||
}
|
||||
|
||||
/// <summary>Сортировка списка остатков. По количеству/резерву/доступному —
|
||||
/// ORDER BY по скалярному подзапросу остатка (SELECT остаётся простым → EF8 переводит).</summary>
|
||||
private IQueryable<Domain.Catalog.Product> OrderProducts(
|
||||
|
|
@ -181,10 +202,13 @@ private async Task<(Guid Sid, string StoreName)> StoreContextAsync(Guid? storeId
|
|||
public async Task<IActionResult> ExportStock(
|
||||
[FromQuery] Guid? storeId, [FromQuery] Guid? groupId, [FromQuery] Guid? productGroupId,
|
||||
[FromQuery] string? search,
|
||||
[FromQuery] bool includeZero = false, [FromQuery] string? format = null,
|
||||
[FromQuery] List<string>? states = null,
|
||||
[FromQuery] bool includeZero = false, [FromQuery] bool negativeOnly = false,
|
||||
[FromQuery] string? format = null,
|
||||
CancellationToken ct = default)
|
||||
{
|
||||
var products = await FilteredProductsAsync(storeId, null, groupId ?? productGroupId, search, includeZero, false, ct);
|
||||
var (pos, zero, neg) = ResolveStates(states, includeZero, negativeOnly);
|
||||
var products = await FilteredProductsAsync(storeId, null, groupId ?? productGroupId, search, pos, zero, neg, ct);
|
||||
var paged = await products.OrderBy(p => p.Name)
|
||||
.Select(p => new { p.Id, p.Name, p.Article, p.UnitOfMeasureId }).ToListAsync(ct);
|
||||
var (_, storeName) = await StoreContextAsync(storeId, ct);
|
||||
|
|
|
|||
|
|
@ -32,7 +32,7 @@ export function NegativeStockBanner() {
|
|||
navigate(`/inventory/inventories/${res.data.inventoryId}`)
|
||||
} catch {
|
||||
// покажем список, чтобы пользователь разобрался вручную
|
||||
navigate('/inventory/stock?negativeOnly=true')
|
||||
navigate('/inventory/stock?states=negative')
|
||||
} finally {
|
||||
setBusy(false)
|
||||
}
|
||||
|
|
@ -63,7 +63,7 @@ export function NegativeStockBanner() {
|
|||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => navigate('/inventory/stock?negativeOnly=true')}
|
||||
onClick={() => navigate('/inventory/stock?states=negative')}
|
||||
className="text-xs text-amber-800 dark:text-amber-300 underline hover:text-amber-900"
|
||||
>
|
||||
Список
|
||||
|
|
|
|||
|
|
@ -92,7 +92,7 @@ export function NotificationCenter() {
|
|||
navigate(`/inventory/inventories/${res.data.inventoryId}`)
|
||||
} catch {
|
||||
setOpen(false)
|
||||
navigate(n.link ?? '/inventory/stock?negativeOnly=true')
|
||||
navigate(n.link ?? '/inventory/stock?states=negative')
|
||||
} finally {
|
||||
setRecounting(null)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@ export function OverdueNegativeStockBlock() {
|
|||
const res = await api.post<{ inventoryId: string }>('/api/negative-stock/recount', { storeId })
|
||||
navigate(`/inventory/inventories/${res.data.inventoryId}`)
|
||||
} catch {
|
||||
navigate(`/inventory/stock?negativeOnly=true&storeId=${storeId}`)
|
||||
navigate(`/inventory/stock?states=negative&storeId=${storeId}`)
|
||||
} finally {
|
||||
setBusy(null)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -16,22 +16,48 @@ export function StockPage() {
|
|||
const stores = useStores()
|
||||
const [storeId, setStoreId] = useState('')
|
||||
const [search, setSearch] = useState('')
|
||||
const [includeZero, setIncludeZero] = useState(false)
|
||||
const [searchParams] = useSearchParams()
|
||||
const [negativeOnly, setNegativeOnly] = useState(searchParams.get('negativeOnly') === 'true')
|
||||
const [searchParams, setSearchParams] = useSearchParams()
|
||||
// Три галочки состояния остатка (объединяются ИЛИ). Инициализация из URL
|
||||
// (states=... или старый negativeOnly/includeZero). Ни одна не отмечена →
|
||||
// по умолчанию: товары с остатком (плюс и минус), нулевые скрыты.
|
||||
const initStates = (() => {
|
||||
const s = searchParams.get('states')
|
||||
if (s) { const set = s.split(','); return { pos: set.includes('positive'), zero: set.includes('zero'), neg: set.includes('negative') } }
|
||||
if (searchParams.get('negativeOnly') === 'true') return { pos: false, zero: false, neg: true }
|
||||
if (searchParams.get('includeZero') === 'true') return { pos: true, zero: true, neg: true }
|
||||
return { pos: false, zero: false, neg: false }
|
||||
})()
|
||||
const [pos, setPos] = useState(initStates.pos)
|
||||
const [zero, setZero] = useState(initStates.zero)
|
||||
const [neg, setNeg] = useState(initStates.neg)
|
||||
const [page, setPage] = useState(1)
|
||||
const [sortKey, setSortKey] = useState<string | null>(null)
|
||||
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
|
||||
const setSort = (key: string, order: SortOrder) => { setSortKey(key); setSortOrder(order); setPage(1) }
|
||||
|
||||
const checked = [pos && 'positive', zero && 'zero', neg && 'negative'].filter(Boolean) as string[]
|
||||
const statesCsv = checked.join(',')
|
||||
// Переключить галочку + отразить в URL (ссылку можно скопировать/поделиться).
|
||||
const toggle = (next: { pos?: boolean; zero?: boolean; neg?: boolean }) => {
|
||||
const np = next.pos ?? pos, nz = next.zero ?? zero, nn = next.neg ?? neg
|
||||
if (next.pos !== undefined) setPos(next.pos)
|
||||
if (next.zero !== undefined) setZero(next.zero)
|
||||
if (next.neg !== undefined) setNeg(next.neg)
|
||||
setPage(1)
|
||||
const csv = [np && 'positive', nz && 'zero', nn && 'negative'].filter(Boolean).join(',')
|
||||
const sp = new URLSearchParams(searchParams)
|
||||
sp.delete('negativeOnly'); sp.delete('includeZero')
|
||||
if (csv) sp.set('states', csv); else sp.delete('states')
|
||||
setSearchParams(sp, { replace: true })
|
||||
}
|
||||
|
||||
const { data, isLoading } = useQuery({
|
||||
queryKey: ['/api/inventory/stock', { storeId, search, includeZero, negativeOnly, page, sortKey, sortOrder }],
|
||||
queryKey: ['/api/inventory/stock', { storeId, search, statesCsv, page, sortKey, sortOrder }],
|
||||
queryFn: async () => {
|
||||
const params = new URLSearchParams({ page: String(page), pageSize: '50' })
|
||||
if (storeId) params.set('storeId', storeId)
|
||||
if (search) params.set('search', search)
|
||||
if (negativeOnly) params.set('negativeOnly', 'true')
|
||||
else if (includeZero) params.set('includeZero', 'true')
|
||||
if (statesCsv) params.set('states', statesCsv)
|
||||
if (sortKey) { params.set('sort', sortKey); params.set('order', sortOrder) }
|
||||
return (await api.get<PagedResult<StockRow>>(`/api/inventory/stock?${params}`)).data
|
||||
},
|
||||
|
|
@ -50,10 +76,11 @@ export function StockPage() {
|
|||
{stores.data?.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||
</Select>
|
||||
</div>
|
||||
<Checkbox label="Показать нулевые" checked={includeZero} onChange={(v) => { setIncludeZero(v); setPage(1) }} />
|
||||
<Checkbox label="Только минусы" checked={negativeOnly} onChange={(v) => { setNegativeOnly(v); setPage(1) }} />
|
||||
<Checkbox label="В наличии" checked={pos} onChange={(v) => toggle({ pos: v })} />
|
||||
<Checkbox label="Нулевые" checked={zero} onChange={(v) => toggle({ zero: v })} />
|
||||
<Checkbox label="В минусе" checked={neg} onChange={(v) => toggle({ neg: v })} />
|
||||
<SearchBar value={search} onChange={(v) => { setSearch(v); setPage(1) }} placeholder="По названию или артикулу…" />
|
||||
<ExportButton url="/api/inventory/stock/export" params={{ storeId, search, includeZero }} defaultName="stock" />
|
||||
<ExportButton url="/api/inventory/stock/export" params={{ storeId, search, states: statesCsv || undefined }} defaultName="stock" />
|
||||
</div>
|
||||
}
|
||||
footer={data && data.total > 0 && (
|
||||
|
|
@ -63,8 +90,8 @@ export function StockPage() {
|
|||
<DataTable
|
||||
rows={data?.items ?? []}
|
||||
isLoading={isLoading}
|
||||
empty={!includeZero && !negativeOnly
|
||||
? 'Нет товаров с остатком — включите «Показать нулевые», чтобы увидеть все товары'
|
||||
empty={checked.length === 0
|
||||
? 'Нет товаров с остатком — отметьте «Нулевые», чтобы увидеть товары с нулевым остатком'
|
||||
: 'Нет данных'}
|
||||
rowKey={(r) => `${r.productId}:${r.storeId}`}
|
||||
sortKey={sortKey}
|
||||
|
|
|
|||
154
tests/food-market.IntegrationTests/StockStateFilterTests.cs
Normal file
154
tests/food-market.IntegrationTests/StockStateFilterTests.cs
Normal file
|
|
@ -0,0 +1,154 @@
|
|||
using System.Net.Http.Json;
|
||||
using System.Text.Json;
|
||||
using FluentAssertions;
|
||||
using foodmarket.IntegrationTests.Support;
|
||||
using Xunit;
|
||||
|
||||
namespace foodmarket.IntegrationTests;
|
||||
|
||||
/// <summary>Задача 11: фильтры остатков «В наличии / Нулевые / В минусе».
|
||||
/// states (positive|zero|negative) объединяются ИЛИ; группа/склад сужают И.
|
||||
/// Пусто → positive+negative (нулевые скрыты). Проверяем сочетания и числовые
|
||||
/// тождества (positive+negative=пусто; positive+zero+negative=весь каталог).</summary>
|
||||
[Collection(ApiCollection.Name)]
|
||||
public class StockStateFilterTests
|
||||
{
|
||||
private readonly ApiFactory _factory;
|
||||
public StockStateFilterTests(ApiFactory factory) => _factory = factory;
|
||||
|
||||
private static string RandomBarcode()
|
||||
=> string.Concat(Enumerable.Range(0, 13).Select(_ => Random.Shared.Next(0, 10)));
|
||||
|
||||
private static async Task EnterAsync(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 id = (await enter.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||||
(await api.Http.PostAsJsonAsync($"/api/inventory/enters/{id}/post", new { })).EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private static async Task LossAsync(ApiActor api, ApiActor.Refs refs, string productId, decimal qty)
|
||||
{
|
||||
var loss = await api.Http.PostAsJsonAsync("/api/inventory/losses", new
|
||||
{
|
||||
date = DateTime.UtcNow, storeId = refs.StoreId, currencyId = refs.CurrencyId,
|
||||
reason = 0, notes = "seed-minus", lines = new[] { new { productId, quantity = qty, unitCost = 50m } },
|
||||
});
|
||||
loss.EnsureSuccessStatusCode();
|
||||
var id = (await loss.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString();
|
||||
(await api.Http.PostAsJsonAsync($"/api/inventory/losses/{id}/post", new { })).EnsureSuccessStatusCode();
|
||||
}
|
||||
|
||||
private static async Task<string> CreateGroupAsync(ApiActor api, string name)
|
||||
{
|
||||
var resp = await api.Http.PostAsJsonAsync("/api/catalog/product-groups", new { name, parentId = (string?)null });
|
||||
resp.EnsureSuccessStatusCode();
|
||||
return (await resp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
|
||||
}
|
||||
|
||||
private static async Task<string> CreateProductInGroupAsync(ApiActor api, ApiActor.Refs refs, string name, string groupId, string barcode)
|
||||
{
|
||||
var resp = await api.Http.PostAsJsonAsync("/api/catalog/products", new
|
||||
{
|
||||
name, article = (string?)null, description = (string?)null,
|
||||
unitOfMeasureId = refs.UnitId, vat = 12, vatEnabled = true,
|
||||
productGroupId = groupId, isService = false, packaging = 0, isMarked = false,
|
||||
referencePrice = 70m, purchaseCurrencyId = refs.CurrencyId,
|
||||
prices = new[] { new { priceTypeId = refs.PriceTypeId, amount = 100m, currencyId = refs.CurrencyId } },
|
||||
barcodes = new[] { new { code = barcode, type = 0, isPrimary = true } },
|
||||
});
|
||||
resp.EnsureSuccessStatusCode();
|
||||
return (await resp.Content.ReadFromJsonAsync<JsonElement>()).GetProperty("id").GetString()!;
|
||||
}
|
||||
|
||||
private async Task<(int total, HashSet<string> ids)> QueryAsync(ApiActor api, string qs)
|
||||
{
|
||||
var j = await api.GetJsonAsync($"/api/inventory/stock?{qs}&pageSize=500");
|
||||
var ids = j.GetProperty("items").EnumerateArray()
|
||||
.Select(x => x.GetProperty("productId").GetString()!).ToHashSet();
|
||||
return (j.GetProperty("total").GetInt32(), ids);
|
||||
}
|
||||
|
||||
[Fact]
|
||||
public async Task State_filters_union_and_group_narrows_and_totals_partition()
|
||||
{
|
||||
var api = new ApiActor(_factory.CreateClient());
|
||||
await api.SignupAndLoginAsync($"stk-{Guid.NewGuid():N}");
|
||||
var refs = await api.LoadRefsAsync();
|
||||
|
||||
// В группе по умолчанию: положительный, минусовой, нулевой (без строки Stock).
|
||||
var pPos = await api.CreateProductAsync(refs, $"POS-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||||
await EnterAsync(api, refs, pPos, 5m);
|
||||
var pNeg = await api.CreateProductAsync(refs, $"NEG-{Guid.NewGuid():N}", 100m, RandomBarcode());
|
||||
await EnterAsync(api, refs, pNeg, 2m); await LossAsync(api, refs, pNeg, 5m); // −3
|
||||
var pZero = await api.CreateProductAsync(refs, $"ZERO-{Guid.NewGuid():N}", 100m, RandomBarcode()); // нет остатка → 0
|
||||
|
||||
// В другой группе: ещё один минусовой (для проверки сужения по группе).
|
||||
var gOther = await CreateGroupAsync(api, $"G-{Guid.NewGuid():N}");
|
||||
var pOtherNeg = await CreateProductInGroupAsync(api, refs, $"ONEG-{Guid.NewGuid():N}", gOther, RandomBarcode());
|
||||
await EnterAsync(api, refs, pOtherNeg, 1m); await LossAsync(api, refs, pOtherNeg, 4m); // −3
|
||||
|
||||
// Контрольные остатки.
|
||||
(await api.StockOfAsync(refs.StoreId, pPos)).Should().Be(5m);
|
||||
(await api.StockOfAsync(refs.StoreId, pNeg)).Should().Be(-3m);
|
||||
(await api.StockOfAsync(refs.StoreId, pZero)).Should().Be(0m);
|
||||
|
||||
// Пусто → positive+negative (нулевые скрыты).
|
||||
var def = await QueryAsync(api, "");
|
||||
def.ids.Should().Contain(new[] { pPos, pNeg, pOtherNeg });
|
||||
def.ids.Should().NotContain(pZero);
|
||||
|
||||
// Только нулевые.
|
||||
var zero = await QueryAsync(api, "states=zero");
|
||||
zero.ids.Should().Contain(pZero);
|
||||
zero.ids.Should().NotContain(new[] { pPos, pNeg, pOtherNeg });
|
||||
|
||||
// Только минусовые.
|
||||
var neg = await QueryAsync(api, "states=negative");
|
||||
neg.ids.Should().Contain(new[] { pNeg, pOtherNeg });
|
||||
neg.ids.Should().NotContain(new[] { pPos, pZero });
|
||||
|
||||
// Только в наличии.
|
||||
var pos = await QueryAsync(api, "states=positive");
|
||||
pos.ids.Should().Contain(pPos);
|
||||
pos.ids.Should().NotContain(new[] { pNeg, pZero, pOtherNeg });
|
||||
|
||||
// Нулевые + минусовые (объединение).
|
||||
var zn = await QueryAsync(api, "states=zero,negative");
|
||||
zn.ids.Should().Contain(new[] { pZero, pNeg, pOtherNeg });
|
||||
zn.ids.Should().NotContain(pPos);
|
||||
|
||||
// Нулевые + минусовые ТОЛЬКО внутри группы по умолчанию (сужение по группе).
|
||||
var znGroup = await QueryAsync(api, $"states=zero,negative&groupId={refs.GroupId}");
|
||||
znGroup.ids.Should().Contain(new[] { pZero, pNeg });
|
||||
znGroup.ids.Should().NotContain(new[] { pPos, pOtherNeg });
|
||||
|
||||
// Минусовые внутри группы по умолчанию → только pNeg (pOtherNeg в другой группе).
|
||||
var negGroup = await QueryAsync(api, $"states=negative&groupId={refs.GroupId}");
|
||||
negGroup.ids.Should().Contain(pNeg);
|
||||
negGroup.ids.Should().NotContain(pOtherNeg);
|
||||
|
||||
// Все три = весь каталог.
|
||||
var all = await QueryAsync(api, "states=positive,zero,negative");
|
||||
all.ids.Should().Contain(new[] { pPos, pNeg, pZero, pOtherNeg });
|
||||
|
||||
// Числовые тождества.
|
||||
(pos.total + neg.total).Should().Be(def.total, "positive + negative = пусто (по умолчанию)");
|
||||
(pos.total + zero.total + neg.total).Should().Be(all.total, "positive + zero + negative = весь каталог");
|
||||
|
||||
// Совместимость старых флагов: negativeOnly ≡ negative; includeZero ≡ все три.
|
||||
var legacyNeg = await QueryAsync(api, "negativeOnly=true");
|
||||
legacyNeg.ids.Should().BeEquivalentTo(neg.ids);
|
||||
var legacyAll = await QueryAsync(api, "includeZero=true");
|
||||
legacyAll.total.Should().Be(all.total);
|
||||
|
||||
// Тот же фильтр со складом (другая ветка кода — по конкретному складу).
|
||||
var negStore = await QueryAsync(api, $"states=negative&storeId={refs.StoreId}");
|
||||
negStore.ids.Should().Contain(new[] { pNeg, pOtherNeg });
|
||||
negStore.ids.Should().NotContain(new[] { pPos, pZero });
|
||||
}
|
||||
}
|
||||
Loading…
Reference in a new issue