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:
parent
337e790eab
commit
d93edcae2c
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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; } // минимальный остаток (для уведомлений)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
1875
src/food-market.infrastructure/Persistence/Migrations/20260424002000_Phase4b_ProductPackaging.Designer.cs
generated
Normal file
1875
src/food-market.infrastructure/Persistence/Migrations/20260424002000_Phase4b_ProductPackaging.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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")
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 && (
|
||||
|
|
|
|||
Loading…
Reference in a new issue