feat(product): группа обязательна, ≥1 штрихкод, умные дефолты на новом
- Product.ProductGroupId теперь NOT NULL (Guid вместо Guid?). Миграция Phase5g_RequiredProductGroup делает backfill: создаёт «Продукты питания» в каждой организации, у которой есть товары без группы, переносит туда null-значения, потом ALTER COLUMN NOT NULL. - ProductDto/ProductInput: ProductGroupId/Name без `?`. - ProductsController.Create/Update: 400 если barcodes пустой. - OtherSystem-импорт: при отсутствии 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:
parent
23e29be21b
commit
38f7725593
|
|
@ -105,8 +105,8 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
|
||||||
{
|
{
|
||||||
("article", false) => q.OrderBy(p => p.Article).ThenBy(p => p.Name),
|
("article", false) => q.OrderBy(p => p.Article).ThenBy(p => p.Name),
|
||||||
("article", true) => q.OrderByDescending(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", false) => q.OrderBy(p => p.ProductGroup!.Name).ThenBy(p => p.Name),
|
||||||
("group", true) => q.OrderByDescending(p => p.ProductGroup != null ? p.ProductGroup.Name : null).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", false) => q.OrderBy(p => p.UnitOfMeasure!.Name).ThenBy(p => p.Name),
|
||||||
("unit", true) => q.OrderByDescending(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),
|
("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")]
|
[HttpPost, Authorize(Roles = "Admin,Manager,Storekeeper")]
|
||||||
public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input, CancellationToken ct)
|
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();
|
var e = new Product();
|
||||||
Apply(e, input);
|
Apply(e, input);
|
||||||
// Если UI скрывает поле Vat (showVatEnabledOnProduct=false) и прислал null — дефолт из страны.
|
// Если 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")]
|
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,Storekeeper")]
|
||||||
public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input, CancellationToken ct)
|
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
|
var e = await _db.Products
|
||||||
.Include(p => p.Barcodes)
|
.Include(p => p.Barcodes)
|
||||||
.Include(p => p.Prices)
|
.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.Id, p.Name, p.Article, p.Description,
|
||||||
p.UnitOfMeasureId, p.UnitOfMeasure!.Name,
|
p.UnitOfMeasureId, p.UnitOfMeasure!.Name,
|
||||||
p.Vat, p.VatEnabled,
|
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.DefaultSupplierId, p.DefaultSupplier != null ? p.DefaultSupplier.Name : null,
|
||||||
p.CountryOfOriginId, p.CountryOfOrigin != null ? p.CountryOfOrigin.Name : null,
|
p.CountryOfOriginId, p.CountryOfOrigin != null ? p.CountryOfOrigin.Name : null,
|
||||||
p.IsService, p.Packaging, p.IsMarked,
|
p.IsService, p.Packaging, p.IsMarked,
|
||||||
|
|
|
||||||
|
|
@ -72,6 +72,9 @@ Guid AddGroup(string name, Guid? parentId)
|
||||||
return id;
|
return id;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// «Продукты питания» — дефолтная группа, в которую попадают новые товары,
|
||||||
|
// если пользователь не указал группу явно. Не должна удаляться.
|
||||||
|
AddGroup("Продукты питания", null);
|
||||||
var gDrinks = AddGroup("Напитки", null);
|
var gDrinks = AddGroup("Напитки", null);
|
||||||
var gDrinksNon = AddGroup("Безалкогольные", gDrinks);
|
var gDrinksNon = AddGroup("Безалкогольные", gDrinks);
|
||||||
AddGroup("Алкогольные", gDrinks);
|
AddGroup("Алкогольные", gDrinks);
|
||||||
|
|
|
||||||
|
|
@ -42,7 +42,7 @@ public record ProductDto(
|
||||||
Guid Id, string Name, string? Article, string? Description,
|
Guid Id, string Name, string? Article, string? Description,
|
||||||
Guid UnitOfMeasureId, string UnitName,
|
Guid UnitOfMeasureId, string UnitName,
|
||||||
decimal Vat, bool VatEnabled,
|
decimal Vat, bool VatEnabled,
|
||||||
Guid? ProductGroupId, string? ProductGroupName,
|
Guid ProductGroupId, string ProductGroupName,
|
||||||
Guid? DefaultSupplierId, string? DefaultSupplierName,
|
Guid? DefaultSupplierId, string? DefaultSupplierName,
|
||||||
Guid? CountryOfOriginId, string? CountryOfOriginName,
|
Guid? CountryOfOriginId, string? CountryOfOriginName,
|
||||||
bool IsService, Packaging Packaging, bool IsMarked,
|
bool IsService, Packaging Packaging, bool IsMarked,
|
||||||
|
|
@ -78,7 +78,7 @@ public record ProductPriceInput(Guid PriceTypeId, [Range(0, 1e10)] decimal Amoun
|
||||||
public record ProductInput(
|
public record ProductInput(
|
||||||
string Name, string? Article, string? Description,
|
string Name, string? Article, string? Description,
|
||||||
Guid UnitOfMeasureId, [Range(0, 100)] decimal? Vat, bool VatEnabled,
|
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,
|
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? MinStock = null, [Range(0, 1e10)] decimal? MaxStock = null,
|
||||||
[Range(0, 1e10)] decimal? PurchasePrice = null, Guid? PurchaseCurrencyId = null,
|
[Range(0, 1e10)] decimal? PurchasePrice = null, Guid? PurchaseCurrencyId = null,
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,7 @@ public class Product : TenantEntity
|
||||||
public decimal Vat { get; set; }
|
public decimal Vat { get; set; }
|
||||||
public bool VatEnabled { get; set; } = true;
|
public bool VatEnabled { get; set; } = true;
|
||||||
|
|
||||||
public Guid? ProductGroupId { get; set; }
|
public Guid ProductGroupId { get; set; }
|
||||||
public ProductGroup? ProductGroup { get; set; }
|
public ProductGroup? ProductGroup { get; set; }
|
||||||
|
|
||||||
public Guid? DefaultSupplierId { get; set; } // основной поставщик
|
public Guid? DefaultSupplierId { get; set; } // основной поставщик
|
||||||
|
|
|
||||||
|
|
@ -159,6 +159,22 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
|
||||||
.IgnoreQueryFilters()
|
.IgnoreQueryFilters()
|
||||||
.ToDictionaryAsync(c => c.Name, c => c.Id, ct);
|
.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. Архивные тоже берём,
|
// Import folders first — build flat then link parents. Архивные тоже берём,
|
||||||
// помечаем IsActive=false — у MoySklad у productfolder есть archived.
|
// помечаем IsActive=false — у MoySklad у productfolder есть archived.
|
||||||
var folders = await _client.GetAllFoldersAsync(token, ct);
|
var folders = await _client.GetAllFoldersAsync(token, ct);
|
||||||
|
|
@ -262,7 +278,7 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
|
||||||
UnitOfMeasureId = baseUnit.Id,
|
UnitOfMeasureId = baseUnit.Id,
|
||||||
Vat = vat,
|
Vat = vat,
|
||||||
VatEnabled = vatEnabled,
|
VatEnabled = vatEnabled,
|
||||||
ProductGroupId = groupId,
|
ProductGroupId = groupId ?? defaultGroupId,
|
||||||
CountryOfOriginId = countryId,
|
CountryOfOriginId = countryId,
|
||||||
Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece,
|
Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece,
|
||||||
IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED",
|
IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED",
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -591,7 +591,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
b.Property<Guid>("OrganizationId")
|
b.Property<Guid>("OrganizationId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<Guid?>("ProductGroupId")
|
b.Property<Guid>("ProductGroupId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<Guid?>("PurchaseCurrencyId")
|
b.Property<Guid?>("PurchaseCurrencyId")
|
||||||
|
|
|
||||||
|
|
@ -98,7 +98,14 @@ export function ProductEditPage() {
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (isNew && form.unitOfMeasureId === '' && units.data?.length) {
|
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) {
|
if (isNew && form.purchaseCurrencyId === '' && currencies.data?.length) {
|
||||||
const def = org.data?.defaultCurrencyId
|
const def = org.data?.defaultCurrencyId
|
||||||
|
|
@ -110,7 +117,15 @@ export function ProductEditPage() {
|
||||||
if (isNew && org.data && form.vat === 16 && org.data.vatRate !== 16) {
|
if (isNew && org.data && form.vat === 16 && org.data.vatRate !== 16) {
|
||||||
setForm((f) => ({ ...f, vat: org.data!.vatRate }))
|
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({
|
const save = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
|
|
@ -178,7 +193,10 @@ export function ProductEditPage() {
|
||||||
const updateBarcode = (i: number, patch: Partial<BarcodeRow>) =>
|
const updateBarcode = (i: number, patch: Partial<BarcodeRow>) =>
|
||||||
setForm({ ...form, barcodes: form.barcodes.map((b, ix) => ix === i ? { ...b, ...patch } : b) })
|
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 (
|
return (
|
||||||
<form onSubmit={onSubmit} className="flex flex-col h-full">
|
<form onSubmit={onSubmit} className="flex flex-col h-full">
|
||||||
|
|
@ -243,13 +261,11 @@ export function ProductEditPage() {
|
||||||
<Grid cols={3}>
|
<Grid cols={3}>
|
||||||
<Field label="Единица измерения *">
|
<Field label="Единица измерения *">
|
||||||
<Select required value={form.unitOfMeasureId} onChange={(e) => setForm({ ...form, unitOfMeasureId: e.target.value })}>
|
<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>)}
|
{units.data?.map((u) => <option key={u.id} value={u.id}>{u.name}</option>)}
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</Field>
|
||||||
<Field label="Группа">
|
<Field label="Группа *">
|
||||||
<Select value={form.productGroupId} onChange={(e) => setForm({ ...form, productGroupId: e.target.value })}>
|
<Select required value={form.productGroupId} onChange={(e) => setForm({ ...form, productGroupId: e.target.value })}>
|
||||||
<option value="">—</option>
|
|
||||||
{groups.data?.map((g) => <option key={g.id} value={g.id}>{g.path}</option>)}
|
{groups.data?.map((g) => <option key={g.id} value={g.id}>{g.path}</option>)}
|
||||||
</Select>
|
</Select>
|
||||||
</Field>
|
</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>}
|
action={<Button type="button" variant="secondary" size="sm" onClick={addBarcode}><Plus className="w-3.5 h-3.5" /> Добавить</Button>}
|
||||||
>
|
>
|
||||||
{form.barcodes.length === 0 ? (
|
{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">
|
<div className="space-y-2">
|
||||||
{form.barcodes.map((b, i) => (
|
{form.barcodes.map((b, i) => (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue