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
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:
parent
45f2ce682f
commit
6f839bf57a
|
|
@ -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),
|
||||||
|
|
|
||||||
|
|
@ -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}>
|
||||||
|
|
|
||||||
|
|
@ -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 (если пользователь её переименовал — заголовок меняется).
|
||||||
{
|
{
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue