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:
parent
a8717897b7
commit
4649a624c3
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 }
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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; }
|
||||
}
|
||||
|
|
|
|||
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in a new issue