feat(product+filters): срок годности (shelfLifeDays) + фильтр от/до
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Build + push Web (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
CI / Backend (.NET 8) (push) Successful in 41s
CI / Web (React + Vite) (push) Successful in 33s
Docker API / Build + push API (push) Successful in 42s
Docker API / Deploy API on stage (push) Successful in 16s

ProductEditPage:
- В секции «Основное» добавлено поле «Срок годности (дней)» рядом с
  Артикулом — NumberInput, целое ≥ 0, hint «не обязательное поле».
- form.shelfLifeDays хранится строкой и сериализуется в payload как
  number | null.

ProductsPage filters:
- Добавлен диапазон «Срок годности (дней) — от / до».
- Удалён остаток фильтра isActive (поле выпилено в Phase3b foundation).

ProductsController.List:
- Принимает shelfLifeDaysFrom / shelfLifeDaysTo (int?) и применяет
  ≥ / ≤ к p.ShelfLifeDays.
- Также теперь принимает referencePriceFrom / referencePriceTo (новые
  имена); старые purchasePriceFrom/To работают как алиасы для
  обратной совместимости с уже отрендеренным UI.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-25 22:52:44 +05:00
parent 5a020cfafa
commit 2976070a2a
3 changed files with 49 additions and 7 deletions

View file

@ -94,6 +94,10 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
[FromQuery] bool? isMarked, [FromQuery] bool? isMarked,
[FromQuery] decimal? purchasePriceFrom, [FromQuery] decimal? purchasePriceFrom,
[FromQuery] decimal? purchasePriceTo, [FromQuery] decimal? purchasePriceTo,
[FromQuery] decimal? referencePriceFrom,
[FromQuery] decimal? referencePriceTo,
[FromQuery] int? shelfLifeDaysFrom,
[FromQuery] int? shelfLifeDaysTo,
CancellationToken ct) CancellationToken ct)
{ {
var q = QueryIncludes().AsNoTracking(); var q = QueryIncludes().AsNoTracking();
@ -115,8 +119,14 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
if (isService is not null) q = q.Where(p => p.IsService == isService); if (isService is not null) q = q.Where(p => p.IsService == isService);
if (packaging is not null) q = q.Where(p => p.Packaging == packaging); if (packaging is not null) q = q.Where(p => p.Packaging == packaging);
if (isMarked is not null) q = q.Where(p => p.IsMarked == isMarked); if (isMarked is not null) q = q.Where(p => p.IsMarked == isMarked);
if (purchasePriceFrom is not null) q = q.Where(p => p.ReferencePrice >= purchasePriceFrom); // referencePriceFrom/To — новый, актуальный параметр; purchasePriceFrom/To
if (purchasePriceTo is not null) q = q.Where(p => p.ReferencePrice <= purchasePriceTo); // — обратная совместимость c прежним UI (тоже по ReferencePrice).
var refFrom = referencePriceFrom ?? purchasePriceFrom;
var refTo = referencePriceTo ?? purchasePriceTo;
if (refFrom is not null) q = q.Where(p => p.ReferencePrice >= refFrom);
if (refTo is not null) q = q.Where(p => p.ReferencePrice <= refTo);
if (shelfLifeDaysFrom is not null) q = q.Where(p => p.ShelfLifeDays >= shelfLifeDaysFrom);
if (shelfLifeDaysTo is not null) q = q.Where(p => p.ShelfLifeDays <= shelfLifeDaysTo);
if (!string.IsNullOrWhiteSpace(req.Search)) if (!string.IsNullOrWhiteSpace(req.Search))
{ {

View file

@ -34,6 +34,7 @@ interface Form {
referencePrice: string referencePrice: string
purchaseCurrencyId: string purchaseCurrencyId: string
imageUrl: string imageUrl: string
shelfLifeDays: string
prices: PriceRow[] prices: PriceRow[]
barcodes: BarcodeRow[] barcodes: BarcodeRow[]
} }
@ -47,6 +48,7 @@ const emptyForm: Form = {
minStock: '', maxStock: '', minStock: '', maxStock: '',
referencePrice: '', purchaseCurrencyId: '', referencePrice: '', purchaseCurrencyId: '',
imageUrl: '', imageUrl: '',
shelfLifeDays: '',
prices: [], prices: [],
barcodes: [], barcodes: [],
} }
@ -89,6 +91,7 @@ export function ProductEditPage() {
referencePrice: p.referencePrice?.toString() ?? '', referencePrice: p.referencePrice?.toString() ?? '',
purchaseCurrencyId: p.purchaseCurrencyId ?? '', purchaseCurrencyId: p.purchaseCurrencyId ?? '',
imageUrl: p.imageUrl ?? '', imageUrl: p.imageUrl ?? '',
shelfLifeDays: p.shelfLifeDays?.toString() ?? '',
prices: p.prices.map((x) => ({ id: x.id, priceTypeId: x.priceTypeId, amount: x.amount, currencyId: x.currencyId })), prices: p.prices.map((x) => ({ id: x.id, priceTypeId: x.priceTypeId, amount: x.amount, currencyId: x.currencyId })),
barcodes: p.barcodes.map((x) => ({ id: x.id, code: x.code, type: x.type, isPrimary: x.isPrimary })), barcodes: p.barcodes.map((x) => ({ id: x.id, code: x.code, type: x.type, isPrimary: x.isPrimary })),
}) })
@ -149,6 +152,7 @@ export function ProductEditPage() {
referencePrice: form.referencePrice === '' ? null : Number(form.referencePrice), referencePrice: form.referencePrice === '' ? null : Number(form.referencePrice),
purchaseCurrencyId: form.purchaseCurrencyId || null, purchaseCurrencyId: form.purchaseCurrencyId || null,
imageUrl: form.imageUrl || null, imageUrl: form.imageUrl || null,
shelfLifeDays: form.shelfLifeDays === '' ? null : Number(form.shelfLifeDays),
prices: form.prices.map((p) => ({ priceTypeId: p.priceTypeId, amount: Number(p.amount), currencyId: p.currencyId })), prices: form.prices.map((p) => ({ priceTypeId: p.priceTypeId, amount: Number(p.amount), currencyId: p.currencyId })),
barcodes: form.barcodes.map((b) => ({ code: b.code, type: b.type, isPrimary: b.isPrimary })), barcodes: form.barcodes.map((b) => ({ code: b.code, type: b.type, isPrimary: b.isPrimary })),
} }
@ -250,6 +254,14 @@ export function ProductEditPage() {
<Field label="Артикул"> <Field label="Артикул">
<TextInput value={form.article} onChange={(e) => setForm({ ...form, article: e.target.value })} /> <TextInput value={form.article} onChange={(e) => setForm({ ...form, article: e.target.value })} />
</Field> </Field>
<Field label="Срок годности (дней)">
<NumberInput
value={form.shelfLifeDays === '' ? null : Number(form.shelfLifeDays)}
onChange={(n) => setForm({ ...form, shelfLifeDays: n == null ? '' : String(Math.max(0, Math.round(n))) })}
placeholder="—"
/>
<p className="text-xs text-slate-400 mt-1">не обязательное поле</p>
</Field>
<Field label="Описание" className="col-span-3"> <Field label="Описание" className="col-span-3">
<TextArea rows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /> <TextArea rows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</Field> </Field>

View file

@ -18,45 +18,49 @@ type TriFilter = 'all' | 'yes' | 'no'
interface Filters { interface Filters {
groupId: string | null groupId: string | null
isActive: TriFilter
isService: TriFilter isService: TriFilter
packaging: number | null // null = все, 1/2/3 = Piece/Weight/Liquid packaging: number | null // null = все, 1/2/3 = Piece/Weight/Liquid
isMarked: TriFilter isMarked: TriFilter
referencePriceFrom: number | null referencePriceFrom: number | null
referencePriceTo: number | null referencePriceTo: number | null
shelfLifeDaysFrom: number | null
shelfLifeDaysTo: number | null
} }
const defaultFilters: Filters = { const defaultFilters: Filters = {
groupId: null, groupId: null,
isActive: 'yes',
isService: 'all', isService: 'all',
packaging: null, packaging: null,
isMarked: 'all', isMarked: 'all',
referencePriceFrom: null, referencePriceFrom: null,
referencePriceTo: null, referencePriceTo: null,
shelfLifeDaysFrom: null,
shelfLifeDaysTo: null,
} }
const toExtra = (f: Filters): Record<string, string | number | boolean | undefined> => { const toExtra = (f: Filters): Record<string, string | number | boolean | undefined> => {
const e: Record<string, string | number | boolean | undefined> = {} const e: Record<string, string | number | boolean | undefined> = {}
if (f.groupId) e.groupId = f.groupId if (f.groupId) e.groupId = f.groupId
if (f.isActive !== 'all') e.isActive = f.isActive === 'yes'
if (f.isService !== 'all') e.isService = f.isService === 'yes' if (f.isService !== 'all') e.isService = f.isService === 'yes'
if (f.packaging) e.packaging = f.packaging if (f.packaging) e.packaging = f.packaging
if (f.isMarked !== 'all') e.isMarked = f.isMarked === 'yes' if (f.isMarked !== 'all') e.isMarked = f.isMarked === 'yes'
if (f.referencePriceFrom != null) e.referencePriceFrom = f.referencePriceFrom if (f.referencePriceFrom != null) e.referencePriceFrom = f.referencePriceFrom
if (f.referencePriceTo != null) e.referencePriceTo = f.referencePriceTo if (f.referencePriceTo != null) e.referencePriceTo = f.referencePriceTo
if (f.shelfLifeDaysFrom != null) e.shelfLifeDaysFrom = f.shelfLifeDaysFrom
if (f.shelfLifeDaysTo != null) e.shelfLifeDaysTo = f.shelfLifeDaysTo
return e return e
} }
const activeFilterCount = (f: Filters) => { const activeFilterCount = (f: Filters) => {
let n = 0 let n = 0
if (f.groupId) n++ if (f.groupId) n++
if (f.isActive !== 'yes') n++ // 'yes' is default, count non-default
if (f.isService !== 'all') n++ if (f.isService !== 'all') n++
if (f.packaging) n++ if (f.packaging) n++
if (f.isMarked !== 'all') n++ if (f.isMarked !== 'all') n++
if (f.referencePriceFrom != null) n++ if (f.referencePriceFrom != null) n++
if (f.referencePriceTo != null) n++ if (f.referencePriceTo != null) n++
if (f.shelfLifeDaysFrom != null) n++
if (f.shelfLifeDaysTo != null) n++
return n return n
} }
@ -213,7 +217,6 @@ export function ProductsPage() {
{/* Filter panel */} {/* Filter panel */}
{filtersOpen && ( {filtersOpen && (
<div className="px-4 sm:px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-900/60 flex flex-wrap gap-3 sm:gap-4 items-center"> <div className="px-4 sm:px-6 py-3 border-b border-slate-200 dark:border-slate-800 bg-slate-50 dark:bg-slate-900/60 flex flex-wrap gap-3 sm:gap-4 items-center">
<Tri label="Активные" value={filters.isActive} onChange={(v) => { setFilters({ ...filters, isActive: v }); setPage(1) }} />
{showService && ( {showService && (
<Tri label="Услуга" value={filters.isService} onChange={(v) => { setFilters({ ...filters, isService: v }); setPage(1) }} /> <Tri label="Услуга" value={filters.isService} onChange={(v) => { setFilters({ ...filters, isService: v }); setPage(1) }} />
)} )}
@ -256,6 +259,23 @@ export function ProductsPage() {
/> />
</div> </div>
</div> </div>
<div className="flex items-center gap-2 text-xs">
<span className="text-slate-500">Срок годности (дней)</span>
<input
type="number" min="0"
value={filters.shelfLifeDaysFrom ?? ''}
placeholder="от"
onChange={(e) => { const v = e.target.value === '' ? null : Number(e.target.value); setFilters({ ...filters, shelfLifeDaysFrom: v }); setPage(1) }}
className="w-20 px-2 py-0.5 rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900"
/>
<input
type="number" min="0"
value={filters.shelfLifeDaysTo ?? ''}
placeholder="до"
onChange={(e) => { const v = e.target.value === '' ? null : Number(e.target.value); setFilters({ ...filters, shelfLifeDaysTo: v }); setPage(1) }}
className="w-20 px-2 py-0.5 rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900"
/>
</div>
{activeCount > 0 && ( {activeCount > 0 && (
<button <button
type="button" type="button"