phase3b: product card cleanup + supply form simplification
Some checks failed
CI / POS (WPF, Windows) (push) Waiting to run
Docker Web / Deploy Web on stage (push) Blocked by required conditions
CI / Backend (.NET 8) (push) Successful in 42s
CI / Web (React + Vite) (push) Successful in 35s
Docker API / Build + push API (push) Successful in 47s
Docker Web / Build + push Web (push) Has been cancelled
Docker API / Deploy API on stage (push) Failing after 37s

Product card:
- Barcodes moved inside «Основное», before description.
- Description hidden behind new ShowDescriptionOnProduct setting (default false).
- «Закупка» и «Цены продажи» объединены в один блок «Цены».

Supply (приёмка):
- Удалены поля «Дата накладной» и «№ накладной поставщика»
  (избыточны: дата документа уже есть, номер можно положить в Notes).
- Поле «Склад *» скрывается если в системе всего один склад
  — для большинства мелких магазинов лишний клик не нужен.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 01:00:06 +05:00
parent b69ba4950b
commit 86930bb71b
15 changed files with 2114 additions and 180 deletions

View file

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

View file

@ -44,7 +44,6 @@ public record SupplyDto(
Guid SupplierId, string SupplierName,
Guid StoreId, string StoreName,
Guid CurrencyId, string CurrencyCode,
string? SupplierInvoiceNumber, DateTime? SupplierInvoiceDate,
string? Notes,
decimal Total, DateTime? PostedAt,
IReadOnlyList<SupplyLineDto> Lines);
@ -57,7 +56,6 @@ public record SupplyLineInput(
[Range(0, 1e10)] decimal? RetailPriceOverride = null);
public record SupplyInput(
DateTime Date, Guid SupplierId, Guid StoreId, Guid CurrencyId,
string? SupplierInvoiceNumber, DateTime? SupplierInvoiceDate,
string? Notes,
IReadOnlyList<SupplyLineInput> Lines);
@ -135,8 +133,6 @@ public async Task<ActionResult<SupplyDto>> Create([FromBody] SupplyInput input,
SupplierId = input.SupplierId,
StoreId = input.StoreId,
CurrencyId = input.CurrencyId,
SupplierInvoiceNumber = input.SupplierInvoiceNumber,
SupplierInvoiceDate = input.SupplierInvoiceDate,
Notes = input.Notes,
};
@ -177,8 +173,6 @@ public async Task<IActionResult> Update(Guid id, [FromBody] SupplyInput input, C
supply.SupplierId = input.SupplierId;
supply.StoreId = input.StoreId;
supply.CurrencyId = input.CurrencyId;
supply.SupplierInvoiceNumber = input.SupplierInvoiceNumber;
supply.SupplierInvoiceDate = input.SupplierInvoiceDate;
supply.Notes = input.Notes;
// Replace lines wholesale (simple, idempotent).
@ -403,7 +397,6 @@ orderby l.SortOrder
row.cp.Id, row.cp.Name,
row.st.Id, row.st.Name,
row.cu.Id, row.cu.Code,
row.s.SupplierInvoiceNumber, row.s.SupplierInvoiceDate,
row.s.Notes,
row.s.Total, row.s.PostedAt,
lines);

View file

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

View file

@ -26,8 +26,6 @@ public class Supply : TenantEntity
public Guid CurrencyId { get; set; }
public Currency Currency { get; set; } = null!;
public string? SupplierInvoiceNumber { get; set; }
public DateTime? SupplierInvoiceDate { get; set; }
public string? Notes { get; set; }
/// <summary>Sum of line totals. Computed on save.</summary>

View file

@ -11,7 +11,6 @@ public static void ConfigurePurchases(this ModelBuilder b)
{
e.ToTable("supplies");
e.Property(x => x.Number).HasMaxLength(50).IsRequired();
e.Property(x => x.SupplierInvoiceNumber).HasMaxLength(100);
e.Property(x => x.Notes).HasMaxLength(1000);
e.Property(x => x.Total).HasPrecision(18, 4);

View file

@ -1099,6 +1099,9 @@ protected override void BuildTargetModel(ModelBuilder modelBuilder)
b.Property<bool>("ShowCountryOfOriginOnProduct")
.HasColumnType("boolean");
b.Property<bool>("ShowDescriptionOnProduct")
.HasColumnType("boolean");
b.Property<bool>("ShowMarkedOnProduct")
.HasColumnType("boolean");

View file

@ -16,10 +16,14 @@ protected override void Up(MigrationBuilder b)
b.AddColumn<bool>(
name: "ShowCountryOfOriginOnProduct", schema: "public", table: "organizations",
type: "boolean", nullable: false, defaultValue: false);
b.AddColumn<bool>(
name: "ShowDescriptionOnProduct", schema: "public", table: "organizations",
type: "boolean", nullable: false, defaultValue: false);
}
protected override void Down(MigrationBuilder b)
{
b.DropColumn(name: "ShowDescriptionOnProduct", schema: "public", table: "organizations");
b.DropColumn(name: "ShowCountryOfOriginOnProduct", schema: "public", table: "organizations");
b.AddColumn<int>(
name: "ShelfLifeDays", schema: "public", table: "products",

View file

@ -0,0 +1,28 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>supplies.SupplierInvoiceNumber и SupplierInvoiceDate удалены.
/// Решено что эти поля избыточны: дата документа уже есть в supplies.Date,
/// а номер накладной поставщика всегда можно записать в Notes (примечание).</summary>
public partial class Phase3b_DropSupplyInvoiceFields : Migration
{
protected override void Up(MigrationBuilder b)
{
b.DropColumn(name: "SupplierInvoiceDate", schema: "public", table: "supplies");
b.DropColumn(name: "SupplierInvoiceNumber", schema: "public", table: "supplies");
}
protected override void Down(MigrationBuilder b)
{
b.AddColumn<System.DateTime>(
name: "SupplierInvoiceDate", schema: "public", table: "supplies",
type: "timestamp with time zone", nullable: true);
b.AddColumn<string>(
name: "SupplierInvoiceNumber", schema: "public", table: "supplies",
type: "character varying(100)", maxLength: 100, nullable: true);
}
}
}

View file

@ -1096,6 +1096,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<bool>("ShowCountryOfOriginOnProduct")
.HasColumnType("boolean");
b.Property<bool>("ShowDescriptionOnProduct")
.HasColumnType("boolean");
b.Property<bool>("ShowMarkedOnProduct")
.HasColumnType("boolean");
@ -1173,13 +1176,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<Guid>("SupplierId")
.HasColumnType("uuid");
b.Property<DateTime?>("SupplierInvoiceDate")
.HasColumnType("timestamp with time zone");
b.Property<string>("SupplierInvoiceNumber")
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<decimal>("Total")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");

View file

@ -103,7 +103,6 @@ export interface SupplyDto {
supplierId: string; supplierName: string;
storeId: string; storeName: string;
currencyId: string; currencyCode: string;
supplierInvoiceNumber: string | null; supplierInvoiceDate: string | null;
notes: string | null;
total: number; postedAt: string | null;
lines: SupplyLineDto[];

View file

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

View file

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

View file

@ -266,10 +266,70 @@ export function ProductEditPage() {
<Field label="Артикул *">
<TextInput required value={form.article} onChange={(e) => setForm({ ...form, article: e.target.value })} />
</Field>
<Field label="Описание" className="col-span-3">
</Grid>
<div className="mt-4 pt-4 border-t border-slate-100 dark:border-slate-800">
<div className="flex items-center justify-between mb-3">
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500">Штрихкоды</h3>
<Button type="button" variant="secondary" size="sm" onClick={addBarcode}>
<Plus className="w-3.5 h-3.5" /> Добавить
</Button>
</div>
{form.barcodes.length === 0 ? (
<div className="text-sm text-red-600 py-2">У товара должен быть хотя бы один штрихкод.</div>
) : (
<div className="space-y-2">
{form.barcodes.map((b, i) => (
<div key={i} className="grid grid-cols-12 gap-2 items-center">
<div className="col-span-6">
<TextInput placeholder="Код" value={b.code} onChange={(e) => updateBarcode(i, { code: e.target.value })} />
</div>
<div className="col-span-3">
<Select value={b.type} onChange={(e) => {
const newType = Number(e.target.value) as BarcodeType
updateBarcode(i, { type: newType, code: generateBarcode(newType) })
}}>
<option value={BarcodeType.Ean13}>EAN-13</option>
<option value={BarcodeType.Ean8}>EAN-8</option>
<option value={BarcodeType.Code128}>CODE 128</option>
<option value={BarcodeType.Code39}>CODE 39</option>
<option value={BarcodeType.Upca}>UPC-A</option>
<option value={BarcodeType.Upce}>UPC-E</option>
<option value={BarcodeType.Other}>Прочий</option>
</Select>
</div>
<div className="col-span-2">
<Checkbox
label="Основной"
checked={b.isPrimary}
onChange={(v) => setForm({ ...form, barcodes: form.barcodes.map((x, ix) => ({ ...x, isPrimary: ix === i ? v : false })) })}
/>
</div>
{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>
)}
</div>
{org.data?.showDescriptionOnProduct && (
<div className="mt-4 pt-4 border-t border-slate-100 dark:border-slate-800">
<Field label="Описание">
<TextArea rows={2} value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</Field>
</Grid>
</div>
)}
</Section>
<Section title="Классификация">
@ -331,7 +391,39 @@ export function ProductEditPage() {
</div>
</Section>
<Section title="Закупка">
<Section
title="Цены"
action={
<div className="flex items-center gap-2">
{!isNew && id && (
<Button type="button" variant="secondary" size="sm" onClick={async () => {
try {
const res = await api.post<{ retail: number }>(`/api/catalog/products/${id}/recalc-retail`)
const def = priceTypes.data?.find((pt) => pt.isSystem) ?? priceTypes.data?.find((pt) => pt.isRetail) ?? priceTypes.data?.[0]
if (def) {
setForm((f) => {
const has = f.prices.some(p => p.priceTypeId === def.id)
return {
...f,
prices: has
? f.prices.map(p => p.priceTypeId === def.id ? { ...p, amount: res.data.retail } : p)
: [...f.prices, { priceTypeId: def.id, amount: res.data.retail, currencyId: currencies.data?.[0]?.id ?? '' }],
}
})
}
await qc.invalidateQueries({ queryKey: ['/api/catalog/products', id] })
} catch (e) {
const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? 'Ошибка пересчёта'
setError(msg)
}
}}>
Привести к себестоимости
</Button>
)}
</div>
}
>
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500 mb-3">Закупка</h3>
<Grid cols={4}>
{org.data?.showReferencePriceOnProduct && (
<Field label="Эталонная цена">
@ -363,66 +455,14 @@ export function ProductEditPage() {
</Field>
)}
</Grid>
</Section>
{org.data?.showMinMaxStock && (
<AdvancedSection>
<Grid cols={4}>
<Field label="Минимальный остаток (для уведомления)">
<NumberInput
value={form.minStock === '' ? null : Number(form.minStock)}
onChange={(n) => setForm({ ...form, minStock: n == null ? '' : String(n) })}
placeholder="—"
/>
</Field>
<Field label="Максимальный остаток (для автозаказа)">
<NumberInput
value={form.maxStock === '' ? null : Number(form.maxStock)}
onChange={(n) => setForm({ ...form, maxStock: n == null ? '' : String(n) })}
placeholder="—"
/>
</Field>
</Grid>
</AdvancedSection>
)}
<Section
title="Цены продажи"
action={
<div className="flex items-center gap-2">
{!isNew && id && (
<Button type="button" variant="secondary" size="sm" onClick={async () => {
try {
const res = await api.post<{ retail: number }>(`/api/catalog/products/${id}/recalc-retail`)
const def = priceTypes.data?.find((pt) => pt.isSystem) ?? priceTypes.data?.find((pt) => pt.isRetail) ?? priceTypes.data?.[0]
if (def) {
setForm((f) => {
const has = f.prices.some(p => p.priceTypeId === def.id)
return {
...f,
prices: has
? f.prices.map(p => p.priceTypeId === def.id ? { ...p, amount: res.data.retail } : p)
: [...f.prices, { priceTypeId: def.id, amount: res.data.retail, currencyId: currencies.data?.[0]?.id ?? '' }],
}
})
}
await qc.invalidateQueries({ queryKey: ['/api/catalog/products', id] })
} catch (e) {
const msg = (e as { response?: { data?: { error?: string } } }).response?.data?.error ?? 'Ошибка пересчёта'
setError(msg)
}
}}>
Привести к себестоимости
</Button>
)}
</div>
}
>
<div className="mt-5 pt-5 border-t border-slate-100 dark:border-slate-800">
<h3 className="text-xs font-semibold uppercase tracking-wide text-slate-500 mb-3">Цены продажи</h3>
{/* Список цен рендерится по справочнику PriceType: одно поле на каждый
* тип, без выпадашки выбора. Значение хранится в form.prices,
* key = priceTypeId. Для отсутствующих записей при наборе создаётся
* новая, при стирании null Amount (UI пустое). Системная запись
* (IsSystem) и обязательные (IsRequired) помечаются звёздочкой. */}
* новая, при стирании null Amount (UI пустое). Обязательные (IsRequired)
* помечаются звёздочкой. */}
<div className="space-y-3">
{priceTypes.data?.slice().sort((a, b) => a.sortOrder - b.sortOrder || a.name.localeCompare(b.name)).map((pt) => {
const idx = form.prices.findIndex(p => p.priceTypeId === pt.id)
@ -438,7 +478,6 @@ export function ProductEditPage() {
value={row?.amount ?? null}
onChange={(n) => {
if (n == null) {
// Стерли значение — удаляем строку, чтобы не слать 0 как required.
setForm({ ...form, prices: form.prices.filter(x => x.priceTypeId !== pt.id) })
return
}
@ -465,65 +504,35 @@ export function ProductEditPage() {
</div>
)}
</div>
</div>
</Section>
{org.data?.showMinMaxStock && (
<AdvancedSection>
<Grid cols={4}>
<Field label="Минимальный остаток (для уведомления)">
<NumberInput
value={form.minStock === '' ? null : Number(form.minStock)}
onChange={(n) => setForm({ ...form, minStock: n == null ? '' : String(n) })}
placeholder="—"
/>
</Field>
<Field label="Максимальный остаток (для автозаказа)">
<NumberInput
value={form.maxStock === '' ? null : Number(form.maxStock)}
onChange={(n) => setForm({ ...form, maxStock: n == null ? '' : String(n) })}
placeholder="—"
/>
</Field>
</Grid>
</AdvancedSection>
)}
{!isNew && id && (
<Section title="Изображения">
<ProductImageGallery productId={id} />
</Section>
)}
<Section
title="Штрихкоды"
action={<Button type="button" variant="secondary" size="sm" onClick={addBarcode}><Plus className="w-3.5 h-3.5" /> Добавить</Button>}
>
{form.barcodes.length === 0 ? (
<div className="text-sm text-red-600 py-2">У товара должен быть хотя бы один штрихкод.</div>
) : (
<div className="space-y-2">
{form.barcodes.map((b, i) => (
<div key={i} className="grid grid-cols-12 gap-2 items-center">
<div className="col-span-6">
<TextInput placeholder="Код" value={b.code} onChange={(e) => updateBarcode(i, { code: e.target.value })} />
</div>
<div className="col-span-3">
<Select value={b.type} onChange={(e) => {
const newType = Number(e.target.value) as BarcodeType
updateBarcode(i, { type: newType, code: generateBarcode(newType) })
}}>
<option value={BarcodeType.Ean13}>EAN-13</option>
<option value={BarcodeType.Ean8}>EAN-8</option>
<option value={BarcodeType.Code128}>CODE 128</option>
<option value={BarcodeType.Code39}>CODE 39</option>
<option value={BarcodeType.Upca}>UPC-A</option>
<option value={BarcodeType.Upce}>UPC-E</option>
<option value={BarcodeType.Other}>Прочий</option>
</Select>
</div>
<div className="col-span-2">
<Checkbox
label="Основной"
checked={b.isPrimary}
onChange={(v) => setForm({ ...form, barcodes: form.barcodes.map((x, ix) => ({ ...x, isPrimary: ix === i ? v : false })) })}
/>
</div>
{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>
)}
</Section>
</div>
</div>
</form>

View file

@ -30,8 +30,6 @@ interface Form {
supplierId: string
storeId: string
currencyId: string
supplierInvoiceNumber: string
supplierInvoiceDate: string
notes: string
lines: LineRow[]
}
@ -41,7 +39,6 @@ const todayIso = () => new Date().toISOString().slice(0, 10)
const emptyForm: Form = {
date: todayIso(),
supplierId: '', storeId: '', currencyId: '',
supplierInvoiceNumber: '', supplierInvoiceDate: '',
notes: '',
lines: [],
}
@ -75,8 +72,6 @@ export function SupplyEditPage() {
supplierId: s.supplierId,
storeId: s.storeId,
currencyId: s.currencyId,
supplierInvoiceNumber: s.supplierInvoiceNumber ?? '',
supplierInvoiceDate: s.supplierInvoiceDate ? s.supplierInvoiceDate.slice(0, 10) : '',
notes: s.notes ?? '',
lines: s.lines.map((l) => ({
id: l.id ?? undefined,
@ -126,8 +121,6 @@ export function SupplyEditPage() {
supplierId: form.supplierId,
storeId: form.storeId,
currencyId: form.currencyId,
supplierInvoiceNumber: form.supplierInvoiceNumber || null,
supplierInvoiceDate: form.supplierInvoiceDate ? new Date(form.supplierInvoiceDate).toISOString() : null,
notes: form.notes || null,
lines: form.lines.map((l) => ({
productId: l.productId,
@ -276,6 +269,7 @@ export function SupplyEditPage() {
{suppliers.data?.map((c) => <option key={c.id} value={c.id}>{c.name}</option>)}
</Select>
</Field>
{(stores.data?.length ?? 0) > 1 && (
<Field label="Склад *">
<Select value={form.storeId} disabled={isPosted}
onChange={(e) => setForm({ ...form, storeId: e.target.value })}>
@ -283,6 +277,7 @@ export function SupplyEditPage() {
{stores.data?.map((s) => <option key={s.id} value={s.id}>{s.name}</option>)}
</Select>
</Field>
)}
{org.data?.multiCurrencyEnabled && (
<Field label="Валюта *">
<Select value={form.currencyId} disabled={isPosted}
@ -292,14 +287,6 @@ export function SupplyEditPage() {
</Select>
</Field>
)}
<Field label="№ накладной поставщика">
<TextInput value={form.supplierInvoiceNumber} disabled={isPosted}
onChange={(e) => setForm({ ...form, supplierInvoiceNumber: e.target.value })} />
</Field>
<Field label="Дата накладной">
<TextInput type="date" value={form.supplierInvoiceDate} disabled={isPosted}
onChange={(e) => setForm({ ...form, supplierInvoiceDate: e.target.value })} />
</Field>
<Field label="Примечание" className="md:col-span-3">
<TextArea rows={2} value={form.notes} disabled={isPosted}
onChange={(e) => setForm({ ...form, notes: e.target.value })} />