chore(price-types): drop IsDefault flag + rename IsRetail label + uniqueness

PriceType: убран флаг IsDefault — он семантически дублировал IsSystem
(защищённая запись «по умолчанию»). Остаются IsSystem / IsRequired /
IsRetail.

- Domain.Catalog.PriceType: удалено поле IsDefault.
- Миграция Phase3b_DropPriceTypeIsDefault: DROP COLUMN.
- DTO/Input (PriceTypeDto, PriceTypeInput) — без IsDefault.
- PriceTypesController:
  • убрана логика uniqueness IsDefault на Create/Update,
  • IsRetail теперь enforce'ит уникальность: при установке IsRetail=true
    у других записей сбрасывается,
  • при удалении единственной IsRetail записи (если она не системная)
    IsRetail автоматически переезжает на IsSystem-запись — у организации
    всегда остаётся один POS-кандидат.
- ProductsController.RecalcRetail и SuppliesController.SetDefaultRetail —
  поиск дефолтной розничной идёт по IsSystem → IsRetail → SortOrder → Name
  (ранее ThenByDescending(IsDefault) — выпилено).
- DevDataSeeder: поле IsDefault убрано.
- web types.ts: убрано isDefault из PriceType.
- PriceTypesPage:
  • убран чекбокс «По умолчанию»,
  • лейбл «Розничная (используется на кассе)» → «Используется на кассе»,
  • Form/blankForm/onRowClick без isDefault.
- ProductsPage / ProductEditPage: фоллбэк дефолтной цены теперь
  IsSystem → IsRetail → первая.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-26 00:15:29 +05:00
parent a8717897b7
commit 4649a624c3
13 changed files with 1968 additions and 29 deletions

View file

@ -37,7 +37,7 @@ public async Task<ActionResult<PagedResult<PriceTypeDto>>> List([FromQuery] Page
};
var items = await q
.Skip(req.Skip).Take(req.Take)
.Select(p => new PriceTypeDto(p.Id, p.Name, p.IsRequired, p.IsSystem, p.IsDefault, p.IsRetail, p.SortOrder))
.Select(p => new PriceTypeDto(p.Id, p.Name, p.IsRequired, p.IsSystem, p.IsRetail, p.SortOrder))
.ToListAsync(ct);
return new PagedResult<PriceTypeDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
}
@ -46,29 +46,30 @@ public async Task<ActionResult<PagedResult<PriceTypeDto>>> List([FromQuery] Page
public async Task<ActionResult<PriceTypeDto>> Get(Guid id, CancellationToken ct)
{
var p = await _db.PriceTypes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
return p is null ? NotFound() : new PriceTypeDto(p.Id, p.Name, p.IsRequired, p.IsSystem, p.IsDefault, p.IsRetail, p.SortOrder);
return p is null ? NotFound() : new PriceTypeDto(p.Id, p.Name, p.IsRequired, p.IsSystem, p.IsRetail, p.SortOrder);
}
[HttpPost, Authorize(Roles = "Admin,Manager")]
public async Task<ActionResult<PriceTypeDto>> Create([FromBody] PriceTypeInput input, CancellationToken ct)
{
if (input.IsDefault)
if (input.IsRetail)
{
await _db.PriceTypes.Where(p => p.IsDefault).ExecuteUpdateAsync(s => s.SetProperty(p => p.IsDefault, false), ct);
// Уникальность IsRetail: не более одной записи в организации.
await _db.PriceTypes.Where(p => p.IsRetail)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.IsRetail, false), ct);
}
var e = new PriceType
{
Name = input.Name,
IsRequired = input.IsRequired,
IsSystem = false,
IsDefault = input.IsDefault,
IsRetail = input.IsRetail,
SortOrder = input.SortOrder,
};
_db.PriceTypes.Add(e);
await _db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(Get), new { id = e.Id },
new PriceTypeDto(e.Id, e.Name, e.IsRequired, e.IsSystem, e.IsDefault, e.IsRetail, e.SortOrder));
new PriceTypeDto(e.Id, e.Name, e.IsRequired, e.IsSystem, e.IsRetail, e.SortOrder));
}
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
@ -76,12 +77,13 @@ public async Task<IActionResult> Update(Guid id, [FromBody] PriceTypeInput input
{
var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound();
if (input.IsDefault && !e.IsDefault)
if (input.IsRetail && !e.IsRetail)
{
await _db.PriceTypes.Where(p => p.IsDefault && p.Id != id).ExecuteUpdateAsync(s => s.SetProperty(p => p.IsDefault, false), ct);
// Снимаем IsRetail с прежней записи (если была) — гарантия уникальности.
await _db.PriceTypes.Where(p => p.IsRetail && p.Id != id)
.ExecuteUpdateAsync(s => s.SetProperty(p => p.IsRetail, false), ct);
}
e.Name = input.Name;
e.IsDefault = input.IsDefault;
e.IsRetail = input.IsRetail;
e.SortOrder = input.SortOrder;
// У системной записи IsRequired всегда true и не меняется.
@ -96,8 +98,25 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound();
if (e.IsSystem) return BadRequest(new { error = "Системная запись не может быть удалена." });
var wasRetail = e.IsRetail;
_db.PriceTypes.Remove(e);
await _db.SaveChangesAsync(ct);
// Если удалённая запись была единственной IsRetail — фоллбэк на системную,
// чтобы у организации всегда оставался один IsRetail-кандидат для POS.
if (wasRetail)
{
var stillRetail = await _db.PriceTypes.AnyAsync(p => p.IsRetail, ct);
if (!stillRetail)
{
var sys = await _db.PriceTypes.FirstOrDefaultAsync(p => p.IsSystem, ct);
if (sys is not null && !sys.IsRetail)
{
sys.IsRetail = true;
await _db.SaveChangesAsync(ct);
}
}
}
return NoContent();
}
}

View file

@ -333,7 +333,6 @@ public async Task<IActionResult> RecalcRetail(Guid id, CancellationToken ct)
var defaultType = await _db.PriceTypes
.OrderByDescending(pt => pt.IsSystem)
.ThenByDescending(pt => pt.IsDefault)
.ThenByDescending(pt => pt.IsRetail)
.ThenBy(pt => pt.SortOrder)
.ThenBy(pt => pt.Name)

View file

@ -296,13 +296,12 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
/// <summary>Записывает значение в дефолтный розничный PriceType. Если в списке
/// цен у товара такой записи нет — создаёт её. Дефолтным считается PriceType
/// с IsDefault=true; если такого нет — первый IsRetail; иначе — первый
/// с IsSystem=true; если такого нет — первый IsRetail; иначе — первый
/// PriceType в списке. Currency берётся из приёмки (или из существующей записи).</summary>
private void SetDefaultRetail(foodmarket.Domain.Catalog.Product p, decimal value, Guid fallbackCurrencyId)
{
var defaultType = _db.PriceTypes
.OrderByDescending(pt => pt.IsSystem)
.ThenByDescending(pt => pt.IsDefault)
.ThenByDescending(pt => pt.IsRetail)
.ThenBy(pt => pt.SortOrder)
.ThenBy(pt => pt.Name)
@ -391,7 +390,7 @@ orderby l.SortOrder
l.Quantity, l.UnitPrice, l.LineTotal, l.SortOrder,
l.RetailPriceManuallyOverridden, l.RetailPriceOverride,
p.Prices
.OrderByDescending(pr => pr.PriceType!.IsDefault)
.OrderByDescending(pr => pr.PriceType!.IsSystem)
.ThenByDescending(pr => pr.PriceType!.IsRetail)
.ThenBy(pr => pr.PriceType!.SortOrder)
.ThenBy(pr => pr.PriceType!.Name)

View file

@ -105,7 +105,7 @@ private static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId,
if (!anyPriceType)
{
db.PriceTypes.AddRange(
new PriceType { OrganizationId = orgId, Name = "Розничная цена", IsSystem = true, IsRequired = true, IsDefault = true, IsRetail = true, SortOrder = 0 },
new PriceType { OrganizationId = orgId, Name = "Розничная цена", IsSystem = true, IsRequired = true, IsRetail = true, SortOrder = 0 },
new PriceType { OrganizationId = orgId, Name = "Оптовая", SortOrder = 1 }
);
}

View file

@ -16,7 +16,7 @@ public record UnitOfMeasureDto(
public record PriceTypeDto(
Guid Id, string Name, bool IsRequired, bool IsSystem,
bool IsDefault, bool IsRetail, int SortOrder);
bool IsRetail, int SortOrder);
public record StoreDto(
Guid Id, string Name, string? Code, string? Address, string? Phone,
@ -64,7 +64,7 @@ public record CurrencyInput(string Code, string Name, string Symbol);
public record UnitOfMeasureInput(string Code, string Name, string? Description = null);
public record PriceTypeInput(
string Name, bool IsRequired = false,
bool IsDefault = false, bool IsRetail = false, int SortOrder = 0);
bool IsRetail = false, int SortOrder = 0);
public record StoreInput(
string Name, string? Code,
string? Address = null, string? Phone = null, string? ManagerName = null,

View file

@ -11,7 +11,9 @@ public class PriceType : TenantEntity
/// <summary>true — системная запись «Розничная цена», не удаляется и
/// IsRequired всегда true. Имя можно переименовать. Сидируется при первом старте.</summary>
public bool IsSystem { get; set; }
public bool IsDefault { get; set; } // цена по умолчанию для новых товаров
public bool IsRetail { get; set; } // используется на кассе
/// <summary>true — единственная запись, по которой POS касса берёт цену
/// для пробивки чека. Контроллер обеспечивает уникальность: установка
/// IsRetail=true сбрасывает её у других записей.</summary>
public bool IsRetail { get; set; }
public int SortOrder { get; set; }
}

View file

@ -0,0 +1,24 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>price_types.IsDefault удалён — флаг семантически дублировал
/// IsSystem (защищённая запись «по умолчанию»). Оставляем IsSystem +
/// IsRequired + IsRetail.</summary>
public partial class Phase3b_DropPriceTypeIsDefault : Migration
{
protected override void Up(MigrationBuilder b)
{
b.DropColumn(name: "IsDefault", schema: "public", table: "price_types");
}
protected override void Down(MigrationBuilder b)
{
b.AddColumn<bool>(
name: "IsDefault", schema: "public", table: "price_types",
type: "boolean", nullable: false, defaultValue: false);
}
}
}

View file

@ -503,9 +503,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsDefault")
.HasColumnType("boolean");
b.Property<bool>("IsRequired")
.HasColumnType("boolean");

View file

@ -29,7 +29,7 @@ export interface Country {
}
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 PriceType { id: string; name: string; isRequired: boolean; isSystem: boolean; isDefault: boolean; isRetail: boolean; sortOrder: number }
export interface PriceType { id: string; name: string; isRequired: boolean; isSystem: boolean; isRetail: boolean; sortOrder: number }
export interface Store {
id: string; name: string; code: string | null; address: string | null; phone: string | null;
managerName: string | null; isMain: boolean; isActive: boolean

View file

@ -17,11 +17,10 @@ interface Form {
name: string
isRequired: boolean
isSystem: boolean
isDefault: boolean
isRetail: boolean
sortOrder: number
}
const blankForm: Form = { name: '', isRequired: false, isSystem: false, isDefault: false, isRetail: false, sortOrder: 0 }
const blankForm: Form = { name: '', isRequired: false, isSystem: false, isRetail: false, sortOrder: 0 }
export function PriceTypesPage() {
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<PriceType>(URL)
@ -62,7 +61,7 @@ export function PriceTypesPage() {
onRowClick={(r) => setForm({
id: r.id, name: r.name,
isRequired: r.isRequired, isSystem: r.isSystem,
isDefault: r.isDefault, isRetail: r.isRetail, sortOrder: r.sortOrder,
isRetail: r.isRetail, sortOrder: r.sortOrder,
})}
columns={[
{ header: 'Название', sortKey: 'name', cell: (r) => (
@ -120,8 +119,7 @@ export function PriceTypesPage() {
disabled={form.isSystem}
onChange={(v) => setForm({ ...form, isRequired: v })}
/>
<Checkbox label="Розничная (используется на кассе)" checked={form.isRetail} onChange={(v) => setForm({ ...form, isRetail: v })} />
<Checkbox label="По умолчанию" checked={form.isDefault} onChange={(v) => setForm({ ...form, isDefault: v })} />
<Checkbox label="Используется на кассе" checked={form.isRetail} onChange={(v) => setForm({ ...form, isRetail: v })} />
</div>
)}
</Modal>

View file

@ -396,7 +396,7 @@ export function ProductEditPage() {
<Button type="button" variant="secondary" size="sm" onClick={async () => {
try {
const res = await api.post<{ retail: number }>(`/api/catalog/products/${id}/recalc-retail`)
const def = priceTypes.data?.find((pt) => pt.isSystem) ?? priceTypes.data?.find((pt) => pt.isDefault) ?? priceTypes.data?.[0]
const def = priceTypes.data?.find((pt) => pt.isSystem) ?? priceTypes.data?.find((pt) => pt.isRetail) ?? priceTypes.data?.[0]
if (def) {
setForm((f) => {
const has = f.prices.some(p => p.priceTypeId === def.id)

View file

@ -109,7 +109,7 @@ export function ProductsPage() {
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<Product>(URL, toExtra(filters))
const org = useOrgSettings()
const priceTypes = usePriceTypes()
const systemPriceType = priceTypes.data?.find((pt) => pt.isSystem) ?? priceTypes.data?.find((pt) => pt.isDefault) ?? priceTypes.data?.[0]
const systemPriceType = priceTypes.data?.find((pt) => pt.isSystem) ?? priceTypes.data?.find((pt) => pt.isRetail) ?? priceTypes.data?.[0]
const showVat = org.data?.showVatEnabledOnProduct ?? false
const showService = org.data?.showServiceOnProduct ?? false
const showMarked = org.data?.showMarkedOnProduct ?? false