chore(price-types): drop IsDefault flag + rename IsRetail label + uniqueness
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 46s
CI / Web (React + Vite) (push) Successful in 34s
Docker API / Build + push API (push) Successful in 40s
Docker Web / Build + push Web (push) Successful in 27s
Docker API / Deploy API on stage (push) Successful in 17s
Docker Web / Deploy Web on stage (push) Successful in 11s

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 5614fb9422
commit bcc6976bd0
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 var items = await q
.Skip(req.Skip).Take(req.Take) .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); .ToListAsync(ct);
return new PagedResult<PriceTypeDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; 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) public async Task<ActionResult<PriceTypeDto>> Get(Guid id, CancellationToken ct)
{ {
var p = await _db.PriceTypes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, 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")] [HttpPost, Authorize(Roles = "Admin,Manager")]
public async Task<ActionResult<PriceTypeDto>> Create([FromBody] PriceTypeInput input, CancellationToken ct) 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 var e = new PriceType
{ {
Name = input.Name, Name = input.Name,
IsRequired = input.IsRequired, IsRequired = input.IsRequired,
IsSystem = false, IsSystem = false,
IsDefault = input.IsDefault,
IsRetail = input.IsRetail, IsRetail = input.IsRetail,
SortOrder = input.SortOrder, SortOrder = input.SortOrder,
}; };
_db.PriceTypes.Add(e); _db.PriceTypes.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 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")] [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); var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound(); 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.Name = input.Name;
e.IsDefault = input.IsDefault;
e.IsRetail = input.IsRetail; e.IsRetail = input.IsRetail;
e.SortOrder = input.SortOrder; e.SortOrder = input.SortOrder;
// У системной записи IsRequired всегда true и не меняется. // У системной записи 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); var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct);
if (e is null) return NotFound(); if (e is null) return NotFound();
if (e.IsSystem) return BadRequest(new { error = "Системная запись не может быть удалена." }); if (e.IsSystem) return BadRequest(new { error = "Системная запись не может быть удалена." });
var wasRetail = e.IsRetail;
_db.PriceTypes.Remove(e); _db.PriceTypes.Remove(e);
await _db.SaveChangesAsync(ct); 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(); return NoContent();
} }
} }

View file

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

View file

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

View file

@ -105,7 +105,7 @@ private static async Task SeedTenantReferencesAsync(AppDbContext db, Guid orgId,
if (!anyPriceType) if (!anyPriceType)
{ {
db.PriceTypes.AddRange( 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 } new PriceType { OrganizationId = orgId, Name = "Оптовая", SortOrder = 1 }
); );
} }

View file

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

View file

@ -11,7 +11,9 @@ public class PriceType : TenantEntity
/// <summary>true — системная запись «Розничная цена», не удаляется и /// <summary>true — системная запись «Розничная цена», не удаляется и
/// IsRequired всегда true. Имя можно переименовать. Сидируется при первом старте.</summary> /// IsRequired всегда true. Имя можно переименовать. Сидируется при первом старте.</summary>
public bool IsSystem { get; set; } public bool IsSystem { get; set; }
public bool IsDefault { get; set; } // цена по умолчанию для новых товаров /// <summary>true — единственная запись, по которой POS касса берёт цену
public bool IsRetail { get; set; } // используется на кассе /// для пробивки чека. Контроллер обеспечивает уникальность: установка
/// IsRetail=true сбрасывает её у других записей.</summary>
public bool IsRetail { get; set; }
public int SortOrder { 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") b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<bool>("IsDefault")
.HasColumnType("boolean");
b.Property<bool>("IsRequired") b.Property<bool>("IsRequired")
.HasColumnType("boolean"); .HasColumnType("boolean");

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 { id: string; code: string; name: string; description: string | null } 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 { export interface Store {
id: string; name: string; code: string | null; address: string | null; phone: string | null; id: string; name: string; code: string | null; address: string | null; phone: string | null;
managerName: string | null; isMain: boolean; isActive: boolean managerName: string | null; isMain: boolean; isActive: boolean

View file

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

View file

@ -396,7 +396,7 @@ export function ProductEditPage() {
<Button type="button" variant="secondary" size="sm" onClick={async () => { <Button type="button" variant="secondary" size="sm" onClick={async () => {
try { try {
const res = await api.post<{ retail: number }>(`/api/catalog/products/${id}/recalc-retail`) 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) { if (def) {
setForm((f) => { setForm((f) => {
const has = f.prices.some(p => p.priceTypeId === def.id) 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 { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<Product>(URL, toExtra(filters))
const org = useOrgSettings() const org = useOrgSettings()
const priceTypes = usePriceTypes() 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 showVat = org.data?.showVatEnabledOnProduct ?? false
const showService = org.data?.showServiceOnProduct ?? false const showService = org.data?.showServiceOnProduct ?? false
const showMarked = org.data?.showMarkedOnProduct ?? false const showMarked = org.data?.showMarkedOnProduct ?? false