feat(product-card+list): drop supplier field, reorder sections, add cost column
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 44s
CI / Web (React + Vite) (push) Successful in 34s
Docker API / Build + push API (push) Successful in 44s
Docker Web / Build + push Web (push) Successful in 27s
Docker API / Deploy API on stage (push) Successful in 17s
Docker Web / Deploy Web on stage (push) Successful in 12s

Карточка товара:
- убрано поле «Основной поставщик» из секции «Классификация» (домен/DTO
  оставлены без изменений; в payload отправляется null);
- порядок секций: Основное → Цены → Классификация → Изображения →
  Штрихкоды (раньше Цены шли после Классификации). Цены — самое важное,
  должны быть ближе к названию товара.

Список товаров:
- добавлена колонка «Себестоимость» перед колонкой системной розничной
  цены. Источник — Product.Cost (скользящее среднее, обновляется при
  проведении приёмки). Cost = 0 (приёмок не было) показывается как «—»,
  чтобы визуально отличать «не накопилось» от реальной себестоимости 0.
- API: добавлен сортировочный case sort=cost,asc/desc.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 02:13:57 +05:00
parent 45f2ce682f
commit 6f839bf57a
3 changed files with 77 additions and 65 deletions

View file

@ -172,6 +172,8 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
("packaging", true) => q.OrderByDescending(p => p.Packaging).ThenBy(p => p.Name), ("packaging", true) => q.OrderByDescending(p => p.Packaging).ThenBy(p => p.Name),
("purchasePrice", false) => q.OrderBy(p => p.ReferencePrice).ThenBy(p => p.Name), ("purchasePrice", false) => q.OrderBy(p => p.ReferencePrice).ThenBy(p => p.Name),
("purchasePrice", true) => q.OrderByDescending(p => p.ReferencePrice).ThenBy(p => p.Name), ("purchasePrice", true) => q.OrderByDescending(p => p.ReferencePrice).ThenBy(p => p.Name),
("cost", false) => q.OrderBy(p => p.Cost).ThenBy(p => p.Name),
("cost", true) => q.OrderByDescending(p => p.Cost).ThenBy(p => p.Name),
("systemPrice", false) => q.OrderBy(p => p.Prices.Where(pr => pr.PriceType!.IsSystem).Select(pr => (decimal?)pr.Amount).FirstOrDefault()).ThenBy(p => p.Name), ("systemPrice", false) => q.OrderBy(p => p.Prices.Where(pr => pr.PriceType!.IsSystem).Select(pr => (decimal?)pr.Amount).FirstOrDefault()).ThenBy(p => p.Name),
("systemPrice", true) => q.OrderByDescending(p => p.Prices.Where(pr => pr.PriceType!.IsSystem).Select(pr => (decimal?)pr.Amount).FirstOrDefault()).ThenBy(p => p.Name), ("systemPrice", true) => q.OrderByDescending(p => p.Prices.Where(pr => pr.PriceType!.IsSystem).Select(pr => (decimal?)pr.Amount).FirstOrDefault()).ThenBy(p => p.Name),
("vat", false) => q.OrderBy(p => p.Vat).ThenBy(p => p.Name), ("vat", false) => q.OrderBy(p => p.Vat).ThenBy(p => p.Name),

View file

@ -6,7 +6,7 @@ import { api } from '@/lib/api'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
import { Field, TextInput, TextArea, Select, Checkbox, MoneyInput, NumberInput } from '@/components/Field' import { Field, TextInput, TextArea, Select, Checkbox, MoneyInput, NumberInput } from '@/components/Field'
import { import {
useUnits, useProductGroups, useCountries, useCurrencies, usePriceTypes, useSuppliers, useUnits, useProductGroups, useCountries, useCurrencies, usePriceTypes,
} from '@/lib/useLookups' } from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings' import { useOrgSettings } from '@/lib/useOrgSettings'
import { BarcodeType, Packaging, type Product } from '@/lib/types' import { BarcodeType, Packaging, type Product } from '@/lib/types'
@ -24,7 +24,6 @@ interface Form {
vat: number vat: number
vatEnabled: boolean vatEnabled: boolean
productGroupId: string productGroupId: string
defaultSupplierId: string
countryOfOriginId: string countryOfOriginId: string
isService: boolean isService: boolean
packaging: Packaging packaging: Packaging
@ -42,7 +41,7 @@ interface Form {
const emptyForm: Form = { const emptyForm: Form = {
name: '', article: '', description: '', name: '', article: '', description: '',
unitOfMeasureId: '', vat: 16, vatEnabled: true, unitOfMeasureId: '', vat: 16, vatEnabled: true,
productGroupId: '', defaultSupplierId: '', countryOfOriginId: '', productGroupId: '', countryOfOriginId: '',
isService: false, packaging: Packaging.Piece, isMarked: false, isService: false, packaging: Packaging.Piece, isMarked: false,
minStock: '', maxStock: '', minStock: '', maxStock: '',
referencePrice: '', purchaseCurrencyId: '', referencePrice: '', purchaseCurrencyId: '',
@ -63,7 +62,6 @@ export function ProductEditPage() {
const currencies = useCurrencies() const currencies = useCurrencies()
const priceTypes = usePriceTypes() const priceTypes = usePriceTypes()
const org = useOrgSettings() const org = useOrgSettings()
const suppliers = useSuppliers()
const existing = useQuery({ const existing = useQuery({
queryKey: ['/api/catalog/products', id], queryKey: ['/api/catalog/products', id],
@ -80,7 +78,7 @@ export function ProductEditPage() {
setForm({ setForm({
name: p.name, article: p.article ?? '', description: p.description ?? '', name: p.name, article: p.article ?? '', description: p.description ?? '',
unitOfMeasureId: p.unitOfMeasureId, vat: p.vat, vatEnabled: p.vatEnabled, unitOfMeasureId: p.unitOfMeasureId, vat: p.vat, vatEnabled: p.vatEnabled,
productGroupId: p.productGroupId ?? '', defaultSupplierId: p.defaultSupplierId ?? '', productGroupId: p.productGroupId ?? '',
countryOfOriginId: p.countryOfOriginId ?? '', countryOfOriginId: p.countryOfOriginId ?? '',
isService: p.isService, packaging: p.packaging, isMarked: p.isMarked, isService: p.isService, packaging: p.packaging, isMarked: p.isMarked,
@ -145,7 +143,7 @@ export function ProductEditPage() {
vat: org.data?.showVatEnabledOnProduct ? form.vat : null, vat: org.data?.showVatEnabledOnProduct ? form.vat : null,
vatEnabled: form.vatEnabled, vatEnabled: form.vatEnabled,
productGroupId: form.productGroupId || null, productGroupId: form.productGroupId || null,
defaultSupplierId: form.defaultSupplierId || null, defaultSupplierId: null,
countryOfOriginId: form.countryOfOriginId || null, countryOfOriginId: form.countryOfOriginId || null,
isService: form.isService, isService: form.isService,
packaging: form.packaging, packaging: form.packaging,
@ -332,65 +330,6 @@ export function ProductEditPage() {
)} )}
</Section> </Section>
<Section title="Классификация">
<Grid cols={3}>
<Field label="Группа *">
<Select required value={form.productGroupId} onChange={(e) => setForm({ ...form, productGroupId: e.target.value })}>
{groups.data?.map((g) => <option key={g.id} value={g.id}>{g.path}</option>)}
</Select>
</Field>
<Field label="Единица измерения *">
<Select required value={form.unitOfMeasureId} onChange={(e) => setForm({ ...form, unitOfMeasureId: e.target.value })}>
{units.data?.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
</Select>
</Field>
<Field label="Фасовка">
<Select value={form.packaging} onChange={(e) => setForm({ ...form, packaging: Number(e.target.value) as Packaging })}>
<option value={Packaging.Piece}>Штучный</option>
<option value={Packaging.Weight}>Весовой</option>
<option value={Packaging.Liquid}>Разливной</option>
</Select>
</Field>
</Grid>
<Grid cols={2}>
<Field label="Основной поставщик">
<Select value={form.defaultSupplierId} onChange={(e) => setForm({ ...form, defaultSupplierId: e.target.value })}>
<option value=""></option>
{suppliers.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Select>
</Field>
{org.data?.showCountryOfOriginOnProduct && (
<Field label="Страна происхождения">
<Select value={form.countryOfOriginId} onChange={(e) => setForm({ ...form, countryOfOriginId: e.target.value })}>
<option value=""></option>
{countries.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Select>
</Field>
)}
</Grid>
{org.data?.showVatEnabledOnProduct && form.vatEnabled && (
<Grid cols={3}>
<Field label="Ставка НДС, %">
<NumberInput
value={form.vat}
onChange={(n) => setForm({ ...form, vat: n ?? 0 })}
/>
</Field>
</Grid>
)}
<div className="pt-3 mt-3 border-t border-slate-100 dark:border-slate-800 flex flex-wrap gap-x-6 gap-y-2">
{org.data?.showVatEnabledOnProduct && (
<Checkbox label="В том числе НДС" checked={form.vatEnabled} onChange={(v) => setForm({ ...form, vatEnabled: v })} />
)}
{org.data?.showServiceOnProduct && (
<Checkbox label="Услуга" checked={form.isService} onChange={(v) => setForm({ ...form, isService: v })} />
)}
{org.data?.showMarkedOnProduct && (
<Checkbox label="Маркируемый (Честный знак / Datamatrix)" checked={form.isMarked} onChange={(v) => setForm({ ...form, isMarked: v })} />
)}
</div>
</Section>
<Section <Section
title="Цены" title="Цены"
action={ action={
@ -506,6 +445,59 @@ export function ProductEditPage() {
)} )}
</Section> </Section>
<Section title="Классификация">
<Grid cols={3}>
<Field label="Группа *">
<Select required value={form.productGroupId} onChange={(e) => setForm({ ...form, productGroupId: e.target.value })}>
{groups.data?.map((g) => <option key={g.id} value={g.id}>{g.path}</option>)}
</Select>
</Field>
<Field label="Единица измерения *">
<Select required value={form.unitOfMeasureId} onChange={(e) => setForm({ ...form, unitOfMeasureId: e.target.value })}>
{units.data?.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
</Select>
</Field>
<Field label="Фасовка">
<Select value={form.packaging} onChange={(e) => setForm({ ...form, packaging: Number(e.target.value) as Packaging })}>
<option value={Packaging.Piece}>Штучный</option>
<option value={Packaging.Weight}>Весовой</option>
<option value={Packaging.Liquid}>Разливной</option>
</Select>
</Field>
</Grid>
{org.data?.showCountryOfOriginOnProduct && (
<Grid cols={2}>
<Field label="Страна происхождения">
<Select value={form.countryOfOriginId} onChange={(e) => setForm({ ...form, countryOfOriginId: e.target.value })}>
<option value=""></option>
{countries.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Select>
</Field>
</Grid>
)}
{org.data?.showVatEnabledOnProduct && form.vatEnabled && (
<Grid cols={3}>
<Field label="Ставка НДС, %">
<NumberInput
value={form.vat}
onChange={(n) => setForm({ ...form, vat: n ?? 0 })}
/>
</Field>
</Grid>
)}
<div className="pt-3 mt-3 border-t border-slate-100 dark:border-slate-800 flex flex-wrap gap-x-6 gap-y-2">
{org.data?.showVatEnabledOnProduct && (
<Checkbox label="В том числе НДС" checked={form.vatEnabled} onChange={(v) => setForm({ ...form, vatEnabled: v })} />
)}
{org.data?.showServiceOnProduct && (
<Checkbox label="Услуга" checked={form.isService} onChange={(v) => setForm({ ...form, isService: v })} />
)}
{org.data?.showMarkedOnProduct && (
<Checkbox label="Маркируемый (Честный знак / Datamatrix)" checked={form.isMarked} onChange={(v) => setForm({ ...form, isMarked: v })} />
)}
</div>
</Section>
{org.data?.showMinMaxStock && ( {org.data?.showMinMaxStock && (
<AdvancedSection> <AdvancedSection>
<Grid cols={4}> <Grid cols={4}>

View file

@ -126,6 +126,24 @@ export function ProductsPage() {
{ header: 'Штрихкод', width: '160px', cell: (r) => ( { header: 'Штрихкод', width: '160px', cell: (r) => (
<span className="font-mono">{r.barcodes[0]?.code ?? '—'}</span> <span className="font-mono">{r.barcodes[0]?.code ?? '—'}</span>
)}, )},
// Себестоимость — расчётное скользящее среднее. До первой проведённой
// приёмки = 0; в этом случае показываем «—», чтобы визуально отличать
// «не накопилась» от «реально стоит 0».
{
header: 'Себестоимость',
width: '160px',
className: 'text-right font-mono',
sortKey: 'cost',
cell: (r) => {
if (r.cost == null || r.cost === 0) return '—'
const fractional = org.data?.allowFractionalPrices ?? false
const num = r.cost.toLocaleString('ru',
fractional
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
: { maximumFractionDigits: 0 })
return `${num} ${org.data?.defaultCurrencySymbol ?? org.data?.defaultCurrencyCode ?? ''}`.trim()
},
},
// Колонка системной розничной цены: заголовок = PriceType.Name той записи // Колонка системной розничной цены: заголовок = PriceType.Name той записи
// что помечена IsSystem (если пользователь её переименовал — заголовок меняется). // что помечена IsSystem (если пользователь её переименовал — заголовок меняется).
{ {