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
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:
parent
4d19015d6d
commit
c2fa47c341
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -72,6 +72,9 @@ Guid AddGroup(string name, Guid? parentId)
|
|||
return id;
|
||||
}
|
||||
|
||||
// «Продукты питания» — дефолтная группа, в которую попадают новые товары,
|
||||
// если пользователь не указал группу явно. Не должна удаляться.
|
||||
AddGroup("Продукты питания", null);
|
||||
var gDrinks = AddGroup("Напитки", null);
|
||||
var gDrinksNon = AddGroup("Безалкогольные", gDrinks);
|
||||
AddGroup("Алкогольные", gDrinks);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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; } // основной поставщик
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
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")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("ProductGroupId")
|
||||
b.Property<Guid>("ProductGroupId")
|
||||
.HasColumnType("uuid");
|
||||
|
||||
b.Property<Guid?>("PurchaseCurrencyId")
|
||||
|
|
|
|||
|
|
@ -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) => (
|
||||
|
|
|
|||
Loading…
Reference in a new issue