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:
parent
d1ebbef671
commit
db3be5bbca
|
|
@ -25,6 +25,25 @@ public ProductsController(AppDbContext db, ITenantContext tenant)
|
||||||
|
|
||||||
// Проверка пересечения штрихкодов с другими товарами организации.
|
// Проверка пересечения штрихкодов с другими товарами организации.
|
||||||
// Возвращает первый конфликт «код → товар» либо null если всё чисто.
|
// Возвращает первый конфликт «код → товар» либо 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(
|
private async Task<(string Code, string ProductName)?> FindBarcodeConflictAsync(
|
||||||
IEnumerable<string> codes, Guid? excludeProductId, CancellationToken ct)
|
IEnumerable<string> codes, Guid? excludeProductId, CancellationToken ct)
|
||||||
{
|
{
|
||||||
|
|
@ -98,6 +117,8 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
|
||||||
[FromQuery] decimal? referencePriceTo,
|
[FromQuery] decimal? referencePriceTo,
|
||||||
[FromQuery] int? shelfLifeDaysFrom,
|
[FromQuery] int? shelfLifeDaysFrom,
|
||||||
[FromQuery] int? shelfLifeDaysTo,
|
[FromQuery] int? shelfLifeDaysTo,
|
||||||
|
[FromQuery] decimal? systemPriceFrom,
|
||||||
|
[FromQuery] decimal? systemPriceTo,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
{
|
{
|
||||||
var q = QueryIncludes().AsNoTracking();
|
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 (refTo is not null) q = q.Where(p => p.ReferencePrice <= refTo);
|
||||||
if (shelfLifeDaysFrom is not null) q = q.Where(p => p.ShelfLifeDays >= shelfLifeDaysFrom);
|
if (shelfLifeDaysFrom is not null) q = q.Where(p => p.ShelfLifeDays >= shelfLifeDaysFrom);
|
||||||
if (shelfLifeDaysTo is not null) q = q.Where(p => p.ShelfLifeDays <= shelfLifeDaysTo);
|
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))
|
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),
|
("packaging", true) => q.OrderByDescending(p => p.Packaging).ThenBy(p => p.Name),
|
||||||
("purchasePrice", false) => q.OrderBy(p => p.ReferencePrice).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),
|
("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", false) => q.OrderBy(p => p.Vat).ThenBy(p => p.Name),
|
||||||
("vat", true) => q.OrderByDescending(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),
|
("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)
|
if (input.Barcodes is null || input.Barcodes.Count == 0)
|
||||||
return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." });
|
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);
|
var conflict = await FindBarcodeConflictAsync(input.Barcodes.Select(b => b.Code), null, ct);
|
||||||
if (conflict is { } c)
|
if (conflict is { } c)
|
||||||
return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}»." });
|
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)
|
if (input.Barcodes is null || input.Barcodes.Count == 0)
|
||||||
return BadRequest(new { error = "У товара должен быть хотя бы один штрихкод." });
|
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);
|
var conflict = await FindBarcodeConflictAsync(input.Barcodes.Select(b => b.Code), id, ct);
|
||||||
if (conflict is { } c)
|
if (conflict is { } c)
|
||||||
return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}»." });
|
return BadRequest(new { error = $"Штрихкод {c.Code} уже используется товаром «{c.ProductName}»." });
|
||||||
|
|
|
||||||
|
|
@ -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);
|
var anyPriceType = await db.PriceTypes.IgnoreQueryFilters().AnyAsync(p => p.OrganizationId == orgId, ct);
|
||||||
if (!anyPriceType)
|
if (!anyPriceType)
|
||||||
{
|
{
|
||||||
db.PriceTypes.AddRange(
|
db.PriceTypes.AddRange(
|
||||||
new PriceType { OrganizationId = orgId, Name = "Розничная", IsDefault = true, IsRetail = true, SortOrder = 1 },
|
new PriceType { OrganizationId = orgId, Name = "Розничная цена", IsSystem = true, IsRequired = true, IsDefault = true, IsRetail = true, SortOrder = 0 },
|
||||||
new PriceType { OrganizationId = orgId, Name = "Оптовая", SortOrder = 2 }
|
new PriceType { OrganizationId = orgId, Name = "Оптовая", SortOrder = 1 }
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
File diff suppressed because it is too large
Load diff
|
|
@ -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-перенос завязан на исторические данные.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -21,8 +21,8 @@ interface Filters {
|
||||||
isService: TriFilter
|
isService: TriFilter
|
||||||
packaging: number | null // null = все, 1/2/3 = Piece/Weight/Liquid
|
packaging: number | null // null = все, 1/2/3 = Piece/Weight/Liquid
|
||||||
isMarked: TriFilter
|
isMarked: TriFilter
|
||||||
referencePriceFrom: number | null
|
systemPriceFrom: number | null
|
||||||
referencePriceTo: number | null
|
systemPriceTo: number | null
|
||||||
shelfLifeDaysFrom: number | null
|
shelfLifeDaysFrom: number | null
|
||||||
shelfLifeDaysTo: number | null
|
shelfLifeDaysTo: number | null
|
||||||
}
|
}
|
||||||
|
|
@ -32,8 +32,8 @@ const defaultFilters: Filters = {
|
||||||
isService: 'all',
|
isService: 'all',
|
||||||
packaging: null,
|
packaging: null,
|
||||||
isMarked: 'all',
|
isMarked: 'all',
|
||||||
referencePriceFrom: null,
|
systemPriceFrom: null,
|
||||||
referencePriceTo: null,
|
systemPriceTo: null,
|
||||||
shelfLifeDaysFrom: null,
|
shelfLifeDaysFrom: null,
|
||||||
shelfLifeDaysTo: 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.isService !== 'all') e.isService = f.isService === 'yes'
|
||||||
if (f.packaging) e.packaging = f.packaging
|
if (f.packaging) e.packaging = f.packaging
|
||||||
if (f.isMarked !== 'all') e.isMarked = f.isMarked === 'yes'
|
if (f.isMarked !== 'all') e.isMarked = f.isMarked === 'yes'
|
||||||
if (f.referencePriceFrom != null) e.referencePriceFrom = f.referencePriceFrom
|
if (f.systemPriceFrom != null) e.systemPriceFrom = f.systemPriceFrom
|
||||||
if (f.referencePriceTo != null) e.referencePriceTo = f.referencePriceTo
|
if (f.systemPriceTo != null) e.systemPriceTo = f.systemPriceTo
|
||||||
if (f.shelfLifeDaysFrom != null) e.shelfLifeDaysFrom = f.shelfLifeDaysFrom
|
if (f.shelfLifeDaysFrom != null) e.shelfLifeDaysFrom = f.shelfLifeDaysFrom
|
||||||
if (f.shelfLifeDaysTo != null) e.shelfLifeDaysTo = f.shelfLifeDaysTo
|
if (f.shelfLifeDaysTo != null) e.shelfLifeDaysTo = f.shelfLifeDaysTo
|
||||||
return e
|
return e
|
||||||
|
|
@ -57,8 +57,8 @@ const activeFilterCount = (f: Filters) => {
|
||||||
if (f.isService !== 'all') n++
|
if (f.isService !== 'all') n++
|
||||||
if (f.packaging) n++
|
if (f.packaging) n++
|
||||||
if (f.isMarked !== 'all') n++
|
if (f.isMarked !== 'all') n++
|
||||||
if (f.referencePriceFrom != null) n++
|
if (f.systemPriceFrom != null) n++
|
||||||
if (f.referencePriceTo != null) n++
|
if (f.systemPriceTo != null) n++
|
||||||
if (f.shelfLifeDaysFrom != null) n++
|
if (f.shelfLifeDaysFrom != null) n++
|
||||||
if (f.shelfLifeDaysTo != null) n++
|
if (f.shelfLifeDaysTo != null) n++
|
||||||
return n
|
return n
|
||||||
|
|
@ -140,6 +140,7 @@ export function ProductsPage() {
|
||||||
header: systemPriceType?.name ?? 'Розничная цена',
|
header: systemPriceType?.name ?? 'Розничная цена',
|
||||||
width: '170px',
|
width: '170px',
|
||||||
className: 'text-right font-mono',
|
className: 'text-right font-mono',
|
||||||
|
sortKey: 'systemPrice',
|
||||||
cell: (r) => {
|
cell: (r) => {
|
||||||
const pr = systemPriceType ? r.prices?.find(x => x.priceTypeId === systemPriceType.id) : undefined
|
const pr = systemPriceType ? r.prices?.find(x => x.priceTypeId === systemPriceType.id) : undefined
|
||||||
if (!pr) return '—'
|
if (!pr) return '—'
|
||||||
|
|
@ -237,24 +238,22 @@ export function ProductsPage() {
|
||||||
<Tri label="Маркируемый" value={filters.isMarked} onChange={(v) => { setFilters({ ...filters, isMarked: v }); setPage(1) }} />
|
<Tri label="Маркируемый" value={filters.isMarked} onChange={(v) => { setFilters({ ...filters, isMarked: v }); setPage(1) }} />
|
||||||
)}
|
)}
|
||||||
<div className="flex items-center gap-2 text-xs">
|
<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">
|
<div className="w-32">
|
||||||
<MoneyInput
|
<MoneyInput
|
||||||
value={filters.referencePriceFrom}
|
value={filters.systemPriceFrom}
|
||||||
onChange={(n) => { setFilters({ ...filters, referencePriceFrom: n }); setPage(1) }}
|
onChange={(n) => { setFilters({ ...filters, systemPriceFrom: n }); setPage(1) }}
|
||||||
currencyCode={org.data?.defaultCurrencyCode ?? undefined}
|
currencyCode={org.data?.defaultCurrencyCode ?? undefined}
|
||||||
currencySymbol={org.data?.defaultCurrencySymbol ?? undefined}
|
currencySymbol={org.data?.defaultCurrencySymbol ?? undefined}
|
||||||
|
|
||||||
placeholder="от"
|
placeholder="от"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div className="w-32">
|
<div className="w-32">
|
||||||
<MoneyInput
|
<MoneyInput
|
||||||
value={filters.referencePriceTo}
|
value={filters.systemPriceTo}
|
||||||
onChange={(n) => { setFilters({ ...filters, referencePriceTo: n }); setPage(1) }}
|
onChange={(n) => { setFilters({ ...filters, systemPriceTo: n }); setPage(1) }}
|
||||||
currencyCode={org.data?.defaultCurrencyCode ?? undefined}
|
currencyCode={org.data?.defaultCurrencyCode ?? undefined}
|
||||||
currencySymbol={org.data?.defaultCurrencySymbol ?? undefined}
|
currencySymbol={org.data?.defaultCurrencySymbol ?? undefined}
|
||||||
|
|
||||||
placeholder="до"
|
placeholder="до"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue