feat(directories): двухуровневые справочники Группы и Ед.измерения (системные + tenant)
Some checks failed
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 45s
CI / Web (React + Vite) (push) Successful in 39s
Docker API / Build + push API (push) Successful in 46s
Docker Web / Build + push Web (push) Successful in 30s
Docker API / Deploy API on stage (push) Failing after 36s
Docker Web / Deploy Web on stage (push) Successful in 11s

Концепция: ProductGroup и UnitOfMeasure становятся двухуровневыми
справочниками. Системные эталонные записи (OrganizationId=NULL,
управляются SuperAdmin'ом) видны всем tenant'ам как «Эталон»
и read-only. Tenant'овские (OrganizationId=<orgId>) — обычная изоляция,
полный CRUD у админа орги.

Архитектура:
- IOptionalTenantEntity { Guid? OrganizationId } — новый интерфейс
  в Domain/Common. ProductGroup и UnitOfMeasure отнаследованы от
  Entity и реализуют его.
- AppDbContext.ApplyOptionalTenantFilter<T>: query-filter для
  IOptionalTenantEntity пропускает запись с OrganizationId=NULL для
  всех tenant'ов + tenant'овские по выбранной orgId. SuperAdmin без
  override видит всё, в override — только NULL+своё.
- StampTenant: при Add для IOptionalTenantEntity — null оставляется
  если SuperAdmin без override (системная), иначе подставляется
  tenant.OrganizationId.
- Миграция Phase4d_OptionalTenantOnDirectories: ALTER COLUMN
  OrganizationId DROP NOT NULL на product_groups и units_of_measure.
  Existing данные FOOD MARKET (11 групп, 5 единиц) сохраняются как
  tenant'овские — additive change, ничего не теряется.
- DTO: UnitOfMeasureDto и ProductGroupDto получили nullable
  OrganizationId; фронт читает его для показа badge «Эталон».
- Защита мутаций: PUT/DELETE контроллеры теперь возвращают Forbid()
  если запись OrganizationId=null и юзер не SuperAdmin (только
  суперадмин может править/удалять системные).

Frontend:
- Badge «Эталон» (indigo) рядом с именем системной записи в обеих
  страницах.
- Клик по строке системной записи → alert «Изменения недоступны…».
- SuperAdmin sidebar: новые пункты «Группы (эталон)» (FolderTree)
  и «Ед. измерения (эталон)» (Ruler) под «Справочники». Страницы
  реиспользуют существующие компоненты — для SuperAdmin без override
  фильтр возвращает все записи, что в Phase 4+ можно ужесточить
  отдельным эндпоинтом «только системные» (?orgId=null).

Decision (нонстоп-выбор по ТЗ): nullable OrganizationId через
IOptionalTenantEntity, не sentinel Guid.Empty — чище, безопаснее,
ясная семантика. Существующие группы FOOD MARKET НЕ мигрированы в
системные (как просил юзер) — пусть SuperAdmin сам создаст эталоны.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 16:20:47 +05:00
parent 8466cf928c
commit 58038c9cf7
15 changed files with 2200 additions and 27 deletions

View file

@ -44,7 +44,7 @@ public class ProductGroupsController : ControllerBase
};
var items = await q
.Skip(req.Skip).Take(req.Take)
.Select(g => new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.MarkupPercent))
.Select(g => new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.MarkupPercent, g.OrganizationId))
.ToListAsync(ct);
return new PagedResult<ProductGroupDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
@ -53,7 +53,7 @@ public class ProductGroupsController : ControllerBase
public async Task<ActionResult<ProductGroupDto>> Get(Guid id, CancellationToken ct)
{
var g = await _db.ProductGroups.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
return g is null ? NotFound() : new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.MarkupPercent);
return g is null ? NotFound() : new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.MarkupPercent, g.OrganizationId);
}
[HttpPost, Authorize(Roles = "Admin,Manager")]
@ -69,14 +69,17 @@ public async Task<ActionResult<ProductGroupDto>> Create([FromBody] ProductGroupI
_db.ProductGroups.Add(e);
await _db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(Get), new { id = e.Id },
new ProductGroupDto(e.Id, e.Name, e.ParentId, e.Path, e.SortOrder, e.MarkupPercent));
new ProductGroupDto(e.Id, e.Name, e.ParentId, e.Path, e.SortOrder, e.MarkupPercent, e.OrganizationId));
}
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,SuperAdmin")]
public async Task<IActionResult> Update(Guid id, [FromBody] ProductGroupInput input, CancellationToken ct)
{
var e = await _db.ProductGroups.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound();
// Системную (эталонную) запись правит только SuperAdmin без override.
if (e.OrganizationId is null && !(User.IsInRole("SuperAdmin")))
return Forbid();
if (input.ParentId == id) return BadRequest(new { error = "ParentId cannot be self" });
e.Name = input.Name;
@ -88,9 +91,11 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductGroupInput in
return NoContent();
}
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,SuperAdmin")]
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);
if (owner is null && !User.IsInRole("SuperAdmin")) return Forbid();
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);

View file

@ -36,7 +36,7 @@ public async Task<ActionResult<PagedResult<UnitOfMeasureDto>>> List([FromQuery]
};
var items = await q
.Skip(req.Skip).Take(req.Take)
.Select(u => new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description))
.Select(u => new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.OrganizationId))
.ToListAsync(ct);
return new PagedResult<UnitOfMeasureDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
@ -45,7 +45,7 @@ public async Task<ActionResult<PagedResult<UnitOfMeasureDto>>> List([FromQuery]
public async Task<ActionResult<UnitOfMeasureDto>> Get(Guid id, CancellationToken ct)
{
var u = await _db.UnitsOfMeasure.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
return u is null ? NotFound() : new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description);
return u is null ? NotFound() : new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.OrganizationId);
}
[HttpPost, Authorize(Roles = "Admin,Manager")]
@ -60,14 +60,15 @@ public async Task<ActionResult<UnitOfMeasureDto>> Create([FromBody] UnitOfMeasur
_db.UnitsOfMeasure.Add(e);
await _db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(Get), new { id = e.Id },
new UnitOfMeasureDto(e.Id, e.Code, e.Name, e.Description));
new UnitOfMeasureDto(e.Id, e.Code, e.Name, e.Description, e.OrganizationId));
}
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager,SuperAdmin")]
public async Task<IActionResult> Update(Guid id, [FromBody] UnitOfMeasureInput input, CancellationToken ct)
{
var e = await _db.UnitsOfMeasure.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound();
if (e.OrganizationId is null && !User.IsInRole("SuperAdmin")) return Forbid();
e.Code = input.Code;
e.Name = input.Name;
@ -76,11 +77,12 @@ public async Task<IActionResult> Update(Guid id, [FromBody] UnitOfMeasureInput i
return NoContent();
}
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin")]
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin,SuperAdmin")]
public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
{
var e = await _db.UnitsOfMeasure.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound();
if (e.OrganizationId is null && !User.IsInRole("SuperAdmin")) return Forbid();
_db.UnitsOfMeasure.Remove(e);
await _db.SaveChangesAsync(ct);
return NoContent();

View file

@ -12,7 +12,7 @@ public record CountryDto(
public record CurrencyDto(Guid Id, string Code, string Name, string Symbol);
public record UnitOfMeasureDto(
Guid Id, string Code, string Name, string? Description);
Guid Id, string Code, string Name, string? Description, Guid? OrganizationId);
public record PriceTypeDto(
Guid Id, string Name, bool IsRequired, bool IsSystem,
@ -28,7 +28,7 @@ public record RetailPointDto(
public record ProductGroupDto(
Guid Id, string Name, Guid? ParentId, string Path, int SortOrder,
decimal? MarkupPercent);
decimal? MarkupPercent, Guid? OrganizationId);
public record CounterpartyDto(
Guid Id, string Name, string? LegalName, CounterpartyType Type,

View file

@ -3,8 +3,11 @@
namespace foodmarket.Domain.Catalog;
// Иерархическая группа товаров (категория). Произвольная вложенность через ParentId.
public class ProductGroup : TenantEntity
// Двухуровневый справочник (IOptionalTenantEntity): системные эталонные группы
// (OrganizationId=null, управляются SuperAdmin'ом) и tenant'овские (OrganizationId=<orgId>).
public class ProductGroup : Entity, IOptionalTenantEntity
{
public Guid? OrganizationId { get; set; }
public string Name { get; set; } = null!;
public Guid? ParentId { get; set; }
public ProductGroup? Parent { get; set; }

View file

@ -3,8 +3,11 @@
namespace foodmarket.Domain.Catalog;
// Единица измерения как в MoySklad entity/uom: code + name + description.
public class UnitOfMeasure : TenantEntity
// Двухуровневый справочник: системные эталонные (OrganizationId=null,
// управляются SuperAdmin'ом — ОКЕИ-эталоны) и tenant'овские расширения.
public class UnitOfMeasure : Entity, IOptionalTenantEntity
{
public Guid? OrganizationId { get; set; }
public string Code { get; set; } = null!; // ОКЕИ код: "796" (шт), "166" (кг), "112" (л)
public string Name { get; set; } = null!; // "штука", "килограмм", "литр"
public string? Description { get; set; }

View file

@ -9,3 +9,12 @@ public abstract class TenantEntity : Entity, ITenantEntity
{
public Guid OrganizationId { get; set; }
}
/// <summary>Двухуровневый справочник: запись либо системная (OrganizationId=null,
/// видна и читается всеми, мутирует только SuperAdmin), либо tenant'овская
/// (OrganizationId=&lt;orgId&gt;, видна и редактируется этой оргой). Применяется
/// для расширяемых эталонных списков типа единиц измерения и групп товаров.</summary>
public interface IOptionalTenantEntity
{
Guid? OrganizationId { get; set; }
}

View file

@ -100,7 +100,9 @@ protected override void OnModelCreating(ModelBuilder builder)
builder.ConfigureSales();
builder.ConfigureOrganizationsHr();
// Apply multi-tenant query filter to every entity that implements ITenantEntity
// Apply multi-tenant query filter to every entity that implements ITenantEntity.
// IOptionalTenantEntity (системные справочники с nullable OrganizationId) —
// через отдельный фильтр, который пропускает запись с NULL для всех.
foreach (var entityType in builder.Model.GetEntityTypes())
{
if (typeof(ITenantEntity).IsAssignableFrom(entityType.ClrType))
@ -111,6 +113,14 @@ protected override void OnModelCreating(ModelBuilder builder)
.MakeGenericMethod(entityType.ClrType);
method.Invoke(this, new object[] { builder });
}
else if (typeof(IOptionalTenantEntity).IsAssignableFrom(entityType.ClrType))
{
var method = typeof(AppDbContext)
.GetMethod(nameof(ApplyOptionalTenantFilter),
System.Reflection.BindingFlags.NonPublic | System.Reflection.BindingFlags.Instance)!
.MakeGenericMethod(entityType.ClrType);
method.Invoke(this, new object[] { builder });
}
}
}
@ -125,6 +135,17 @@ protected override void OnModelCreating(ModelBuilder builder)
|| e.OrganizationId == _tenant.OrganizationId);
}
private void ApplyOptionalTenantFilter<T>(ModelBuilder builder) where T : class, IOptionalTenantEntity
{
// Системные записи (OrganizationId == null) видны ВСЕМ tenant'ам как
// эталонные. Tenant'овские (свои OrganizationId) — обычная изоляция.
// SuperAdmin без override видит всё.
builder.Entity<T>().HasQueryFilter(e =>
(_tenant.IsSuperAdmin && !_tenant.IsTenantOverride)
|| e.OrganizationId == null
|| e.OrganizationId == _tenant.OrganizationId);
}
public override int SaveChanges()
{
StampTenant();
@ -143,11 +164,27 @@ private void StampTenant()
{
foreach (var entry in ChangeTracker.Entries())
{
if (entry.State == EntityState.Added && entry.Entity is ITenantEntity tenant && tenant.OrganizationId == Guid.Empty)
if (entry.State != EntityState.Added) continue;
if (entry.Entity is ITenantEntity tenant && tenant.OrganizationId == Guid.Empty)
{
if (_tenant.OrganizationId.HasValue)
tenant.OrganizationId = _tenant.OrganizationId.Value;
}
else if (entry.Entity is IOptionalTenantEntity opt && opt.OrganizationId is null)
{
// Если SuperAdmin создаёт запись «как пользователь» (override
// активен), стампим выбранную орг. Если SuperAdmin без override
// (системная консоль) — оставляем null (системная запись).
// Tenant-юзер всегда стампит свой orgId.
if (_tenant.IsSuperAdmin && !_tenant.IsTenantOverride)
{
// null — системная запись; оставляем
}
else if (_tenant.OrganizationId.HasValue)
{
opt.OrganizationId = _tenant.OrganizationId.Value;
}
}
}
}

View file

@ -0,0 +1,35 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>ProductGroup и UnitOfMeasure становятся двухуровневыми
/// справочниками: системные эталонные (OrganizationId=NULL, управляются
/// SuperAdmin'ом, видны всем tenant'ам как «Эталон») и tenant'овские.
/// На уровне БД — DROP NOT NULL на колонке OrganizationId; существующие
/// записи остаются tenant'овскими (OrganizationId сохраняется), миграция
/// additive — никакие данные не теряются.</summary>
public partial class Phase4d_OptionalTenantOnDirectories : Migration
{
protected override void Up(MigrationBuilder b)
{
b.AlterColumn<System.Guid>(
name: "OrganizationId", schema: "public", table: "product_groups",
type: "uuid", nullable: true,
oldClrType: typeof(System.Guid), oldType: "uuid");
b.AlterColumn<System.Guid>(
name: "OrganizationId", schema: "public", table: "units",
type: "uuid", nullable: true,
oldClrType: typeof(System.Guid), oldType: "uuid");
}
protected override void Down(MigrationBuilder b)
{
// Защита от потери данных при downgrade — если есть NULL'ы (системные
// записи), нельзя вернуть NOT NULL без миграции данных. Оставляем
// как есть; downgrade в проде делать через отдельный скрипт.
b.Sql("/* No-op: оптимально не возвращать NOT NULL без data migration */");
}
}
}

View file

@ -698,7 +698,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasMaxLength(200)
.HasColumnType("character varying(200)");
b.Property<Guid>("OrganizationId")
b.Property<Guid?>("OrganizationId")
.HasColumnType("uuid");
b.Property<Guid?>("ParentId")
@ -927,7 +927,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasMaxLength(100)
.HasColumnType("character varying(100)");
b.Property<Guid>("OrganizationId")
b.Property<Guid?>("OrganizationId")
.HasColumnType("uuid");
b.Property<DateTime?>("UpdatedAt")

View file

@ -58,6 +58,8 @@ export default function App() {
<Route path="organizations/new" element={<SuperAdminOrgCreatePage />} />
<Route path="audit-log" element={<SuperAdminAuditLogPage />} />
<Route path="countries" element={<CountriesPage />} />
<Route path="groups" element={<ProductGroupsPage />} />
<Route path="units" element={<UnitsOfMeasurePage />} />
</Route>
{/* Tenant-роуты обычный AppLayout, но с TenantRouteGuard:

View file

@ -4,6 +4,7 @@ import { useQuery } from '@tanstack/react-query'
import {
ShieldCheck, Building, FileClock, HeartPulse, HardDriveDownload,
Settings, Users, LayoutDashboard, LogOut, Menu, X, ChevronDown, Globe,
FolderTree, Ruler,
} from 'lucide-react'
import { api, getOrgOverride, setOrgOverride } from '@/lib/api'
import { logout } from '@/lib/auth'
@ -22,6 +23,8 @@ const NAV: NavSection[] = [
]},
{ group: 'Справочники', items: [
{ to: '/super-admin/countries', icon: Globe, label: 'Страны' },
{ to: '/super-admin/groups', icon: FolderTree, label: 'Группы (эталон)' },
{ to: '/super-admin/units', icon: Ruler, label: 'Ед. измерения (эталон)' },
]},
{ group: 'Аудит', items: [
{ to: '/super-admin/audit-log', icon: FileClock, label: 'Журнал действий' },

View file

@ -28,7 +28,7 @@ export interface Country {
vatRate: number
}
export interface Currency { id: string; code: string; name: string; symbol: string }
export interface UnitOfMeasure { id: string; code: string; name: string; description: string | null }
export interface UnitOfMeasure { id: string; code: string; name: string; description: string | null; organizationId: string | null }
export interface PriceType { id: string; name: string; isRequired: boolean; isSystem: boolean; isRetail: boolean; sortOrder: number }
export interface Store {
id: string; name: string; code: string | null; address: string | null; phone: string | null;
@ -38,7 +38,7 @@ export interface RetailPoint {
id: string; name: string; code: string | null; storeId: string; storeName: string | null;
address: string | null; phone: string | null; fiscalSerial: string | null; fiscalRegNumber: string | null; isActive: boolean
}
export interface ProductGroup { id: string; name: string; parentId: string | null; path: string; sortOrder: number; markupPercent: number | null }
export interface ProductGroup { id: string; name: string; parentId: string | null; path: string; sortOrder: number; markupPercent: number | null; organizationId: string | null }
export interface Counterparty {
id: string; name: string; legalName: string | null; type: CounterpartyType;
bin: string | null; iin: string | null; taxNumber: string | null; countryId: string | null; countryName: string | null;

View file

@ -63,9 +63,22 @@ export function ProductGroupsPage() {
sortKey={sortKey}
sortOrder={sortOrder}
onSortChange={setSort}
onRowClick={(r) => setForm({ id: r.id, name: r.name, parentId: r.parentId, sortOrder: r.sortOrder, markupPercent: r.markupPercent })}
onRowClick={(r) => {
if (r.organizationId === null) {
alert('Эталонная группа. Изменения недоступны — управляются на уровне платформы.')
return
}
setForm({ id: r.id, name: r.name, parentId: r.parentId, sortOrder: r.sortOrder, markupPercent: r.markupPercent })
}}
columns={[
{ header: 'Название', sortKey: 'name', cell: (r) => r.name },
{ header: 'Название', sortKey: 'name', cell: (r) => (
<span>
{r.name}
{r.organizationId === null && (
<span className="ml-2 text-[10px] uppercase px-1.5 py-0.5 rounded bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300">Эталон</span>
)}
</span>
)},
{ header: 'Путь', sortKey: 'path', cell: (r) => <span className="text-slate-500">{r.path}</span> },
{ header: 'Наценка', width: '140px', cell: (r) => (
<div onClick={(e) => e.stopPropagation()}>

View file

@ -56,13 +56,26 @@ export function UnitsOfMeasurePage() {
sortKey={sortKey}
sortOrder={sortOrder}
onSortChange={setSort}
onRowClick={(r) => setForm({
onRowClick={(r) => {
if (r.organizationId === null) {
alert('Эталонная единица измерения. Изменения недоступны — управляются на уровне платформы.')
return
}
setForm({
id: r.id, code: r.code, name: r.name,
description: r.description ?? ''
})}
})
}}
columns={[
{ header: 'Код', width: '90px', sortKey: 'code', cell: (r) => <span className="font-mono">{r.code}</span> },
{ header: 'Название', sortKey: 'name', cell: (r) => r.name },
{ header: 'Название', sortKey: 'name', cell: (r) => (
<span>
{r.name}
{r.organizationId === null && (
<span className="ml-2 text-[10px] uppercase px-1.5 py-0.5 rounded bg-indigo-100 text-indigo-700 dark:bg-indigo-900/40 dark:text-indigo-300">Эталон</span>
)}
</span>
)},
{ header: 'Описание', cell: (r) => r.description ?? '—' },
]}
/>