feat(product): группа обязательна, ≥1 штрихкод, умные дефолты на новом
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 26s
CI / Web (React + Vite) (push) Successful in 25s
Docker Images / API image (push) Successful in 38s
Docker Images / Web image (push) Successful in 26s
Docker Images / Deploy stage (push) Successful in 18s

- Product.ProductGroupId теперь NOT NULL (Guid вместо Guid?). Миграция
  Phase5g_RequiredProductGroup делает backfill: создаёт «Продукты
  питания» в каждой организации, у которой есть товары без группы,
  переносит туда null-значения, потом ALTER COLUMN NOT NULL.
- ProductDto/ProductInput: ProductGroupId/Name без `?`.
- ProductsController.Create/Update: 400 если barcodes пустой.
- MoySklad-импорт: при отсутствии productFolder у товара ставится
  defaultGroupId — id «Продукты питания» (создаётся при необходимости).
- DemoCatalogSeeder: «Продукты питания» добавлена в seed-набор групп.
- ProductEditPage:
  • новый товар сразу получает 1 EAN-13 в barcodes,
  • Single-select Единица измерения и Группа лишились опции «—»,
  • дефолт unitOfMeasureId — id единицы code='796' (штука),
  • дефолт productGroupId — «Продукты питания» (или первая),
  • Save disabled пока имя/единица/группа/≥1 штрихкод не заполнены,
  • если штрихкоды удалены — красная подсказка вместо нейтральной.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-25 11:24:10 +05:00
parent 4d19015d6d
commit c2fa47c341
9 changed files with 1992 additions and 16 deletions

View file

@ -105,8 +105,8 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
{
("article", false) => q.OrderBy(p => p.Article).ThenBy(p => p.Name),
("article", true) => q.OrderByDescending(p => p.Article).ThenBy(p => p.Name),
("group", false) => q.OrderBy(p => p.ProductGroup != null ? p.ProductGroup.Name : null).ThenBy(p => p.Name),
("group", true) => q.OrderByDescending(p => p.ProductGroup != null ? p.ProductGroup.Name : null).ThenBy(p => p.Name),
("group", false) => q.OrderBy(p => p.ProductGroup!.Name).ThenBy(p => p.Name),
("group", true) => q.OrderByDescending(p => p.ProductGroup!.Name).ThenBy(p => p.Name),
("unit", false) => q.OrderBy(p => p.UnitOfMeasure!.Name).ThenBy(p => p.Name),
("unit", true) => q.OrderByDescending(p => p.UnitOfMeasure!.Name).ThenBy(p => p.Name),
("packaging", false) => q.OrderBy(p => p.Packaging).ThenBy(p => p.Name),
@ -137,6 +137,8 @@ public async Task<ActionResult<ProductDto>> Get(Guid id, CancellationToken ct)
[HttpPost, Authorize(Roles = "Admin,Manager,Storekeeper")]
public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input, CancellationToken ct)
{
if (input.Barcodes is null || input.Barcodes.Count == 0)
return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." });
var e = new Product();
Apply(e, input);
// Если UI скрывает поле Vat (showVatEnabledOnProduct=false) и прислал null — дефолт из страны.
@ -165,6 +167,8 @@ public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")]
public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input, CancellationToken ct)
{
if (input.Barcodes is null || input.Barcodes.Count == 0)
return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." });
var e = await _db.Products
.Include(p => p.Barcodes)
.Include(p => p.Prices)
@ -225,7 +229,7 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
p.Id, p.Name, p.Article, p.Description,
p.UnitOfMeasureId, p.UnitOfMeasure!.Name,
p.Vat, p.VatEnabled,
p.ProductGroupId, p.ProductGroup != null ? p.ProductGroup.Name : null,
p.ProductGroupId, p.ProductGroup!.Name,
p.DefaultSupplierId, p.DefaultSupplier != null ? p.DefaultSupplier.Name : null,
p.CountryOfOriginId, p.CountryOfOrigin != null ? p.CountryOfOrigin.Name : null,
p.IsService, p.Packaging, p.IsMarked,

View file

@ -72,6 +72,9 @@ Guid AddGroup(string name, Guid? parentId)
return id;
}
// «Продукты питания» — дефолтная группа, в которую попадают новые товары,
// если пользователь не указал группу явно. Не должна удаляться.
AddGroup("Продукты питания", null);
var gDrinks = AddGroup("Напитки", null);
var gDrinksNon = AddGroup("Безалкогольные", gDrinks);
AddGroup("Алкогольные", gDrinks);

View file

@ -42,7 +42,7 @@ public record ProductDto(
Guid Id, string Name, string? Article, string? Description,
Guid UnitOfMeasureId, string UnitName,
decimal Vat, bool VatEnabled,
Guid? ProductGroupId, string? ProductGroupName,
Guid ProductGroupId, string ProductGroupName,
Guid? DefaultSupplierId, string? DefaultSupplierName,
Guid? CountryOfOriginId, string? CountryOfOriginName,
bool IsService, Packaging Packaging, bool IsMarked,
@ -78,7 +78,7 @@ public record ProductPriceInput(Guid PriceTypeId, [Range(0, 1e10)] decimal Amoun
public record ProductInput(
string Name, string? Article, string? Description,
Guid UnitOfMeasureId, [Range(0, 100)] decimal? Vat, bool VatEnabled,
Guid? ProductGroupId, Guid? DefaultSupplierId, Guid? CountryOfOriginId,
Guid ProductGroupId, Guid? DefaultSupplierId, Guid? CountryOfOriginId,
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? PurchasePrice = null, Guid? PurchaseCurrencyId = null,

View file

@ -20,7 +20,7 @@ public class Product : TenantEntity
public decimal Vat { get; set; }
public bool VatEnabled { get; set; } = true;
public Guid? ProductGroupId { get; set; }
public Guid ProductGroupId { get; set; }
public ProductGroup? ProductGroup { get; set; }
public Guid? DefaultSupplierId { get; set; } // основной поставщик

View file

@ -159,6 +159,22 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
.IgnoreQueryFilters()
.ToDictionaryAsync(c => c.Name, c => c.Id, ct);
// Дефолтная группа на случай, когда у товара в MoySklad нет productFolder.
var defaultGroup = await _db.ProductGroups.FirstOrDefaultAsync(g => g.Name == "Продукты питания", ct);
if (defaultGroup is null)
{
defaultGroup = new ProductGroup
{
OrganizationId = orgId,
Name = "Продукты питания",
Path = "Продукты питания",
IsActive = true,
};
_db.ProductGroups.Add(defaultGroup);
await _db.SaveChangesAsync(ct);
}
var defaultGroupId = defaultGroup.Id;
// Import folders first — build flat then link parents. Архивные тоже берём,
// помечаем IsActive=false — у MoySklad у productfolder есть archived.
var folders = await _client.GetAllFoldersAsync(token, ct);
@ -262,7 +278,7 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
UnitOfMeasureId = baseUnit.Id,
Vat = vat,
VatEnabled = vatEnabled,
ProductGroupId = groupId,
ProductGroupId = groupId ?? defaultGroupId,
CountryOfOriginId = countryId,
Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece,
IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED",

View file

@ -0,0 +1,52 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>products.ProductGroupId становится NOT NULL.
/// Backfill: для каждой организации с товарами без группы создаётся
/// (если ещё нет) группа «Продукты питания», все NULL-товары
/// привязываются к ней; затем колонка ставится NOT NULL.</summary>
public partial class Phase5g_RequiredProductGroup : Migration
{
protected override void Up(MigrationBuilder b)
{
b.Sql("""
INSERT INTO public.product_groups
("Id", "OrganizationId", "Name", "Path", "SortOrder", "IsActive", "CreatedAt")
SELECT gen_random_uuid(), o."OrganizationId", 'Продукты питания',
'Продукты питания', 0, true, now() AT TIME ZONE 'UTC'
FROM (
SELECT DISTINCT "OrganizationId" FROM public.products WHERE "ProductGroupId" IS NULL
) o
WHERE NOT EXISTS (
SELECT 1 FROM public.product_groups pg
WHERE pg."OrganizationId" = o."OrganizationId" AND pg."Name" = 'Продукты питания'
);
""");
b.Sql("""
UPDATE public.products p
SET "ProductGroupId" = pg."Id"
FROM public.product_groups pg
WHERE pg."OrganizationId" = p."OrganizationId"
AND pg."Name" = 'Продукты питания'
AND p."ProductGroupId" IS NULL;
""");
b.AlterColumn<System.Guid>(
name: "ProductGroupId", schema: "public", table: "products",
type: "uuid", nullable: false,
oldClrType: typeof(System.Guid), oldType: "uuid", oldNullable: true);
}
protected override void Down(MigrationBuilder b)
{
b.AlterColumn<System.Guid>(
name: "ProductGroupId", schema: "public", table: "products",
type: "uuid", nullable: true,
oldClrType: typeof(System.Guid), oldType: "uuid", oldNullable: false);
}
}
}

View file

@ -591,7 +591,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<Guid>("OrganizationId")
.HasColumnType("uuid");
b.Property<Guid?>("ProductGroupId")
b.Property<Guid>("ProductGroupId")
.HasColumnType("uuid");
b.Property<Guid?>("PurchaseCurrencyId")

View file

@ -98,7 +98,14 @@ export function ProductEditPage() {
useEffect(() => {
if (isNew && form.unitOfMeasureId === '' && units.data?.length) {
setForm((f) => ({ ...f, unitOfMeasureId: units.data?.[0]?.id ?? '' }))
// Дефолт — «штука» (ОКЕИ 796), иначе первая.
const sht = units.data.find((u) => u.code === '796') ?? units.data[0]
setForm((f) => ({ ...f, unitOfMeasureId: sht.id }))
}
if (isNew && form.productGroupId === '' && groups.data?.length) {
// Дефолт — «Продукты питания», иначе первая.
const food = groups.data.find((g) => g.name === 'Продукты питания') ?? groups.data[0]
setForm((f) => ({ ...f, productGroupId: food.id }))
}
if (isNew && form.purchaseCurrencyId === '' && currencies.data?.length) {
const def = org.data?.defaultCurrencyId
@ -110,7 +117,15 @@ export function ProductEditPage() {
if (isNew && org.data && form.vat === 16 && org.data.vatRate !== 16) {
setForm((f) => ({ ...f, vat: org.data!.vatRate }))
}
}, [isNew, units.data, currencies.data, org.data, form.unitOfMeasureId, form.purchaseCurrencyId, form.vat])
// У нового товара сразу один сгенерированный штрихкод, чтобы кнопку Сохранить
// можно было нажать без лишних кликов.
if (isNew && form.barcodes.length === 0) {
setForm((f) => ({
...f,
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])
const save = useMutation({
mutationFn: async () => {
@ -178,7 +193,10 @@ export function ProductEditPage() {
const updateBarcode = (i: number, patch: Partial<BarcodeRow>) =>
setForm({ ...form, barcodes: form.barcodes.map((b, ix) => ix === i ? { ...b, ...patch } : b) })
const canSave = form.name.trim().length > 0 && !!form.unitOfMeasureId
const canSave = form.name.trim().length > 0
&& !!form.unitOfMeasureId
&& !!form.productGroupId
&& form.barcodes.length > 0
return (
<form onSubmit={onSubmit} className="flex flex-col h-full">
@ -243,13 +261,11 @@ export function ProductEditPage() {
<Grid cols={3}>
<Field label="Единица измерения *">
<Select required value={form.unitOfMeasureId} onChange={(e) => setForm({ ...form, unitOfMeasureId: e.target.value })}>
<option value=""></option>
{units.data?.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
</Select>
</Field>
<Field label="Группа">
<Select value={form.productGroupId} onChange={(e) => setForm({ ...form, productGroupId: e.target.value })}>
<option value=""></option>
<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>
@ -396,7 +412,7 @@ export function ProductEditPage() {
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-slate-400 py-2">Штрихкодов нет.</div>
<div className="text-sm text-red-600 py-2">У товара должен быть хотя бы один штрихкод.</div>
) : (
<div className="space-y-2">
{form.barcodes.map((b, i) => (