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 var items = await q
.Skip(req.Skip).Take(req.Take) .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); .ToListAsync(ct);
return new PagedResult<ProductGroupDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; 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) public async Task<ActionResult<ProductGroupDto>> Get(Guid id, CancellationToken ct)
{ {
var g = await _db.ProductGroups.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, 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")] [HttpPost, Authorize(Roles = "Admin,Manager")]
@ -69,14 +69,17 @@ public async Task<ActionResult<ProductGroupDto>> Create([FromBody] ProductGroupI
_db.ProductGroups.Add(e); _db.ProductGroups.Add(e);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(Get), new { id = e.Id }, 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) public async Task<IActionResult> Update(Guid id, [FromBody] ProductGroupInput input, CancellationToken ct)
{ {
var e = await _db.ProductGroups.FirstOrDefaultAsync(x => x.Id == id, ct); var e = await _db.ProductGroups.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound(); 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" }); if (input.ParentId == id) return BadRequest(new { error = "ParentId cannot be self" });
e.Name = input.Name; e.Name = input.Name;
@ -88,9 +91,11 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductGroupInput in
return NoContent(); return NoContent();
} }
[HttpDelete("{id:guid}"), Authorize(Roles = "Admin")] [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);
if (owner is null && !User.IsInRole("SuperAdmin")) return Forbid();
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);

View file

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

View file

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

View file

@ -3,8 +3,11 @@
namespace foodmarket.Domain.Catalog; namespace foodmarket.Domain.Catalog;
// Иерархическая группа товаров (категория). Произвольная вложенность через ParentId. // Иерархическая группа товаров (категория). Произвольная вложенность через 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 string Name { get; set; } = null!;
public Guid? ParentId { get; set; } public Guid? ParentId { get; set; }
public ProductGroup? Parent { get; set; } public ProductGroup? Parent { get; set; }

View file

@ -3,8 +3,11 @@
namespace foodmarket.Domain.Catalog; namespace foodmarket.Domain.Catalog;
// Единица измерения как в MoySklad entity/uom: code + name + description. // Единица измерения как в 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 Code { get; set; } = null!; // ОКЕИ код: "796" (шт), "166" (кг), "112" (л)
public string Name { get; set; } = null!; // "штука", "килограмм", "литр" public string Name { get; set; } = null!; // "штука", "килограмм", "литр"
public string? Description { get; set; } public string? Description { get; set; }

View file

@ -9,3 +9,12 @@ public abstract class TenantEntity : Entity, ITenantEntity
{ {
public Guid OrganizationId { get; set; } 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.ConfigureSales();
builder.ConfigureOrganizationsHr(); 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()) foreach (var entityType in builder.Model.GetEntityTypes())
{ {
if (typeof(ITenantEntity).IsAssignableFrom(entityType.ClrType)) if (typeof(ITenantEntity).IsAssignableFrom(entityType.ClrType))
@ -111,6 +113,14 @@ protected override void OnModelCreating(ModelBuilder builder)
.MakeGenericMethod(entityType.ClrType); .MakeGenericMethod(entityType.ClrType);
method.Invoke(this, new object[] { builder }); 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); || 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() public override int SaveChanges()
{ {
StampTenant(); StampTenant();
@ -143,11 +164,27 @@ private void StampTenant()
{ {
foreach (var entry in ChangeTracker.Entries()) 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) if (_tenant.OrganizationId.HasValue)
tenant.OrganizationId = _tenant.OrganizationId.Value; 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) .HasMaxLength(200)
.HasColumnType("character varying(200)"); .HasColumnType("character varying(200)");
b.Property<Guid>("OrganizationId") b.Property<Guid?>("OrganizationId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<Guid?>("ParentId") b.Property<Guid?>("ParentId")
@ -927,7 +927,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasMaxLength(100) .HasMaxLength(100)
.HasColumnType("character varying(100)"); .HasColumnType("character varying(100)");
b.Property<Guid>("OrganizationId") b.Property<Guid?>("OrganizationId")
.HasColumnType("uuid"); .HasColumnType("uuid");
b.Property<DateTime?>("UpdatedAt") b.Property<DateTime?>("UpdatedAt")

View file

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

View file

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

View file

@ -28,7 +28,7 @@ export interface Country {
vatRate: number vatRate: number
} }
export interface Currency { id: string; code: string; name: string; symbol: string } 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 PriceType { id: string; name: string; isRequired: boolean; isSystem: boolean; isRetail: boolean; sortOrder: number }
export interface Store { export interface Store {
id: string; name: string; code: string | null; address: string | null; phone: string | null; 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; 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 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 { export interface Counterparty {
id: string; name: string; legalName: string | null; type: CounterpartyType; id: string; name: string; legalName: string | null; type: CounterpartyType;
bin: string | null; iin: string | null; taxNumber: string | null; countryId: string | null; countryName: string | null; 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} sortKey={sortKey}
sortOrder={sortOrder} sortOrder={sortOrder}
onSortChange={setSort} 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={[ 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: 'Путь', sortKey: 'path', cell: (r) => <span className="text-slate-500">{r.path}</span> },
{ header: 'Наценка', width: '140px', cell: (r) => ( { header: 'Наценка', width: '140px', cell: (r) => (
<div onClick={(e) => e.stopPropagation()}> <div onClick={(e) => e.stopPropagation()}>

View file

@ -56,13 +56,26 @@ export function UnitsOfMeasurePage() {
sortKey={sortKey} sortKey={sortKey}
sortOrder={sortOrder} sortOrder={sortOrder}
onSortChange={setSort} onSortChange={setSort}
onRowClick={(r) => setForm({ onRowClick={(r) => {
id: r.id, code: r.code, name: r.name, if (r.organizationId === null) {
description: r.description ?? '' alert('Эталонная единица измерения. Изменения недоступны — управляются на уровне платформы.')
})} return
}
setForm({
id: r.id, code: r.code, name: r.name,
description: r.description ?? ''
})
}}
columns={[ columns={[
{ header: 'Код', width: '90px', sortKey: 'code', cell: (r) => <span className="font-mono">{r.code}</span> }, { 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 ?? '—' }, { header: 'Описание', cell: (r) => r.description ?? '—' },
]} ]}
/> />