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 если всё чисто.
|
||||
/// <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}»." });
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
|||
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
|
||||
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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue