diff --git a/src/food-market.api/Controllers/Catalog/ProductGroupsController.cs b/src/food-market.api/Controllers/Catalog/ProductGroupsController.cs index 0b9ecc8..6fcb838 100644 --- a/src/food-market.api/Controllers/Catalog/ProductGroupsController.cs +++ b/src/food-market.api/Controllers/Catalog/ProductGroupsController.cs @@ -94,8 +94,13 @@ public async Task Update(Guid id, [FromBody] ProductGroupInput in [HttpDelete("{id:guid}"), Authorize(Roles = "Admin,SuperAdmin")] public async Task Delete(Guid id, CancellationToken ct) { - var owner = await _db.ProductGroups.Where(x => x.Id == id).Select(x => x.OrganizationId).FirstOrDefaultAsync(ct); - if (owner is null && !User.IsInRole("SuperAdmin")) return Forbid(); + var meta = await _db.ProductGroups + .Where(x => x.Id == id) + .Select(x => new { x.OrganizationId, x.IsSystem }) + .FirstOrDefaultAsync(ct); + if (meta is null) return NotFound(); + if (meta.OrganizationId is null && !User.IsInRole("SuperAdmin")) return Forbid(); + if (meta.IsSystem) return BadRequest(new { error = "Системную группу удалить нельзя." }); var hasChildren = await _db.ProductGroups.AnyAsync(g => g.ParentId == id, ct); if (hasChildren) return BadRequest(new { error = "Нельзя удалить группу с подгруппами" }); var hasProducts = await _db.Products.AnyAsync(p => p.ProductGroupId == id, ct); diff --git a/src/food-market.api/Seed/DevDataSeeder.cs b/src/food-market.api/Seed/DevDataSeeder.cs index d4d78c8..21b4f15 100644 --- a/src/food-market.api/Seed/DevDataSeeder.cs +++ b/src/food-market.api/Seed/DevDataSeeder.cs @@ -174,6 +174,23 @@ public static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId, // организации все active globals через junction org_units_of_measure. await EnableAllActiveUnitsForOrgAsync(db, orgId, ct); + // Phase5e: системная группа товаров «Все товары». Любой новый продукт, + // если юзер не указал группу явно, должен иметь хоть какую-то — без + // этой группы ProductsController.Create падал с 400 на новой орге. + var hasSystemGroup = await db.ProductGroups.IgnoreQueryFilters() + .AnyAsync(g => g.OrganizationId == orgId && g.IsSystem, ct); + if (!hasSystemGroup) + { + db.ProductGroups.Add(new ProductGroup + { + OrganizationId = orgId, + Name = "Все товары", + Path = "Все товары", + SortOrder = 0, + IsSystem = true, + }); + } + // Сидируем PriceType ТОЛЬКО если у организации не было ни одной записи. // Если есть — никогда не создаём «системную копию», корректность IsSystem // обеспечивает миграция Phase3b_FixPriceTypeIsSystem (выбор по факту: diff --git a/src/food-market.domain/Catalog/ProductGroup.cs b/src/food-market.domain/Catalog/ProductGroup.cs index d091b03..c3ba856 100644 --- a/src/food-market.domain/Catalog/ProductGroup.cs +++ b/src/food-market.domain/Catalog/ProductGroup.cs @@ -18,4 +18,10 @@ public class ProductGroup : Entity, IOptionalTenantEntity /// Процент наценки на себестоимость для автоматического расчёта /// розничной цены при проведении приёмки. NULL = автонаценка отключена. public decimal? MarkupPercent { get; set; } + + /// Системная группа создаётся при bootstrap'е новой org как + /// дефолт, чтобы любой новый продукт мог иметь хоть какую-то группу без + /// предварительной настройки. Удалить или переименовать нельзя — иначе + /// продукты осиротеют. + public bool IsSystem { get; set; } } diff --git a/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs b/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs index 682495b..64ba2ef 100644 --- a/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs +++ b/src/food-market.infrastructure/Persistence/Configurations/CatalogConfigurations.cs @@ -108,6 +108,7 @@ private static void ConfigureProductGroup(EntityTypeBuilder b) b.ToTable("product_groups"); b.Property(x => x.Name).HasMaxLength(200).IsRequired(); b.Property(x => x.Path).HasMaxLength(1000); + b.Property(x => x.IsSystem).HasDefaultValue(false); b.HasOne(x => x.Parent) .WithMany(x => x.Children) .HasForeignKey(x => x.ParentId) diff --git a/src/food-market.infrastructure/Persistence/Migrations/20260508200000_Phase5e_ProductGroupIsSystem.cs b/src/food-market.infrastructure/Persistence/Migrations/20260508200000_Phase5e_ProductGroupIsSystem.cs new file mode 100644 index 0000000..f4c226a --- /dev/null +++ b/src/food-market.infrastructure/Persistence/Migrations/20260508200000_Phase5e_ProductGroupIsSystem.cs @@ -0,0 +1,36 @@ +using Microsoft.EntityFrameworkCore.Infrastructure; +using Microsoft.EntityFrameworkCore.Migrations; +using foodmarket.Infrastructure.Persistence; + +#nullable disable + +namespace foodmarket.Infrastructure.Persistence.Migrations +{ + /// Phase5e — добавляем product_groups.IsSystem (default false). + /// Используется для бутстрап-группы «Все товары», создаваемой при + /// запуске новой org: продукт без явной группы попадает в неё, удалить + /// её нельзя, иначе продукты осиротеют. + [DbContext(typeof(AppDbContext))] + [Migration("20260508200000_Phase5e_ProductGroupIsSystem")] + public partial class Phase5e_ProductGroupIsSystem : Migration + { + protected override void Up(MigrationBuilder b) + { + b.AddColumn( + name: "IsSystem", + schema: "public", + table: "product_groups", + type: "boolean", + nullable: false, + defaultValue: false); + } + + protected override void Down(MigrationBuilder b) + { + b.DropColumn( + name: "IsSystem", + schema: "public", + table: "product_groups"); + } + } +} diff --git a/src/food-market.web/src/components/ProductQuickCreateModal.tsx b/src/food-market.web/src/components/ProductQuickCreateModal.tsx index 50a979f..e64f3b5 100644 --- a/src/food-market.web/src/components/ProductQuickCreateModal.tsx +++ b/src/food-market.web/src/components/ProductQuickCreateModal.tsx @@ -4,7 +4,7 @@ import { api } from '@/lib/api' import { Modal } from '@/components/Modal' import { Button } from '@/components/Button' import { Field, TextInput, Select, AsyncSelect } from '@/components/Field' -import { useUnits } from '@/lib/useLookups' +import { useUnits, useProductGroups } from '@/lib/useLookups' import { useOrgSettings } from '@/lib/useOrgSettings' import { BarcodeType, type Product } from '@/lib/types' import { generateEan13InternalPrefix2 } from '@/lib/barcode' @@ -26,6 +26,7 @@ interface Props { * pre-fill'ятся из набранного запроса в зависимости от типа. */ export function ProductQuickCreateModal({ open, onClose, prefill, onCreated }: Props) { const units = useUnits() + const groups = useProductGroups() const org = useOrgSettings() const [name, setName] = useState('') @@ -66,7 +67,13 @@ export function ProductQuickCreateModal({ open, onClose, prefill, onCreated }: P const sht = units.data.find((u) => u.code === '796') ?? units.data[0] setUnitId(sht.id) } - }, [open, units.data, unitId]) + // Дефолт ProductGroup — системная «Все товары» (Phase5e), либо + // единственная имеющаяся. + if (!groupId && groups.data?.length) { + const sys = groups.data.find((g) => g.name === 'Все товары') ?? (groups.data.length === 1 ? groups.data[0] : undefined) + if (sys) setGroupId(sys.id) + } + }, [open, units.data, groups.data, unitId, groupId]) const create = useMutation({ mutationFn: async () => { diff --git a/src/food-market.web/src/pages/ProductEditPage.tsx b/src/food-market.web/src/pages/ProductEditPage.tsx index 9174924..af788c5 100644 --- a/src/food-market.web/src/pages/ProductEditPage.tsx +++ b/src/food-market.web/src/pages/ProductEditPage.tsx @@ -6,7 +6,7 @@ import { api } from '@/lib/api' import { Button } from '@/components/Button' import { Field, TextInput, TextArea, Select, AsyncSelect, Checkbox, MoneyInput, NumberInput } from '@/components/Field' import { - useUnits, useCountries, useCurrencies, usePriceTypes, + useUnits, useCountries, useCurrencies, usePriceTypes, useProductGroups, } from '@/lib/useLookups' import { useOrgSettings } from '@/lib/useOrgSettings' import { BarcodeType, Packaging, type Product } from '@/lib/types' @@ -57,6 +57,7 @@ export function ProductEditPage() { const qc = useQueryClient() const units = useUnits() + const groups = useProductGroups() const countries = useCountries() const currencies = useCurrencies() const priceTypes = usePriceTypes() @@ -99,6 +100,14 @@ export function ProductEditPage() { setForm((f) => ({ ...f, unitOfMeasureId: sht.id })) } + // Дефолт ProductGroup — системная «Все товары» (Phase5e). Если у орги + // ровно одна группа (тот самый системный bootstrap) — авто-выбираем её, + // чтобы пользователь мог сохранить продукт без дополнительного клика. + if (isNew && form.productGroupId === '' && groups.data?.length) { + const sys = groups.data.find((g) => g.name === 'Все товары') ?? (groups.data.length === 1 ? groups.data[0] : undefined) + if (sys) setForm((f) => ({ ...f, productGroupId: sys.id })) + } + if (isNew && form.purchaseCurrencyId === '' && currencies.data?.length) { const def = org.data?.defaultCurrencyId ? currencies.data?.find(c => c.id === org.data!.defaultCurrencyId) @@ -124,7 +133,7 @@ export function ProductEditPage() { barcodes: [{ code: generateEan13InternalPrefix2(), type: BarcodeType.Ean13, isPrimary: true }], })) } - }, [isNew, units.data, currencies.data, org.data, form.unitOfMeasureId, form.purchaseCurrencyId, form.vat, form.article, form.barcodes.length]) + }, [isNew, units.data, groups.data, currencies.data, org.data, form.unitOfMeasureId, form.productGroupId, form.purchaseCurrencyId, form.vat, form.article, form.barcodes.length]) const save = useMutation({ mutationFn: async () => {