feat(bootstrap): системная ProductGroup «Все товары» при создании org
Гэп из e2e-отчёта: новая орга стартует с пустым каталогом групп, и ProductsController.Create падает с 400 «ProductGroupId required» пока юзер вручную не заведёт группу. Это плохой UX — особенно для quick- create товара из чека или приёмки. Что сделано: - ProductGroup получил поле IsSystem (default false) + миграция Phase5e. - DevDataSeeder.SeedTenantReferencesAsync теперь создаёт идемпотентно системную группу «Все товары» (IsSystem=true) при bootstrap'е новой org. Та же логика срабатывает в SuperAdminOrganizationsController.Create и AuthSignupController, потому что оба зовут SeedTenantReferencesAsync. - ProductGroupsController.Delete: системная группа защищена от удаления (400 «Системную группу удалить нельзя.»). Иначе продукты могли бы осиротеть после ON DELETE RESTRICT. - ProductEditPage / ProductQuickCreateModal: при создании нового товара автоматически выбирают «Все товары» (или единственную группу), чтобы пользователь мог сохранить продукт без лишнего клика.
This commit is contained in:
parent
57168299ac
commit
319a91ff10
|
|
@ -94,8 +94,13 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductGroupInput in
|
||||||
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,SuperAdmin")]
|
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,SuperAdmin")]
|
||||||
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var owner = await _db.ProductGroups.Where(x => x.Id == id).Select(x => x.OrganizationId).FirstOrDefaultAsync(ct);
|
var meta = await _db.ProductGroups
|
||||||
if (owner is null && !User.IsInRole("SuperAdmin")) return Forbid();
|
.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);
|
var hasChildren = await _db.ProductGroups.AnyAsync(g => g.ParentId == id, ct);
|
||||||
if (hasChildren) return BadRequest(new { error = "Нельзя удалить группу с подгруппами" });
|
if (hasChildren) return BadRequest(new { error = "Нельзя удалить группу с подгруппами" });
|
||||||
var hasProducts = await _db.Products.AnyAsync(p => p.ProductGroupId == id, ct);
|
var hasProducts = await _db.Products.AnyAsync(p => p.ProductGroupId == id, ct);
|
||||||
|
|
|
||||||
|
|
@ -174,6 +174,23 @@ public static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId,
|
||||||
// организации все active globals через junction org_units_of_measure.
|
// организации все active globals через junction org_units_of_measure.
|
||||||
await EnableAllActiveUnitsForOrgAsync(db, orgId, ct);
|
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 ТОЛЬКО если у организации не было ни одной записи.
|
// Сидируем PriceType ТОЛЬКО если у организации не было ни одной записи.
|
||||||
// Если есть — никогда не создаём «системную копию», корректность IsSystem
|
// Если есть — никогда не создаём «системную копию», корректность IsSystem
|
||||||
// обеспечивает миграция Phase3b_FixPriceTypeIsSystem (выбор по факту:
|
// обеспечивает миграция Phase3b_FixPriceTypeIsSystem (выбор по факту:
|
||||||
|
|
|
||||||
|
|
@ -18,4 +18,10 @@ public class ProductGroup : Entity, IOptionalTenantEntity
|
||||||
/// <summary>Процент наценки на себестоимость для автоматического расчёта
|
/// <summary>Процент наценки на себестоимость для автоматического расчёта
|
||||||
/// розничной цены при проведении приёмки. NULL = автонаценка отключена.</summary>
|
/// розничной цены при проведении приёмки. NULL = автонаценка отключена.</summary>
|
||||||
public decimal? MarkupPercent { get; set; }
|
public decimal? MarkupPercent { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Системная группа создаётся при bootstrap'е новой org как
|
||||||
|
/// дефолт, чтобы любой новый продукт мог иметь хоть какую-то группу без
|
||||||
|
/// предварительной настройки. Удалить или переименовать нельзя — иначе
|
||||||
|
/// продукты осиротеют.</summary>
|
||||||
|
public bool IsSystem { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -108,6 +108,7 @@ private static void ConfigureProductGroup(EntityTypeBuilder<ProductGroup> b)
|
||||||
b.ToTable("product_groups");
|
b.ToTable("product_groups");
|
||||||
b.Property(x => x.Name).HasMaxLength(200).IsRequired();
|
b.Property(x => x.Name).HasMaxLength(200).IsRequired();
|
||||||
b.Property(x => x.Path).HasMaxLength(1000);
|
b.Property(x => x.Path).HasMaxLength(1000);
|
||||||
|
b.Property(x => x.IsSystem).HasDefaultValue(false);
|
||||||
b.HasOne(x => x.Parent)
|
b.HasOne(x => x.Parent)
|
||||||
.WithMany(x => x.Children)
|
.WithMany(x => x.Children)
|
||||||
.HasForeignKey(x => x.ParentId)
|
.HasForeignKey(x => x.ParentId)
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,36 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Infrastructure;
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
using foodmarket.Infrastructure.Persistence;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace foodmarket.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>Phase5e — добавляем product_groups.IsSystem (default false).
|
||||||
|
/// Используется для бутстрап-группы «Все товары», создаваемой при
|
||||||
|
/// запуске новой org: продукт без явной группы попадает в неё, удалить
|
||||||
|
/// её нельзя, иначе продукты осиротеют.</summary>
|
||||||
|
[DbContext(typeof(AppDbContext))]
|
||||||
|
[Migration("20260508200000_Phase5e_ProductGroupIsSystem")]
|
||||||
|
public partial class Phase5e_ProductGroupIsSystem : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder b)
|
||||||
|
{
|
||||||
|
b.AddColumn<bool>(
|
||||||
|
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");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,7 +4,7 @@ import { api } from '@/lib/api'
|
||||||
import { Modal } from '@/components/Modal'
|
import { Modal } from '@/components/Modal'
|
||||||
import { Button } from '@/components/Button'
|
import { Button } from '@/components/Button'
|
||||||
import { Field, TextInput, Select, AsyncSelect } from '@/components/Field'
|
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 { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
import { BarcodeType, type Product } from '@/lib/types'
|
import { BarcodeType, type Product } from '@/lib/types'
|
||||||
import { generateEan13InternalPrefix2 } from '@/lib/barcode'
|
import { generateEan13InternalPrefix2 } from '@/lib/barcode'
|
||||||
|
|
@ -26,6 +26,7 @@ interface Props {
|
||||||
* pre-fill'ятся из набранного запроса в зависимости от типа. */
|
* pre-fill'ятся из набранного запроса в зависимости от типа. */
|
||||||
export function ProductQuickCreateModal({ open, onClose, prefill, onCreated }: Props) {
|
export function ProductQuickCreateModal({ open, onClose, prefill, onCreated }: Props) {
|
||||||
const units = useUnits()
|
const units = useUnits()
|
||||||
|
const groups = useProductGroups()
|
||||||
const org = useOrgSettings()
|
const org = useOrgSettings()
|
||||||
|
|
||||||
const [name, setName] = useState('')
|
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]
|
const sht = units.data.find((u) => u.code === '796') ?? units.data[0]
|
||||||
setUnitId(sht.id)
|
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({
|
const create = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { api } from '@/lib/api'
|
||||||
import { Button } from '@/components/Button'
|
import { Button } from '@/components/Button'
|
||||||
import { Field, TextInput, TextArea, Select, AsyncSelect, Checkbox, MoneyInput, NumberInput } from '@/components/Field'
|
import { Field, TextInput, TextArea, Select, AsyncSelect, Checkbox, MoneyInput, NumberInput } from '@/components/Field'
|
||||||
import {
|
import {
|
||||||
useUnits, useCountries, useCurrencies, usePriceTypes,
|
useUnits, useCountries, useCurrencies, usePriceTypes, useProductGroups,
|
||||||
} from '@/lib/useLookups'
|
} from '@/lib/useLookups'
|
||||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
import { useOrgSettings } from '@/lib/useOrgSettings'
|
||||||
import { BarcodeType, Packaging, type Product } from '@/lib/types'
|
import { BarcodeType, Packaging, type Product } from '@/lib/types'
|
||||||
|
|
@ -57,6 +57,7 @@ export function ProductEditPage() {
|
||||||
const qc = useQueryClient()
|
const qc = useQueryClient()
|
||||||
|
|
||||||
const units = useUnits()
|
const units = useUnits()
|
||||||
|
const groups = useProductGroups()
|
||||||
const countries = useCountries()
|
const countries = useCountries()
|
||||||
const currencies = useCurrencies()
|
const currencies = useCurrencies()
|
||||||
const priceTypes = usePriceTypes()
|
const priceTypes = usePriceTypes()
|
||||||
|
|
@ -99,6 +100,14 @@ export function ProductEditPage() {
|
||||||
setForm((f) => ({ ...f, unitOfMeasureId: sht.id }))
|
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) {
|
if (isNew && form.purchaseCurrencyId === '' && currencies.data?.length) {
|
||||||
const def = org.data?.defaultCurrencyId
|
const def = org.data?.defaultCurrencyId
|
||||||
? currencies.data?.find(c => c.id === 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 }],
|
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({
|
const save = useMutation({
|
||||||
mutationFn: async () => {
|
mutationFn: async () => {
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue