feat(product-card): drop ShelfLifeDays + recompose classification + auto-article + barcode trash hide
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 46s
CI / Web (React + Vite) (push) Successful in 35s
Docker API / Build + push API (push) Successful in 44s
Docker Web / Build + push Web (push) Successful in 25s
Docker API / Deploy API on stage (push) Successful in 17s
Docker Web / Deploy Web on stage (push) Successful in 11s

- Удаление поля «Срок годности (дней)»:
  • Domain.Product.ShelfLifeDays убран,
  • миграция Phase3b_DropProductShelfLifeDays — DROP COLUMN,
  • DTO/Input/UI/фильтр в списке товаров — выпилены.

- Перекомпоновка секции «Классификация» в карточке товара:
  • ряд 1 (3 col): Группа * | Единица измерения * | Фасовка,
  • ряд 2 (2 col): Основной поставщик | Страна происхождения,
  • Страна происхождения видна только если включена настройка
    Organization.ShowCountryOfOriginOnProduct (default false).
  • Та же миграция добавляет колонку, в OrganizationSettingsPage
    появляется галка «Показывать «Страну происхождения» на товаре»
    с подсказкой про импорт.

- Артикул теперь обязательное поле с авто-генерацией:
  • ProductEditPage: метка «Артикул *», required,
  • генератор generateArticle() (timestamp[-6] + 3 random) — у нового
    товара поле сразу заполнено,
  • canSave требует непустой article. Уникальность подтверждает
    сервер (он также имеет свой fallback-генератор max+1).

- Иконка корзины в секции «Штрихкоды» рендерится только при
  form.barcodes.length > 1 — для единственной строки удаления нет
  (минимум 1 штрихкод обязателен, удалять единственный нельзя).

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 00:50:05 +05:00
parent defad7cbb4
commit b69ba4950b
14 changed files with 2014 additions and 83 deletions

View file

@ -115,8 +115,6 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
[FromQuery] decimal? purchasePriceTo,
[FromQuery] decimal? referencePriceFrom,
[FromQuery] decimal? referencePriceTo,
[FromQuery] int? shelfLifeDaysFrom,
[FromQuery] int? shelfLifeDaysTo,
[FromQuery] decimal? systemPriceFrom,
[FromQuery] decimal? systemPriceTo,
CancellationToken ct)
@ -146,8 +144,6 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
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);
// Фильтр по системной (главной розничной) цене — берём Prices c PriceType.IsSystem=true.
if (systemPriceFrom is not null)
q = q.Where(p => p.Prices.Any(pr => pr.PriceType!.IsSystem && pr.Amount >= systemPriceFrom));
@ -421,7 +417,7 @@ public async Task<ActionResult<IReadOnlyList<BarcodeDuplicate>>> BarcodeDuplicat
p.ReferencePrice, p.ReferencePriceUpdatedAt,
p.PurchaseCurrencyId, p.PurchaseCurrency != null ? p.PurchaseCurrency.Code : null,
p.Cost, p.LastSupplyAt,
p.ImageUrl, p.ShelfLifeDays,
p.ImageUrl,
p.Prices.Select(pr => new ProductPriceDto(pr.Id, pr.PriceTypeId, pr.PriceType!.Name, pr.Amount, pr.CurrencyId, pr.Currency!.Code)).ToList(),
p.Barcodes.Select(b => new ProductBarcodeDto(b.Id, b.Code, b.Type, b.IsPrimary)).ToList());
@ -451,6 +447,5 @@ private static void Apply(Product e, ProductInput i)
}
e.PurchaseCurrencyId = i.PurchaseCurrencyId;
e.ImageUrl = i.ImageUrl;
e.ShelfLifeDays = i.ShelfLifeDays;
}
}

View file

@ -35,7 +35,8 @@ public record OrgSettingsDto(
bool ShowMarkedOnProduct,
bool ShowMinMaxStock,
bool AllowFractionalPrices,
bool ShowReferencePriceOnProduct);
bool ShowReferencePriceOnProduct,
bool ShowCountryOfOriginOnProduct);
// DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId).
public record OrgSettingsInput(
@ -47,7 +48,8 @@ public record OrgSettingsInput(
bool ShowMarkedOnProduct,
bool ShowMinMaxStock,
bool AllowFractionalPrices,
bool ShowReferencePriceOnProduct);
bool ShowReferencePriceOnProduct,
bool ShowCountryOfOriginOnProduct);
[HttpGet("settings")]
public async Task<ActionResult<OrgSettingsDto>> Get(CancellationToken ct)
@ -84,6 +86,7 @@ public async Task<ActionResult<OrgSettingsDto>> Update([FromBody] OrgSettingsInp
o.ShowMinMaxStock = input.ShowMinMaxStock;
o.AllowFractionalPrices = input.AllowFractionalPrices;
o.ShowReferencePriceOnProduct = input.ShowReferencePriceOnProduct;
o.ShowCountryOfOriginOnProduct = input.ShowCountryOfOriginOnProduct;
await _db.SaveChangesAsync(ct);
await _db.Entry(o).Reference(x => x.DefaultCurrency).LoadAsync(ct);
@ -112,5 +115,6 @@ private async Task<decimal> ReadVatRateAsync(string countryCode, CancellationTok
o.ShowMarkedOnProduct,
o.ShowMinMaxStock,
o.AllowFractionalPrices,
o.ShowReferencePriceOnProduct);
o.ShowReferencePriceOnProduct,
o.ShowCountryOfOriginOnProduct);
}

View file

@ -52,7 +52,7 @@ public record ProductDto(
decimal? ReferencePrice, DateTime? ReferencePriceUpdatedAt,
Guid? PurchaseCurrencyId, string? PurchaseCurrencyCode,
decimal Cost, DateTime? LastSupplyAt,
string? ImageUrl, int? ShelfLifeDays,
string? ImageUrl,
IReadOnlyList<ProductPriceDto> Prices,
IReadOnlyList<ProductBarcodeDto> Barcodes);
@ -90,6 +90,6 @@ public record ProductInput(
bool IsService = false, Packaging Packaging = Packaging.Piece, bool IsMarked = false,
[Range(0, 1e10)] decimal? MinStock = null, [Range(0, 1e10)] decimal? MaxStock = null,
[Range(0, 1e10)] decimal? ReferencePrice = null, Guid? PurchaseCurrencyId = null,
string? ImageUrl = null, [Range(0, 100000)] int? ShelfLifeDays = null,
string? ImageUrl = null,
IReadOnlyList<ProductPriceInput>? Prices = null,
IReadOnlyList<ProductBarcodeInput>? Barcodes = null);

View file

@ -54,8 +54,6 @@ public class Product : TenantEntity
public DateTime? LastSupplyAt { get; set; }
public string? ImageUrl { get; set; } // основное изображение (остальные в ProductImage)
/// <summary>Срок годности в днях (для отчётов и фильтрации). Не обязательное.</summary>
public int? ShelfLifeDays { get; set; }
public ICollection<ProductPrice> Prices { get; set; } = [];
public ICollection<ProductBarcode> Barcodes { get; set; } = [];

View file

@ -57,4 +57,9 @@ public class Organization : Entity
/// <summary>Показывать ли в карточке товара поле «Эталонная цена».
/// Default: true.</summary>
public bool ShowReferencePriceOnProduct { get; set; } = true;
/// <summary>Показывать ли в карточке товара поле «Страна происхождения».
/// Default: false. Большинству KZ-магазинов это поле не нужно;
/// включать для торговцев импортом или маркируемой продукцией.</summary>
public bool ShowCountryOfOriginOnProduct { get; set; }
}

View file

@ -0,0 +1,29 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>products.ShelfLifeDays удалён — поле «Срок годности (дней)»
/// решили убрать целиком из системы. Также добавлена настройка
/// organizations.ShowCountryOfOriginOnProduct (default false) — поле
/// «Страна происхождения» в карточке теперь скрыто пока не включено в настройках.</summary>
public partial class Phase3b_DropProductShelfLifeDays : Migration
{
protected override void Up(MigrationBuilder b)
{
b.DropColumn(name: "ShelfLifeDays", schema: "public", table: "products");
b.AddColumn<bool>(
name: "ShowCountryOfOriginOnProduct", schema: "public", table: "organizations",
type: "boolean", nullable: false, defaultValue: false);
}
protected override void Down(MigrationBuilder b)
{
b.DropColumn(name: "ShowCountryOfOriginOnProduct", schema: "public", table: "organizations");
b.AddColumn<int>(
name: "ShelfLifeDays", schema: "public", table: "products",
type: "integer", nullable: true);
}
}
}

View file

@ -605,9 +605,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<DateTime?>("ReferencePriceUpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("ShelfLifeDays")
.HasColumnType("integer");
b.Property<Guid>("UnitOfMeasureId")
.HasColumnType("uuid");
@ -1096,6 +1093,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<bool>("MultiCurrencyEnabled")
.HasColumnType("boolean");
b.Property<bool>("ShowCountryOfOriginOnProduct")
.HasColumnType("boolean");
b.Property<bool>("ShowMarkedOnProduct")
.HasColumnType("boolean");

View file

@ -58,6 +58,16 @@ export function generateEan13InternalPrefix2(): string {
return ean13()
}
/** Сгенерировать числовой артикул для товара. На бэкенде есть надёжный
* max(article)+1 генератор, но для UX удобно сразу подставить значение в поле:
* берём последние 6 цифр времени + 3 случайных. Уникальность подтверждает
* сервер; конфликт по unique index возвращается как 400 «Артикул уже занят». */
export function generateArticle(): string {
const ts = Date.now().toString().slice(-6)
const rnd = Math.floor(100 + Math.random() * 900).toString()
return ts + rnd
}
/** Сгенерировать штрихкод под указанный формат. */
export function generateBarcode(type: BarcodeType): string {
switch (type) {

View file

@ -60,7 +60,7 @@ export interface Product {
referencePrice: number | null; referencePriceUpdatedAt: string | null;
purchaseCurrencyId: string | null; purchaseCurrencyCode: string | null;
cost: number; lastSupplyAt: string | null;
imageUrl: string | null; shelfLifeDays: number | null;
imageUrl: string | null;
prices: ProductPrice[]; barcodes: ProductBarcode[]
}

View file

@ -16,6 +16,7 @@ export interface OrgSettings {
showMinMaxStock: boolean
allowFractionalPrices: boolean
showReferencePriceOnProduct: boolean
showCountryOfOriginOnProduct: boolean
}
export function useOrgSettings() {

View file

@ -46,6 +46,7 @@ export function OrganizationSettingsPage() {
showMinMaxStock: form.showMinMaxStock,
allowFractionalPrices: form.allowFractionalPrices,
showReferencePriceOnProduct: form.showReferencePriceOnProduct,
showCountryOfOriginOnProduct: form.showCountryOfOriginOnProduct,
}
return (await api.put<OrgSettings>('/api/organization/settings', payload)).data
},
@ -161,6 +162,16 @@ export function OrganizationSettingsPage() {
Справочная цена закупа необязательное поле. Авто-заполняется первой
проведённой приёмкой и через 30 дней без приёмок переписывается на текущую себестоимость.
</p>
<Checkbox
label='Показывать «Страну происхождения» на товаре'
checked={form.showCountryOfOriginOnProduct}
onChange={(v) => setForm({ ...form, showCountryOfOriginOnProduct: v })}
/>
<p className="text-xs text-slate-500 -mt-2">
По умолчанию выключено. Включай если торгуешь импортом или ведёшь
учёт по странам тогда в карточке товара будет селект «Страна происхождения».
</p>
</section>
<div className="mt-4 flex gap-3 items-center">

View file

@ -11,7 +11,7 @@ import {
import { useOrgSettings } from '@/lib/useOrgSettings'
import { BarcodeType, Packaging, type Product } from '@/lib/types'
import { ProductImageGallery } from '@/components/ProductImageGallery'
import { generateEan13InternalPrefix2, generateBarcode } from '@/lib/barcode'
import { generateEan13InternalPrefix2, generateBarcode, generateArticle } from '@/lib/barcode'
interface PriceRow { id?: string; priceTypeId: string; amount: number; currencyId: string }
interface BarcodeRow { id?: string; code: string; type: BarcodeType; isPrimary: boolean }
@ -34,7 +34,6 @@ interface Form {
referencePrice: string
purchaseCurrencyId: string
imageUrl: string
shelfLifeDays: string
prices: PriceRow[]
barcodes: BarcodeRow[]
}
@ -48,7 +47,6 @@ const emptyForm: Form = {
minStock: '', maxStock: '',
referencePrice: '', purchaseCurrencyId: '',
imageUrl: '',
shelfLifeDays: '',
prices: [],
barcodes: [],
}
@ -91,7 +89,6 @@ export function ProductEditPage() {
referencePrice: p.referencePrice?.toString() ?? '',
purchaseCurrencyId: p.purchaseCurrencyId ?? '',
imageUrl: p.imageUrl ?? '',
shelfLifeDays: p.shelfLifeDays?.toString() ?? '',
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 })),
})
@ -119,6 +116,13 @@ export function ProductEditPage() {
if (isNew && org.data && form.vat === 16 && org.data.vatRate !== 16) {
setForm((f) => ({ ...f, vat: org.data!.vatRate }))
}
// У нового товара сразу автогенерируется артикул — уникальность
// окончательно подтверждает сервер. Поле остаётся редактируемым,
// пользователь может заменить вручную.
if (isNew && form.article === '') {
const a = generateArticle()
setForm((f) => ({ ...f, article: a }))
}
// У нового товара сразу один сгенерированный штрихкод, чтобы кнопку Сохранить
// можно было нажать без лишних кликов.
if (isNew && form.barcodes.length === 0) {
@ -127,7 +131,7 @@ export function ProductEditPage() {
barcodes: [{ code: generateEan13InternalPrefix2(), type: BarcodeType.Ean13, isPrimary: true }],
}))
}
}, [isNew, units.data, groups.data, currencies.data, org.data, form.unitOfMeasureId, form.productGroupId, form.purchaseCurrencyId, form.vat, form.barcodes.length])
}, [isNew, units.data, groups.data, currencies.data, org.data, form.unitOfMeasureId, form.productGroupId, form.purchaseCurrencyId, form.vat, form.article, form.barcodes.length])
const save = useMutation({
mutationFn: async () => {
@ -152,7 +156,6 @@ export function ProductEditPage() {
referencePrice: form.referencePrice === '' ? null : Number(form.referencePrice),
purchaseCurrencyId: form.purchaseCurrencyId || 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 })),
barcodes: form.barcodes.map((b) => ({ code: b.code, type: b.type, isPrimary: b.isPrimary })),
}
@ -204,6 +207,7 @@ export function ProductEditPage() {
})
const canSave = form.name.trim().length > 0
&& form.article.trim().length > 0
&& !!form.unitOfMeasureId
&& !!form.productGroupId
&& form.barcodes.length > 0
@ -259,16 +263,8 @@ export function ProductEditPage() {
<Field label="Название *" className="col-span-2">
<TextInput required value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field>
<Field label="Артикул">
<TextInput value={form.article} onChange={(e) => setForm({ ...form, article: e.target.value })} />
</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 label="Артикул *">
<TextInput required value={form.article} onChange={(e) => setForm({ ...form, article: e.target.value })} />
</Field>
<Field label="Описание" className="col-span-3">
<TextArea rows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
@ -278,30 +274,16 @@ export function ProductEditPage() {
<Section title="Классификация">
<Grid cols={3}>
<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 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 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>)}
<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.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>
</Grid>
<Grid cols={3}>
<Field label="Фасовка">
<Select value={form.packaging} onChange={(e) => setForm({ ...form, packaging: Number(e.target.value) as Packaging })}>
<option value={Packaging.Piece}>Штучный</option>
@ -310,6 +292,22 @@ export function ProductEditPage() {
</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="Ставка НДС, %">
@ -509,14 +507,18 @@ export function ProductEditPage() {
onChange={(v) => setForm({ ...form, barcodes: form.barcodes.map((x, ix) => ({ ...x, isPrimary: ix === i ? v : false })) })}
/>
</div>
<button
type="button"
onClick={() => removeBarcode(i)}
className="col-span-1 text-slate-400 hover:text-red-600 flex justify-center"
title="Удалить строку"
>
<Trash2 className="w-4 h-4" />
</button>
{form.barcodes.length > 1 ? (
<button
type="button"
onClick={() => removeBarcode(i)}
className="col-span-1 text-slate-400 hover:text-red-600 flex justify-center"
title="Удалить строку"
>
<Trash2 className="w-4 h-4" />
</button>
) : (
<div className="col-span-1" />
)}
</div>
))}
</div>

View file

@ -23,8 +23,6 @@ interface Filters {
isMarked: TriFilter
systemPriceFrom: number | null
systemPriceTo: number | null
shelfLifeDaysFrom: number | null
shelfLifeDaysTo: number | null
}
const defaultFilters: Filters = {
@ -34,8 +32,6 @@ const defaultFilters: Filters = {
isMarked: 'all',
systemPriceFrom: null,
systemPriceTo: null,
shelfLifeDaysFrom: null,
shelfLifeDaysTo: null,
}
const toExtra = (f: Filters): Record<string, string | number | boolean | undefined> => {
@ -46,8 +42,6 @@ const toExtra = (f: Filters): Record<string, string | number | boolean | undefin
if (f.isMarked !== 'all') e.isMarked = f.isMarked === 'yes'
if (f.systemPriceFrom != null) e.systemPriceFrom = f.systemPriceFrom
if (f.systemPriceTo != null) e.systemPriceTo = f.systemPriceTo
if (f.shelfLifeDaysFrom != null) e.shelfLifeDaysFrom = f.shelfLifeDaysFrom
if (f.shelfLifeDaysTo != null) e.shelfLifeDaysTo = f.shelfLifeDaysTo
return e
}
@ -59,8 +53,6 @@ const activeFilterCount = (f: Filters) => {
if (f.isMarked !== 'all') n++
if (f.systemPriceFrom != null) n++
if (f.systemPriceTo != null) n++
if (f.shelfLifeDaysFrom != null) n++
if (f.shelfLifeDaysTo != null) n++
return n
}
@ -258,23 +250,6 @@ export function ProductsPage() {
/>
</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 && (
<button
type="button"