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? groupId,
|
||||||
[FromQuery] Guid? productGroupId,
|
[FromQuery] Guid? productGroupId,
|
||||||
[FromQuery] string? search,
|
[FromQuery] string? search,
|
||||||
|
[FromQuery] List<string>? states = null,
|
||||||
[FromQuery] bool includeZero = false,
|
[FromQuery] bool includeZero = false,
|
||||||
[FromQuery] bool negativeOnly = false,
|
[FromQuery] bool negativeOnly = false,
|
||||||
[FromQuery] int page = 1,
|
[FromQuery] int page = 1,
|
||||||
|
|
@ -35,7 +36,8 @@ public record StockRow(
|
||||||
[FromQuery] string? order = null,
|
[FromQuery] string? order = null,
|
||||||
CancellationToken ct = default)
|
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 total = await products.CountAsync(ct);
|
||||||
|
|
||||||
var desc = string.Equals(order, "desc", StringComparison.OrdinalIgnoreCase);
|
var desc = string.Equals(order, "desc", StringComparison.OrdinalIgnoreCase);
|
||||||
|
|
@ -68,7 +70,7 @@ public record StockRow(
|
||||||
/// чтобы не тащить подзапросы в SELECT (EF8 их не переводит вместе с ORDER BY).</summary>
|
/// чтобы не тащить подзапросы в SELECT (EF8 их не переводит вместе с ORDER BY).</summary>
|
||||||
private async Task<IQueryable<Domain.Catalog.Product>> FilteredProductsAsync(
|
private async Task<IQueryable<Domain.Catalog.Product>> FilteredProductsAsync(
|
||||||
Guid? storeId, Guid? productId, Guid? groupId, string? search,
|
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);
|
var products = _db.Products.Where(p => !p.IsService && !p.IsArchived);
|
||||||
if (productId.HasValue) products = products.Where(p => p.Id == productId.Value);
|
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)));
|
|| p.Barcodes.Any(b => EF.Functions.ILike(b.Code, term)));
|
||||||
}
|
}
|
||||||
|
|
||||||
// Остаток товара как скалярный подзапрос с COALESCE(...,0). Фильтр по нему в WHERE
|
// Фильтр по состоянию остатка — ОДНИМ WHERE: объединение (ИЛИ) отмеченных состояний.
|
||||||
// EF8 переводит (ORDER BY/SELECT по тому же выражению — нет, поэтому там in-memory).
|
// Остаток = скалярный подзапрос с COALESCE(...,0) (товар без строки Stock = 0).
|
||||||
|
// pos/zero/neg — булевы, EF8 переводит их в параметры (@pos AND qty>0) OR ... .
|
||||||
|
// Группа/склад/поиск сужают (И), галочки состояния объединяются (ИЛИ).
|
||||||
var sid = storeId ?? Guid.Empty;
|
var sid = storeId ?? Guid.Empty;
|
||||||
if (storeId.HasValue)
|
if (storeId.HasValue)
|
||||||
{
|
products = products.Where(p =>
|
||||||
if (negativeOnly)
|
(pos && (_db.Stocks.Where(s => s.ProductId == p.Id && s.StoreId == sid).Select(s => (decimal?)s.Quantity).FirstOrDefault() ?? 0m) > 0m)
|
||||||
products = products.Where(p => (_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)
|
||||||
else if (!includeZero)
|
|| (neg && (_db.Stocks.Where(s => s.ProductId == p.Id && s.StoreId == sid).Select(s => (decimal?)s.Quantity).FirstOrDefault() ?? 0m) < 0m));
|
||||||
products = products.Where(p => (_db.Stocks.Where(s => s.ProductId == p.Id && s.StoreId == sid).Select(s => (decimal?)s.Quantity).FirstOrDefault() ?? 0m) != 0m);
|
|
||||||
}
|
|
||||||
else
|
else
|
||||||
{
|
products = products.Where(p =>
|
||||||
if (negativeOnly)
|
(pos && (_db.Stocks.Where(s => s.ProductId == p.Id).Sum(s => (decimal?)s.Quantity) ?? 0m) > 0m)
|
||||||
products = products.Where(p => (_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)
|
||||||
else if (!includeZero)
|
|| (neg && (_db.Stocks.Where(s => s.ProductId == p.Id).Sum(s => (decimal?)s.Quantity) ?? 0m) < 0m));
|
||||||
products = products.Where(p => (_db.Stocks.Where(s => s.ProductId == p.Id).Sum(s => (decimal?)s.Quantity) ?? 0m) != 0m);
|
|
||||||
}
|
|
||||||
return products;
|
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>Сортировка списка остатков. По количеству/резерву/доступному —
|
/// <summary>Сортировка списка остатков. По количеству/резерву/доступному —
|
||||||
/// ORDER BY по скалярному подзапросу остатка (SELECT остаётся простым → EF8 переводит).</summary>
|
/// ORDER BY по скалярному подзапросу остатка (SELECT остаётся простым → EF8 переводит).</summary>
|
||||||
private IQueryable<Domain.Catalog.Product> OrderProducts(
|
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(
|
public async Task<IActionResult> ExportStock(
|
||||||
[FromQuery] Guid? storeId, [FromQuery] Guid? groupId, [FromQuery] Guid? productGroupId,
|
[FromQuery] Guid? storeId, [FromQuery] Guid? groupId, [FromQuery] Guid? productGroupId,
|
||||||
[FromQuery] string? search,
|
[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)
|
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)
|
var paged = await products.OrderBy(p => p.Name)
|
||||||
.Select(p => new { p.Id, p.Name, p.Article, p.UnitOfMeasureId }).ToListAsync(ct);
|
.Select(p => new { p.Id, p.Name, p.Article, p.UnitOfMeasureId }).ToListAsync(ct);
|
||||||
var (_, storeName) = await StoreContextAsync(storeId, ct);
|
var (_, storeName) = await StoreContextAsync(storeId, ct);
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ export function NegativeStockBanner() {
|
||||||
navigate(`/inventory/inventories/${res.data.inventoryId}`)
|
navigate(`/inventory/inventories/${res.data.inventoryId}`)
|
||||||
} catch {
|
} catch {
|
||||||
// покажем список, чтобы пользователь разобрался вручную
|
// покажем список, чтобы пользователь разобрался вручную
|
||||||
navigate('/inventory/stock?negativeOnly=true')
|
navigate('/inventory/stock?states=negative')
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(false)
|
setBusy(false)
|
||||||
}
|
}
|
||||||
|
|
@ -63,7 +63,7 @@ export function NegativeStockBanner() {
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
type="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"
|
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}`)
|
navigate(`/inventory/inventories/${res.data.inventoryId}`)
|
||||||
} catch {
|
} catch {
|
||||||
setOpen(false)
|
setOpen(false)
|
||||||
navigate(n.link ?? '/inventory/stock?negativeOnly=true')
|
navigate(n.link ?? '/inventory/stock?states=negative')
|
||||||
} finally {
|
} finally {
|
||||||
setRecounting(null)
|
setRecounting(null)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,7 +31,7 @@ export function OverdueNegativeStockBlock() {
|
||||||
const res = await api.post<{ inventoryId: string }>('/api/negative-stock/recount', { storeId })
|
const res = await api.post<{ inventoryId: string }>('/api/negative-stock/recount', { storeId })
|
||||||
navigate(`/inventory/inventories/${res.data.inventoryId}`)
|
navigate(`/inventory/inventories/${res.data.inventoryId}`)
|
||||||
} catch {
|
} catch {
|
||||||
navigate(`/inventory/stock?negativeOnly=true&storeId=${storeId}`)
|
navigate(`/inventory/stock?states=negative&storeId=${storeId}`)
|
||||||
} finally {
|
} finally {
|
||||||
setBusy(null)
|
setBusy(null)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,22 +16,48 @@ export function StockPage() {
|
||||||
const stores = useStores()
|
const stores = useStores()
|
||||||
const [storeId, setStoreId] = useState('')
|
const [storeId, setStoreId] = useState('')
|
||||||
const [search, setSearch] = useState('')
|
const [search, setSearch] = useState('')
|
||||||
const [includeZero, setIncludeZero] = useState(false)
|
const [searchParams, setSearchParams] = useSearchParams()
|
||||||
const [searchParams] = useSearchParams()
|
// Три галочки состояния остатка (объединяются ИЛИ). Инициализация из URL
|
||||||
const [negativeOnly, setNegativeOnly] = useState(searchParams.get('negativeOnly') === 'true')
|
// (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 [page, setPage] = useState(1)
|
||||||
const [sortKey, setSortKey] = useState<string | null>(null)
|
const [sortKey, setSortKey] = useState<string | null>(null)
|
||||||
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
|
const [sortOrder, setSortOrder] = useState<SortOrder>('asc')
|
||||||
const setSort = (key: string, order: SortOrder) => { setSortKey(key); setSortOrder(order); setPage(1) }
|
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({
|
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 () => {
|
queryFn: async () => {
|
||||||
const params = new URLSearchParams({ page: String(page), pageSize: '50' })
|
const params = new URLSearchParams({ page: String(page), pageSize: '50' })
|
||||||
if (storeId) params.set('storeId', storeId)
|
if (storeId) params.set('storeId', storeId)
|
||||||
if (search) params.set('search', search)
|
if (search) params.set('search', search)
|
||||||
if (negativeOnly) params.set('negativeOnly', 'true')
|
if (statesCsv) params.set('states', statesCsv)
|
||||||
else if (includeZero) params.set('includeZero', 'true')
|
|
||||||
if (sortKey) { params.set('sort', sortKey); params.set('order', sortOrder) }
|
if (sortKey) { params.set('sort', sortKey); params.set('order', sortOrder) }
|
||||||
return (await api.get<PagedResult<StockRow>>(`/api/inventory/stock?${params}`)).data
|
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>)}
|
{stores.data?.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
|
||||||
</Select>
|
</Select>
|
||||||
</div>
|
</div>
|
||||||
<Checkbox label="Показать нулевые" checked={includeZero} onChange={(v) => { setIncludeZero(v); setPage(1) }} />
|
<Checkbox label="В наличии" checked={pos} onChange={(v) => toggle({ pos: v })} />
|
||||||
<Checkbox label="Только минусы" checked={negativeOnly} onChange={(v) => { setNegativeOnly(v); setPage(1) }} />
|
<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="По названию или артикулу…" />
|
<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>
|
</div>
|
||||||
}
|
}
|
||||||
footer={data && data.total > 0 && (
|
footer={data && data.total > 0 && (
|
||||||
|
|
@ -63,8 +90,8 @@ export function StockPage() {
|
||||||
<DataTable
|
<DataTable
|
||||||
rows={data?.items ?? []}
|
rows={data?.items ?? []}
|
||||||
isLoading={isLoading}
|
isLoading={isLoading}
|
||||||
empty={!includeZero && !negativeOnly
|
empty={checked.length === 0
|
||||||
? 'Нет товаров с остатком — включите «Показать нулевые», чтобы увидеть все товары'
|
? 'Нет товаров с остатком — отметьте «Нулевые», чтобы увидеть товары с нулевым остатком'
|
||||||
: 'Нет данных'}
|
: 'Нет данных'}
|
||||||
rowKey={(r) => `${r.productId}:${r.storeId}`}
|
rowKey={(r) => `${r.productId}:${r.storeId}`}
|
||||||
sortKey={sortKey}
|
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