feat(product): enum Packaging (штучный/весовой/разливной) вместо IsWeighed

Миграция Phase4b_ProductPackaging:
  products.IsWeighed (bool) → products.Packaging (int enum)
  1=Piece (default), 2=Weight, 3=Liquid
Backfill: прежние весовые товары → Weight.

Domain/DTO/Input/Controller/Seeder/OtherSystemImport — всё обновлено.

Web:
- Packaging enum в types.ts.
- ProductEditPage: select "Фасовка" вместо checkbox "Весовой".
- Подпись чекбокса НДС уточнена: "НДС применяется (ставка выше)" —
  ссылается на поле Vat на товаре.
- Удалён IsMarked checkbox текст → "Маркируемый (Честный знак / Datamatrix)".
- ProductsPage фильтр: select Packaging вместо Tri(IsWeighed).
This commit is contained in:
nurdotnet 2026-04-24 11:08:43 +05:00
parent 337e790eab
commit d93edcae2c
12 changed files with 1970 additions and 25 deletions

View file

@ -22,7 +22,7 @@ public class ProductsController : ControllerBase
[FromQuery] PagedRequest req,
[FromQuery] Guid? groupId,
[FromQuery] bool? isService,
[FromQuery] bool? isWeighed,
[FromQuery] Packaging? packaging,
[FromQuery] bool? isMarked,
[FromQuery] bool? isActive,
[FromQuery] bool? hasBarcode,
@ -45,7 +45,7 @@ public class ProductsController : ControllerBase
}
}
if (isService is not null) q = q.Where(p => p.IsService == isService);
if (isWeighed is not null) q = q.Where(p => p.IsWeighed == isWeighed);
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 (isActive is not null) q = q.Where(p => p.IsActive == isActive);
if (hasBarcode is not null)
@ -149,7 +149,7 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
p.ProductGroupId, p.ProductGroup != null ? p.ProductGroup.Name : null,
p.DefaultSupplierId, p.DefaultSupplier != null ? p.DefaultSupplier.Name : null,
p.CountryOfOriginId, p.CountryOfOrigin != null ? p.CountryOfOrigin.Name : null,
p.IsService, p.IsWeighed, p.IsMarked,
p.IsService, p.Packaging, p.IsMarked,
p.MinStock, p.MaxStock,
p.PurchasePrice, p.PurchaseCurrencyId, p.PurchaseCurrency != null ? p.PurchaseCurrency.Code : null,
p.ImageUrl, p.IsActive,
@ -168,7 +168,7 @@ private static void Apply(Product e, ProductInput i)
e.DefaultSupplierId = i.DefaultSupplierId;
e.CountryOfOriginId = i.CountryOfOriginId;
e.IsService = i.IsService;
e.IsWeighed = i.IsWeighed;
e.Packaging = i.Packaging;
e.IsMarked = i.IsMarked;
e.MinStock = i.MinStock;
e.MaxStock = i.MaxStock;

View file

@ -161,7 +161,7 @@ Guid AddGroup(string name, Guid? parentId)
VatEnabled = !(d.Name.Contains("Хлеб") || d.Name.Contains("Батон") || d.Name.Contains("Лепёшка")),
ProductGroupId = d.Group,
CountryOfOriginId = d.Country,
IsWeighed = d.IsWeighed,
Packaging = d.IsWeighed ? Packaging.Weight : Packaging.Piece,
IsActive = true,
PurchasePrice = Math.Round(d.RetailPrice * 0.72m, 2),
PurchaseCurrencyId = kzt.Id,

View file

@ -40,7 +40,7 @@ public record ProductDto(
Guid? ProductGroupId, string? ProductGroupName,
Guid? DefaultSupplierId, string? DefaultSupplierName,
Guid? CountryOfOriginId, string? CountryOfOriginName,
bool IsService, bool IsWeighed, bool IsMarked,
bool IsService, Packaging Packaging, bool IsMarked,
decimal? MinStock, decimal? MaxStock,
decimal? PurchasePrice, Guid? PurchaseCurrencyId, string? PurchaseCurrencyCode,
string? ImageUrl, bool IsActive,
@ -72,7 +72,7 @@ public record ProductInput(
string Name, string? Article, string? Description,
Guid UnitOfMeasureId, int Vat, bool VatEnabled,
Guid? ProductGroupId, Guid? DefaultSupplierId, Guid? CountryOfOriginId,
bool IsService = false, bool IsWeighed = false, bool IsMarked = false,
bool IsService = false, Packaging Packaging = Packaging.Piece, bool IsMarked = false,
decimal? MinStock = null, decimal? MaxStock = null,
decimal? PurchasePrice = null, Guid? PurchaseCurrencyId = null,
string? ImageUrl = null, bool IsActive = true,

View file

@ -6,6 +6,16 @@ public enum CounterpartyType
Individual = 2,
}
/// <summary>Фасовка товара: как продаётся и учитывается в остатках.
/// Piece — штучный товар (1 шт), по умолчанию. Weight — весовой (кг, г), продаётся с весов.
/// Liquid — разливной (л), продаётся из тары на разлив.</summary>
public enum Packaging
{
Piece = 1,
Weight = 2,
Liquid = 3,
}
public enum BarcodeType
{
Ean13 = 1,

View file

@ -26,7 +26,7 @@ public class Product : TenantEntity
public Country? CountryOfOrigin { get; set; }
public bool IsService { get; set; } // услуга, а не физический товар
public bool IsWeighed { get; set; } // весовой (продаётся с весов)
public Packaging Packaging { get; set; } = Packaging.Piece; // фасовка (штучный/весовой/разливной)
public bool IsMarked { get; set; } // маркируемый (Datamatrix)
public decimal? MinStock { get; set; } // минимальный остаток (для уведомлений)

View file

@ -240,7 +240,7 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
product.VatEnabled = vatEnabled;
product.ProductGroupId = groupId ?? product.ProductGroupId;
product.CountryOfOriginId = countryId ?? product.CountryOfOriginId;
product.IsWeighed = p.Weighed;
product.Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece;
product.IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED";
product.IsActive = !p.Archived;
product.PurchasePrice = p.BuyPrice is null ? product.PurchasePrice : p.BuyPrice.Value / 100m;
@ -260,7 +260,7 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
VatEnabled = vatEnabled,
ProductGroupId = groupId,
CountryOfOriginId = countryId,
IsWeighed = p.Weighed,
Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece,
IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED",
IsActive = !p.Archived,
PurchasePrice = p.BuyPrice is null ? null : p.BuyPrice.Value / 100m,

View file

@ -0,0 +1,32 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Заменяем products.IsWeighed (bool) на products.Packaging (int enum):
/// 1=Piece, 2=Weight, 3=Liquid. Штучный по умолчанию.</summary>
public partial class Phase4b_ProductPackaging : Migration
{
protected override void Up(MigrationBuilder b)
{
b.AddColumn<int>(
name: "Packaging", schema: "public", table: "products",
type: "integer", nullable: false, defaultValue: 1);
// Backfill: IsWeighed=true → Weight, иначе Piece.
b.Sql("""UPDATE public.products SET "Packaging" = CASE WHEN "IsWeighed" THEN 2 ELSE 1 END;""");
b.DropColumn(name: "IsWeighed", schema: "public", table: "products");
}
protected override void Down(MigrationBuilder b)
{
b.AddColumn<bool>(
name: "IsWeighed", schema: "public", table: "products",
type: "boolean", nullable: false, defaultValue: false);
b.Sql("""UPDATE public.products SET "IsWeighed" = ("Packaging" = 2);""");
b.DropColumn(name: "Packaging", schema: "public", table: "products");
}
}
}

View file

@ -574,7 +574,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<bool>("IsService")
.HasColumnType("boolean");
b.Property<bool>("IsWeighed")
b.Property<int>("Packaging")
.HasColumnType("boolean");
b.Property<decimal?>("MaxStock")

View file

@ -12,6 +12,14 @@ export type CounterpartyType = (typeof CounterpartyType)[keyof typeof Counterpar
export const BarcodeType = { Ean13: 1, Ean8: 2, Code128: 3, Code39: 4, Upca: 5, Upce: 6, Other: 99 } as const
export type BarcodeType = (typeof BarcodeType)[keyof typeof BarcodeType]
export const Packaging = { Piece: 1, Weight: 2, Liquid: 3 } as const
export type Packaging = (typeof Packaging)[keyof typeof Packaging]
export const packagingLabel: Record<Packaging, string> = {
[Packaging.Piece]: 'Штучный',
[Packaging.Weight]: 'Весовой',
[Packaging.Liquid]: 'Разливной',
}
export interface Country { id: string; code: string; name: string; sortOrder: number }
export interface Currency { id: string; code: string; name: string; symbol: string; minorUnit: number; isActive: boolean }
export interface UnitOfMeasure { id: string; code: string; name: string; description: string | null; isActive: boolean }
@ -41,7 +49,7 @@ export interface Product {
productGroupId: string | null; productGroupName: string | null;
defaultSupplierId: string | null; defaultSupplierName: string | null;
countryOfOriginId: string | null; countryOfOriginName: string | null;
isService: boolean; isWeighed: boolean; isMarked: boolean;
isService: boolean; packaging: Packaging; isMarked: boolean;
minStock: number | null; maxStock: number | null;
purchasePrice: number | null; purchaseCurrencyId: string | null; purchaseCurrencyCode: string | null;
imageUrl: string | null; isActive: boolean;

View file

@ -9,7 +9,7 @@ import {
useUnits, useProductGroups, useCountries, useCurrencies, usePriceTypes, useSuppliers,
} from '@/lib/useLookups'
import { useOrgSettings } from '@/lib/useOrgSettings'
import { BarcodeType, type Product } from '@/lib/types'
import { BarcodeType, Packaging, type Product } from '@/lib/types'
interface PriceRow { id?: string; priceTypeId: string; amount: number; currencyId: string }
interface BarcodeRow { id?: string; code: string; type: BarcodeType; isPrimary: boolean }
@ -25,7 +25,7 @@ interface Form {
defaultSupplierId: string
countryOfOriginId: string
isService: boolean
isWeighed: boolean
packaging: Packaging
isMarked: boolean
isActive: boolean
minStock: string
@ -45,7 +45,7 @@ const emptyForm: Form = {
name: '', article: '', description: '',
unitOfMeasureId: '', vat: defaultVat, vatEnabled: true,
productGroupId: '', defaultSupplierId: '', countryOfOriginId: '',
isService: false, isWeighed: false, isMarked: false, isActive: true,
isService: false, packaging: Packaging.Piece, isMarked: false, isActive: true,
minStock: '', maxStock: '',
purchasePrice: '', purchaseCurrencyId: '',
imageUrl: '',
@ -84,7 +84,7 @@ export function ProductEditPage() {
unitOfMeasureId: p.unitOfMeasureId, vat: p.vat, vatEnabled: p.vatEnabled,
productGroupId: p.productGroupId ?? '', defaultSupplierId: p.defaultSupplierId ?? '',
countryOfOriginId: p.countryOfOriginId ?? '',
isService: p.isService, isWeighed: p.isWeighed, isMarked: p.isMarked,
isService: p.isService, packaging: p.packaging, isMarked: p.isMarked,
isActive: p.isActive,
minStock: p.minStock?.toString() ?? '',
maxStock: p.maxStock?.toString() ?? '',
@ -126,7 +126,7 @@ export function ProductEditPage() {
defaultSupplierId: form.defaultSupplierId || null,
countryOfOriginId: form.countryOfOriginId || null,
isService: form.isService,
isWeighed: form.isWeighed,
packaging: form.packaging,
isMarked: form.isMarked,
isActive: form.isActive,
minStock: form.minStock === '' ? null : Number(form.minStock),
@ -273,11 +273,19 @@ export function ProductEditPage() {
<TextInput value={form.imageUrl} onChange={(e) => setForm({ ...form, imageUrl: e.target.value })} />
</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>
<option value={Packaging.Weight}>Весовой</option>
<option value={Packaging.Liquid}>Разливной</option>
</Select>
</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">
<Checkbox label="НДС применяется" checked={form.vatEnabled} onChange={(v) => setForm({ ...form, vatEnabled: v })} />
<Checkbox label="НДС применяется (ставка выше)" checked={form.vatEnabled} onChange={(v) => setForm({ ...form, vatEnabled: v })} />
<Checkbox label="Услуга" checked={form.isService} onChange={(v) => setForm({ ...form, isService: v })} />
<Checkbox label="Весовой" checked={form.isWeighed} onChange={(v) => setForm({ ...form, isWeighed: v })} />
<Checkbox label="Маркируемый" checked={form.isMarked} onChange={(v) => setForm({ ...form, isMarked: v })} />
<Checkbox label="Маркируемый (Честный знак / Datamatrix)" checked={form.isMarked} onChange={(v) => setForm({ ...form, isMarked: v })} />
<Checkbox label="Активен" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
</div>
</Section>

View file

@ -17,7 +17,7 @@ interface Filters {
groupId: string | null
isActive: TriFilter
isService: TriFilter
isWeighed: TriFilter
packaging: number | null // null = все, 1/2/3 = Piece/Weight/Liquid
isMarked: TriFilter
hasBarcode: TriFilter
}
@ -26,7 +26,7 @@ const defaultFilters: Filters = {
groupId: null,
isActive: 'yes',
isService: 'all',
isWeighed: 'all',
packaging: null,
isMarked: 'all',
hasBarcode: 'all',
}
@ -36,7 +36,7 @@ const toExtra = (f: Filters): Record<string, string | number | boolean | undefin
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.isWeighed !== 'all') e.isWeighed = f.isWeighed === 'yes'
if (f.packaging) e.packaging = f.packaging
if (f.isMarked !== 'all') e.isMarked = f.isMarked === 'yes'
if (f.hasBarcode !== 'all') e.hasBarcode = f.hasBarcode === 'yes'
return e
@ -47,7 +47,7 @@ const activeFilterCount = (f: Filters) => {
if (f.groupId) n++
if (f.isActive !== 'yes') n++ // 'yes' is default, count non-default
if (f.isService !== 'all') n++
if (f.isWeighed !== 'all') n++
if (f.packaging) n++
if (f.isMarked !== 'all') n++
if (f.hasBarcode !== 'all') n++
return n
@ -137,7 +137,19 @@ export function ProductsPage() {
<div className="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-4 items-center">
<Tri label="Активные" value={filters.isActive} onChange={(v) => { setFilters({ ...filters, isActive: v }); setPage(1) }} />
<Tri label="Услуга" value={filters.isService} onChange={(v) => { setFilters({ ...filters, isService: v }); setPage(1) }} />
<Tri label="Весовой" value={filters.isWeighed} onChange={(v) => { setFilters({ ...filters, isWeighed: v }); setPage(1) }} />
<div className="flex items-center gap-2 text-xs">
<span className="text-slate-500">Фасовка</span>
<select
value={filters.packaging ?? ''}
onChange={(e) => { const v = e.target.value; setFilters({ ...filters, packaging: v ? Number(v) : null }); setPage(1) }}
className="rounded border border-slate-200 dark:border-slate-700 bg-white dark:bg-slate-900 px-2 py-0.5 text-xs"
>
<option value="">все</option>
<option value="1">штучный</option>
<option value="2">весовой</option>
<option value="3">разливной</option>
</select>
</div>
<Tri label="Маркируемый" value={filters.isMarked} onChange={(v) => { setFilters({ ...filters, isMarked: v }); setPage(1) }} />
<Tri label="Со штрихкодом" value={filters.hasBarcode} onChange={(v) => { setFilters({ ...filters, hasBarcode: v }); setPage(1) }} yesLabel="есть" noLabel="нет" />
{activeCount > 0 && (