fix(price-types): correct is-system seeder + require value > 0 + system-price filter/sort

Phase3b сидер ошибочно создавал НОВУЮ запись «Розничная цена» с
IsSystem=true в каждой организации, не проверяя что фактически
системной была другая запись (с реальными ценами у товаров).
В итоге IsSystem-замок оказывался не у той записи.

Миграция Phase3b_FixPriceTypeIsSystem (идемпотентная):
- Снимает IsSystem со всех записей.
- Помечает IsSystem=true + IsRequired=true тому PriceType, у которого
  максимум связанных ProductPrice (приоритет — фактически
  использующейся цене); при равенстве — самая старая (CreatedAt ASC).
- Если у организации вообще нет PriceType — создаёт «Розничная цена»
  (IsSystem=true, IsRequired=true).

DevDataSeeder: «Розничная» переименована в «Розничная цена», добавлены
IsSystem=true / IsRequired=true; работает только если у организации
ноль PriceType — больше не шлёпает дубль.

API валидация (ProductsController.Create/Update):
- FindMissingRequiredPriceAsync: для каждого PriceType с IsRequired=true
  проверяет, что в input.Prices есть запись с Amount > 0. Иначе
  возвращает 400 «Цена «<имя>» обязательна и должна быть больше 0.».

API фильтр+сортировка по системной цене:
- ProductsController.List: query parameters systemPriceFrom / systemPriceTo
  применяют ≥ / ≤ к Prices.Where(IsSystem).Amount.
- Sort key 'systemPrice' — OrderBy / OrderByDescending по той же
  системной цене.

Web ProductsPage:
- Filters.referencePriceFrom/To → systemPriceFrom/To, бэк-параметры
  systemPriceFrom/To.
- Подпись фильтра — динамическое имя системного PriceType (имя из
  справочника, обновляется при переименовании).
- Колонка системной цены получила sortKey='systemPrice'.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-25 23:31:31 +05:00
parent d1ebbef671
commit db3be5bbca
5 changed files with 2024 additions and 17 deletions

View file

@ -25,6 +25,25 @@ public ProductsController(AppDbContext db, ITenantContext tenant)
// Проверка пересечения штрихкодов с другими товарами организации.
// Возвращает первый конфликт «код → товар» либо null если всё чисто.
/// <summary>Проверяет что у каждого PriceType с IsRequired=true есть
/// соответствующая запись в input.Prices с Amount > 0. Возвращает имя
/// первого нарушенного типа либо null если всё ок.</summary>
private async Task<string?> FindMissingRequiredPriceAsync(
IReadOnlyList<ProductPriceInput>? prices, CancellationToken ct)
{
var required = await _db.PriceTypes
.Where(pt => pt.IsRequired)
.Select(pt => new { pt.Id, pt.Name })
.ToListAsync(ct);
if (required.Count == 0) return null;
foreach (var pt in required)
{
var price = prices?.FirstOrDefault(p => p.PriceTypeId == pt.Id);
if (price is null || price.Amount <= 0m) return pt.Name;
}
return null;
}
private async Task<(string Code, string ProductName)?> FindBarcodeConflictAsync(
IEnumerable<string> codes, Guid? excludeProductId, CancellationToken ct)
{
@ -98,6 +117,8 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
[FromQuery] decimal? referencePriceTo,
[FromQuery] int? shelfLifeDaysFrom,
[FromQuery] int? shelfLifeDaysTo,
[FromQuery] decimal? systemPriceFrom,
[FromQuery] decimal? systemPriceTo,
CancellationToken ct)
{
var q = QueryIncludes().AsNoTracking();
@ -127,6 +148,11 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
if (refTo is not null) q = q.Where(p => p.ReferencePrice <= refTo);
if (shelfLifeDaysFrom is not null) q = q.Where(p => p.ShelfLifeDays >= shelfLifeDaysFrom);
if (shelfLifeDaysTo is not null) q = q.Where(p => p.ShelfLifeDays <= shelfLifeDaysTo);
// Фильтр по системной (главной розничной) цене — берём Prices c PriceType.IsSystem=true.
if (systemPriceFrom is not null)
q = q.Where(p => p.Prices.Any(pr => pr.PriceType!.IsSystem && pr.Amount >= systemPriceFrom));
if (systemPriceTo is not null)
q = q.Where(p => p.Prices.Any(pr => pr.PriceType!.IsSystem && pr.Amount <= systemPriceTo));
if (!string.IsNullOrWhiteSpace(req.Search))
{
@ -150,6 +176,8 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
("packaging", true) => q.OrderByDescending(p => p.Packaging).ThenBy(p => p.Name),
("purchasePrice", false) => q.OrderBy(p => p.ReferencePrice).ThenBy(p => p.Name),
("purchasePrice", true) => q.OrderByDescending(p => p.ReferencePrice).ThenBy(p => p.Name),
("systemPrice", false) => q.OrderBy(p => p.Prices.Where(pr => pr.PriceType!.IsSystem).Select(pr => (decimal?)pr.Amount).FirstOrDefault()).ThenBy(p => p.Name),
("systemPrice", true) => q.OrderByDescending(p => p.Prices.Where(pr => pr.PriceType!.IsSystem).Select(pr => (decimal?)pr.Amount).FirstOrDefault()).ThenBy(p => p.Name),
("vat", false) => q.OrderBy(p => p.Vat).ThenBy(p => p.Name),
("vat", true) => q.OrderByDescending(p => p.Vat).ThenBy(p => p.Name),
("name", true) => q.OrderByDescending(p => p.Name),
@ -174,6 +202,8 @@ public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input
{
if (input.Barcodes is null || input.Barcodes.Count == 0)
return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." });
if (await FindMissingRequiredPriceAsync(input.Prices, ct) is { } missing)
return BadRequest(new { error = $"Цена «{missing}» обязательна и должна быть больше 0." });
var conflict = await FindBarcodeConflictAsync(input.Barcodes.Select(b => b.Code), null, ct);
if (conflict is { } c)
return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}»." });
@ -209,6 +239,8 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input,
{
if (input.Barcodes is null || input.Barcodes.Count == 0)
return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." });
if (await FindMissingRequiredPriceAsync(input.Prices, ct) is { } missing)
return BadRequest(new { error = $"Цена «{missing}» обязательна и должна быть больше 0." });
var conflict = await FindBarcodeConflictAsync(input.Barcodes.Select(b => b.Code), id, ct);
if (conflict is { } c)
return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}»." });

View file

@ -97,12 +97,16 @@ private static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId,
);
}
// Сидируем PriceType ТОЛЬКО если у организации не было ни одной записи.
// Если есть — никогда не создаём «системную копию», корректность IsSystem
// обеспечивает миграция Phase3b_FixPriceTypeIsSystem (выбор по факту:
// запись с максимумом ProductPrice).
var anyPriceType = await db.PriceTypes.IgnoreQueryFilters().AnyAsync(p => p.OrganizationId == orgId, ct);
if (!anyPriceType)
{
db.PriceTypes.AddRange(
new PriceType { OrganizationId = orgId, Name = "Розничная", IsDefault = true, IsRetail = true, SortOrder = 1 },
new PriceType { OrganizationId = orgId, Name = "Оптовая", SortOrder = 2 }
new PriceType { OrganizationId = orgId, Name = "Розничная цена", IsSystem = true, IsRequired = true, IsDefault = true, IsRetail = true, SortOrder = 0 },
new PriceType { OrganizationId = orgId, Name = "Оптовая", SortOrder = 1 }
);
}

View file

@ -0,0 +1,68 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase3b сидер ошибочно создавал НОВУЮ запись «Розничная цена»
/// с IsSystem=true в каждой организации, у которой не было IsSystem=true,
/// игнорируя факт что фактически системной была другая запись (с реальными
/// ценами у товаров). В итоге IsSystem оказывался не у той записи.
///
/// Чиним идемпотентно для каждой организации:
/// 1. Сначала снимаем IsSystem со всех записей.
/// 2. Помечаем IsSystem той записи, у которой больше всего связанных
/// ProductPrice (приоритет — реально использующейся цене); при равенстве
/// выбирается самая старая (CreatedAt ASC).
/// 3. Если в организации вообще нет ни одной PriceType — создаём новую
/// «Розничная цена» с IsSystem=true.
/// 4. У выбранной системной IsRequired ставим true.
/// </summary>
public partial class Phase3b_FixPriceTypeIsSystem : Migration
{
protected override void Up(MigrationBuilder b)
{
b.Sql("""
-- 1. Снимаем IsSystem со всех записей.
UPDATE public.price_types SET "IsSystem" = false;
-- 2. Для каждой организации находим кандидата с max(count(prices))
-- и помечаем его IsSystem=true, IsRequired=true.
WITH ranked AS (
SELECT
pt."Id",
pt."OrganizationId",
COUNT(pp."Id") AS price_count,
pt."CreatedAt",
ROW_NUMBER() OVER (
PARTITION BY pt."OrganizationId"
ORDER BY COUNT(pp."Id") DESC, pt."CreatedAt" ASC, pt."Id" ASC
) AS rn
FROM public.price_types pt
LEFT JOIN public.product_prices pp ON pp."PriceTypeId" = pt."Id"
GROUP BY pt."Id", pt."OrganizationId", pt."CreatedAt"
)
UPDATE public.price_types pt
SET "IsSystem" = true, "IsRequired" = true
FROM ranked
WHERE pt."Id" = ranked."Id" AND ranked.rn = 1;
-- 3. Если у организации ВООБЩЕ нет PriceType создадим «Розничная цена».
INSERT INTO public.price_types
("Id", "OrganizationId", "Name", "IsRequired", "IsSystem",
"IsDefault", "IsRetail", "SortOrder", "CreatedAt")
SELECT gen_random_uuid(), o."Id", 'Розничная цена', true, true,
true, true, 0, now() AT TIME ZONE 'UTC'
FROM public.organizations o
WHERE NOT EXISTS (
SELECT 1 FROM public.price_types pt WHERE pt."OrganizationId" = o."Id"
);
""");
}
protected override void Down(MigrationBuilder b)
{
// Невосстанавливаемо — IsSystem-перенос завязан на исторические данные.
}
}
}

View file

@ -21,8 +21,8 @@ interface Filters {
isService: TriFilter
packaging: number | null // null = все, 1/2/3 = Piece/Weight/Liquid
isMarked: TriFilter
referencePriceFrom: number | null
referencePriceTo: number | null
systemPriceFrom: number | null
systemPriceTo: number | null
shelfLifeDaysFrom: number | null
shelfLifeDaysTo: number | null
}
@ -32,8 +32,8 @@ const defaultFilters: Filters = {
isService: 'all',
packaging: null,
isMarked: 'all',
referencePriceFrom: null,
referencePriceTo: null,
systemPriceFrom: null,
systemPriceTo: null,
shelfLifeDaysFrom: null,
shelfLifeDaysTo: null,
}
@ -44,8 +44,8 @@ const toExtra = (f: Filters): Record<string, string | number | boolean | undefin
if (f.isService !== 'all') e.isService = f.isService === 'yes'
if (f.packaging) e.packaging = f.packaging
if (f.isMarked !== 'all') e.isMarked = f.isMarked === 'yes'
if (f.referencePriceFrom != null) e.referencePriceFrom = f.referencePriceFrom
if (f.referencePriceTo != null) e.referencePriceTo = f.referencePriceTo
if (f.systemPriceFrom != null) e.systemPriceFrom = f.systemPriceFrom
if (f.systemPriceTo != null) e.systemPriceTo = f.systemPriceTo
if (f.shelfLifeDaysFrom != null) e.shelfLifeDaysFrom = f.shelfLifeDaysFrom
if (f.shelfLifeDaysTo != null) e.shelfLifeDaysTo = f.shelfLifeDaysTo
return e
@ -57,8 +57,8 @@ const activeFilterCount = (f: Filters) => {
if (f.isService !== 'all') n++
if (f.packaging) n++
if (f.isMarked !== 'all') n++
if (f.referencePriceFrom != null) n++
if (f.referencePriceTo != null) n++
if (f.systemPriceFrom != null) n++
if (f.systemPriceTo != null) n++
if (f.shelfLifeDaysFrom != null) n++
if (f.shelfLifeDaysTo != null) n++
return n
@ -140,6 +140,7 @@ export function ProductsPage() {
header: systemPriceType?.name ?? 'Розничная цена',
width: '170px',
className: 'text-right font-mono',
sortKey: 'systemPrice',
cell: (r) => {
const pr = systemPriceType ? r.prices?.find(x => x.priceTypeId === systemPriceType.id) : undefined
if (!pr) return '—'
@ -237,24 +238,22 @@ export function ProductsPage() {
<Tri label="Маркируемый" value={filters.isMarked} onChange={(v) => { setFilters({ ...filters, isMarked: v }); setPage(1) }} />
)}
<div className="flex items-center gap-2 text-xs">
<span className="text-slate-500">Эталонная цена</span>
<span className="text-slate-500">{systemPriceType?.name ?? 'Цена'}</span>
<div className="w-32">
<MoneyInput
value={filters.referencePriceFrom}
onChange={(n) => { setFilters({ ...filters, referencePriceFrom: n }); setPage(1) }}
value={filters.systemPriceFrom}
onChange={(n) => { setFilters({ ...filters, systemPriceFrom: n }); setPage(1) }}
currencyCode={org.data?.defaultCurrencyCode ?? undefined}
currencySymbol={org.data?.defaultCurrencySymbol ?? undefined}
placeholder="от"
/>
</div>
<div className="w-32">
<MoneyInput
value={filters.referencePriceTo}
onChange={(n) => { setFilters({ ...filters, referencePriceTo: n }); setPage(1) }}
value={filters.systemPriceTo}
onChange={(n) => { setFilters({ ...filters, systemPriceTo: n }); setPage(1) }}
currencyCode={org.data?.defaultCurrencyCode ?? undefined}
currencySymbol={org.data?.defaultCurrencySymbol ?? undefined}
placeholder="до"
/>
</div>