refactor(units): drop Description, hide Code from non-SuperAdmin UI
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 1m24s
CI / Web (React + Vite) (push) Successful in 42s
Docker API / Build + push API (push) Successful in 1m33s
Docker Web / Build + push Web (push) Successful in 37s
Docker API / Deploy API on stage (push) Successful in 18s
Docker Web / Deploy Web on stage (push) Successful in 12s

Description у пяти канонических ОКЕИ-единиц никогда не заполнялось ни UI,
ни импортом, ни сидером — выкидываем поле полностью (Domain → EF-config
→ DTO → Input → frontend types → Super-Admin форма). Migration
Phase5d_DropUnitOfMeasureDescription дропает колонку.

Code оставляем в БД (нужен для интеграций МойСклад/1С), но скрываем от
org Admin'а:
- /catalog/units-of-measure — только колонки Name + кнопка toggle, без
  Code и Description; поиск/сортировка только по Name.
- /super-admin/units-of-measure — Code продолжает показываться в таблице
  и форме редактирования.

Дропдаун единиц в ProductEditPage / ProductQuickCreateModal уже отдаёт
только {u.name} в options, проверено. На SupplyEditPage/RetailSaleEditPage
в строках документа отображается unitName, Code не показывался — без
изменений.
This commit is contained in:
nns 2026-05-08 11:02:10 +05:00
parent 37cd9aa94b
commit bf53629092
9 changed files with 50 additions and 21 deletions

View file

@ -69,7 +69,7 @@ public UnitsOfMeasureController(AppDbContext db, ITenantContext tenant)
var items = await q var items = await q
.Skip(req.Skip).Take(req.Take) .Skip(req.Skip).Take(req.Take)
.Select(u => new UnitOfMeasureDto( .Select(u => new UnitOfMeasureDto(
u.Id, u.Code, u.Name, u.Description, u.OrganizationId, u.IsActive, true)) u.Id, u.Code, u.Name, u.OrganizationId, u.IsActive, true))
.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 };
} }
@ -87,7 +87,7 @@ public async Task<ActionResult<UnitOfMeasureDto>> Get(Guid id, CancellationToken
var enabled = !orgId.HasValue || await _db.OrgUnitsOfMeasure var enabled = !orgId.HasValue || await _db.OrgUnitsOfMeasure
.IgnoreQueryFilters() .IgnoreQueryFilters()
.AnyAsync(j => j.OrganizationId == orgId.Value && j.UnitOfMeasureId == id, ct); .AnyAsync(j => j.OrganizationId == orgId.Value && j.UnitOfMeasureId == id, ct);
return new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.OrganizationId, u.IsActive, enabled); return new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.OrganizationId, u.IsActive, enabled);
} }
/// <summary>Включить global для текущей орги. Идемпотентно: повторный /// <summary>Включить global для текущей орги. Идемпотентно: повторный

View file

@ -47,7 +47,7 @@ public class SuperAdminUnitsOfMeasureController : ControllerBase
var items = await q var items = await q
.Skip(req.Skip).Take(req.Take) .Skip(req.Skip).Take(req.Take)
.Select(u => new UnitOfMeasureDto( .Select(u => new UnitOfMeasureDto(
u.Id, u.Code, u.Name, u.Description, u.OrganizationId, u.IsActive, true)) u.Id, u.Code, u.Name, u.OrganizationId, u.IsActive, true))
.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 };
} }
@ -61,7 +61,7 @@ public async Task<ActionResult<UnitOfMeasureDto>> Get(Guid id, CancellationToken
.FirstOrDefaultAsync(x => x.Id == id && x.OrganizationId == null, ct); .FirstOrDefaultAsync(x => x.Id == id && x.OrganizationId == null, ct);
return u is null return u is null
? NotFound() ? NotFound()
: new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.OrganizationId, u.IsActive, true); : new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.OrganizationId, u.IsActive, true);
} }
[HttpPost] [HttpPost]
@ -81,13 +81,12 @@ public async Task<ActionResult<UnitOfMeasureDto>> Create([FromBody] UnitOfMeasur
OrganizationId = null, OrganizationId = null,
Code = input.Code.Trim(), Code = input.Code.Trim(),
Name = input.Name.Trim(), Name = input.Name.Trim(),
Description = input.Description,
IsActive = true, IsActive = true,
}; };
_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, e.OrganizationId, e.IsActive, true)); new UnitOfMeasureDto(e.Id, e.Code, e.Name, e.OrganizationId, e.IsActive, true));
} }
[HttpPut("{id:guid}")] [HttpPut("{id:guid}")]
@ -110,7 +109,6 @@ public async Task<IActionResult> Update(Guid id, [FromBody] UnitOfMeasureInput i
e.Code = input.Code.Trim(); e.Code = input.Code.Trim();
e.Name = input.Name.Trim(); e.Name = input.Name.Trim();
e.Description = input.Description;
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? OrganizationId, Guid Id, string Code, string Name, Guid? OrganizationId,
bool IsActive = true, bool IsEnabledForOrg = true); bool IsActive = true, bool IsEnabledForOrg = true);
public record PriceTypeDto( public record PriceTypeDto(
@ -62,7 +62,7 @@ public record CountryInput(
string Code, string Name, string Code, string Name,
Guid? DefaultCurrencyId = null, decimal VatRate = 0m); Guid? DefaultCurrencyId = null, decimal VatRate = 0m);
public record CurrencyInput(string Code, string Name, string Symbol); public record CurrencyInput(string Code, string Name, string Symbol);
public record UnitOfMeasureInput(string Code, string Name, string? Description = null); public record UnitOfMeasureInput(string Code, string Name);
public record PriceTypeInput( public record PriceTypeInput(
string Name, bool IsRequired = false, string Name, bool IsRequired = false,
bool IsRetail = false, int SortOrder = 0); bool IsRetail = false, int SortOrder = 0);

View file

@ -13,6 +13,5 @@ public class UnitOfMeasure : Entity, IOptionalTenantEntity
public Guid? OrganizationId { get; set; } 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 bool IsActive { get; set; } = true; public bool IsActive { get; set; } = true;
} }

View file

@ -47,7 +47,6 @@ private static void ConfigureUnit(EntityTypeBuilder<UnitOfMeasure> b)
b.ToTable("units_of_measure"); b.ToTable("units_of_measure");
b.Property(x => x.Code).HasMaxLength(10).IsRequired(); b.Property(x => x.Code).HasMaxLength(10).IsRequired();
b.Property(x => x.Name).HasMaxLength(100).IsRequired(); b.Property(x => x.Name).HasMaxLength(100).IsRequired();
b.Property(x => x.Description).HasMaxLength(500);
b.Property(x => x.IsActive).HasDefaultValue(true); b.Property(x => x.IsActive).HasDefaultValue(true);
// Phase5c: после миграции OrganizationId всегда NULL — глобальный справочник. // Phase5c: после миграции OrganizationId всегда NULL — глобальный справочник.
// Уникальность по Code только среди активных, чтобы можно было // Уникальность по Code только среди активных, чтобы можно было

View file

@ -0,0 +1,37 @@
using Microsoft.EntityFrameworkCore.Infrastructure;
using Microsoft.EntityFrameworkCore.Migrations;
using foodmarket.Infrastructure.Persistence;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase5d — выкидываем UnitOfMeasure.Description: для пяти
/// канонических ОКЕИ-единиц («штука», «кг», ...) нечего описывать,
/// поле никогда не заполнялось ни UI, ни импортом, ни сидером.
/// Code остаётся (нужен для интеграций МойСклад/1С), но скрыт в UI
/// от org Admin'а.</summary>
[DbContext(typeof(AppDbContext))]
[Migration("20260508100000_Phase5d_DropUnitOfMeasureDescription")]
public partial class Phase5d_DropUnitOfMeasureDescription : Migration
{
protected override void Up(MigrationBuilder b)
{
b.DropColumn(
name: "Description",
schema: "public",
table: "units_of_measure");
}
protected override void Down(MigrationBuilder b)
{
b.AddColumn<string>(
name: "Description",
schema: "public",
table: "units_of_measure",
type: "character varying(500)",
maxLength: 500,
nullable: true);
}
}
}

View file

@ -29,7 +29,7 @@ export interface Country {
} }
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 { export interface UnitOfMeasure {
id: string; code: string; name: string; description: string | null; organizationId: string | null; id: string; code: string; name: string; organizationId: string | null;
isActive: boolean; isEnabledForOrg: boolean isActive: boolean; isEnabledForOrg: boolean
} }
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 }

View file

@ -16,10 +16,9 @@ interface Form {
id?: string id?: string
code: string code: string
name: string name: string
description: string
} }
const blank: Form = { code: '', name: '', description: '' } const blank: Form = { code: '', name: '' }
export function SuperAdminUnitsOfMeasurePage() { export function SuperAdminUnitsOfMeasurePage() {
const list = useCatalogList<UnitOfMeasure>(URL) const list = useCatalogList<UnitOfMeasure>(URL)
@ -80,12 +79,11 @@ export function SuperAdminUnitsOfMeasurePage() {
onSortChange={list.setSort} onSortChange={list.setSort}
onRowClick={(r) => { onRowClick={(r) => {
setSubmitError(null) setSubmitError(null)
setForm({ id: r.id, code: r.code, name: r.name, description: r.description ?? '' }) setForm({ id: r.id, code: r.code, name: r.name })
}} }}
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) => r.name },
{ header: 'Описание', cell: (r) => r.description ?? '—' },
{ {
header: 'Статус', header: 'Статус',
width: '110px', width: '110px',
@ -121,9 +119,6 @@ export function SuperAdminUnitsOfMeasurePage() {
<Field label="Название"> <Field label="Название">
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /> <TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field> </Field>
<Field label="Описание">
<TextInput value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</Field>
{submitError && ( {submitError && (
<p className="text-sm text-red-600 dark:text-red-400">{submitError}</p> <p className="text-sm text-red-600 dark:text-red-400">{submitError}</p>
)} )}

View file

@ -62,9 +62,10 @@ export function UnitsOfMeasurePage() {
sortOrder={list.sortOrder} sortOrder={list.sortOrder}
onSortChange={list.setSort} onSortChange={list.setSort}
columns={[ columns={[
{ header: 'Код', width: '90px', sortKey: 'code', cell: (r) => <span className="font-mono">{r.code}</span> }, // Колонку «Код» (ОКЕИ 796/166/...) не показываем org-юзеру: код нужен
// только для интеграций (МойСклад/1С) и виден SuperAdmin'у на
// /super-admin/units. Здесь админ орги выбирает по понятному имени.
{ header: 'Название', sortKey: 'name', cell: (r) => r.name }, { header: 'Название', sortKey: 'name', cell: (r) => r.name },
{ header: 'Описание', cell: (r) => r.description ?? '—' },
{ {
header: 'Для орги', header: 'Для орги',
width: '120px', width: '120px',