feat(phase3b): drop IsActive, add ShelfLifeDays, restore PriceType IsSystem/IsRequired

Domain + миграция Phase3b_PricingCleanup:
- DROP IsActive у products / product_groups / units_of_measure /
  counterparties / price_types (включая индекс
  IX_products_OrganizationId_IsActive). В этих сущностях концепт
  деактивации не оправдан — если товар/группа/единица/контрагент
  не нужны, их физически удаляют.
- DROP organizations.MultiplePriceTypesEnabled — раздел «Типы цен»
  всегда виден, отдельной настройки больше не нужно.
- ADD price_types.IsRequired bool default false — обязательность
  заполнения для каждого товара.
- ADD price_types.IsSystem bool default false — защищённая запись,
  не удаляется и IsRequired всегда true; имя редактируется.
  В каждой организации гарантируется одна системная запись
  «Розничная цена» (создаётся миграцией если её нет).
- ADD products.ShelfLifeDays integer NULL — срок годности.

API:
- ProductsController/UnitsOfMeasureController/ProductGroupsController/
  CounterpartiesController/PriceTypesController: убраны параметры
  isActive в фильтрах, sort-keys, DTO, Apply, Создании.
- Products проекция: вместо IsActive теперь ShelfLifeDays.
- PriceTypesController: 400 при попытке удалить системную запись;
  IsRequired у системной — всегда true, не меняется через PUT.
- recalc-retail / supply posting: дефолтный PriceType ищется по
  IsSystem → IsDefault → IsRetail → SortOrder → Name (без IsActive).
- OrgSettingsDto/Input — без MultiplePriceTypesEnabled.

Web:
- types.ts: убраны isActive у Product/ProductGroup/UnitOfMeasure/
  Counterparty/PriceType. PriceType пополнен isRequired/isSystem.
  Product получил shelfLifeDays.
- useOrgSettings: убрано multiplePriceTypesEnabled.
- AppLayout: меню «Типы цен» всегда видно.
- Pages (Counterparties/Units/ProductGroups/PriceTypes/ProductEdit/
  OrganizationSettings): сняты колонки/чекбоксы/поля «Активен»;
  удалён GroupMarkupsPage; в PriceTypesPage добавлен Lock-индикатор
  системной записи и блок-подсказка, кнопка удаления скрыта.
- DemoCatalogSeeder и OtherSystem-импортёр: больше не пишут IsActive.

UI-перекомпоновка карточки товара (Phase3b пп.6/9), Supply Posted-toggle,
PercentInput, ShelfLifeDays-фильтр и редизайн прайс-секции — отдельными
коммитами далее по плану.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-25 22:46:34 +05:00
parent 9c0e9494f3
commit b79c71591d
31 changed files with 2117 additions and 254 deletions

View file

@ -44,8 +44,6 @@ public class CounterpartiesController : ControllerBase
("legalName", true) => q.OrderByDescending(c => c.LegalName).ThenBy(c => c.Name), ("legalName", true) => q.OrderByDescending(c => c.LegalName).ThenBy(c => c.Name),
("phone", false) => q.OrderBy(c => c.Phone).ThenBy(c => c.Name), ("phone", false) => q.OrderBy(c => c.Phone).ThenBy(c => c.Name),
("phone", true) => q.OrderByDescending(c => c.Phone).ThenBy(c => c.Name), ("phone", true) => q.OrderByDescending(c => c.Phone).ThenBy(c => c.Name),
("isActive", false) => q.OrderBy(c => c.IsActive).ThenBy(c => c.Name),
("isActive", true) => q.OrderByDescending(c => c.IsActive).ThenBy(c => c.Name),
("name", true) => q.OrderByDescending(c => c.Name), ("name", true) => q.OrderByDescending(c => c.Name),
_ => q.OrderBy(c => c.Name), _ => q.OrderBy(c => c.Name),
}; };
@ -55,7 +53,7 @@ public class CounterpartiesController : ControllerBase
c.Id, c.Name, c.LegalName, c.Type, c.Id, c.Name, c.LegalName, c.Type,
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country != null ? c.Country.Name : null, c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country != null ? c.Country.Name : null,
c.Address, c.Phone, c.Email, c.Address, c.Phone, c.Email,
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive)) c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes))
.ToListAsync(ct); .ToListAsync(ct);
return new PagedResult<CounterpartyDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; return new PagedResult<CounterpartyDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
} }
@ -68,7 +66,7 @@ public async Task<ActionResult<CounterpartyDto>> Get(Guid id, CancellationToken
c.Id, c.Name, c.LegalName, c.Type, c.Id, c.Name, c.LegalName, c.Type,
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name, c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name,
c.Address, c.Phone, c.Email, c.Address, c.Phone, c.Email,
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive); c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes);
} }
[HttpPost, Authorize(Roles = "Admin,Manager,Storekeeper")] [HttpPost, Authorize(Roles = "Admin,Manager,Storekeeper")]
@ -117,7 +115,6 @@ private static Counterparty Apply(Counterparty e, CounterpartyInput i)
e.Bik = i.Bik; e.Bik = i.Bik;
e.ContactPerson = i.ContactPerson; e.ContactPerson = i.ContactPerson;
e.Notes = i.Notes; e.Notes = i.Notes;
e.IsActive = i.IsActive;
return e; return e;
} }
@ -128,6 +125,6 @@ private async Task<CounterpartyDto> ProjectAsync(Guid id, CancellationToken ct)
c.Id, c.Name, c.LegalName, c.Type, c.Id, c.Name, c.LegalName, c.Type,
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name, c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name,
c.Address, c.Phone, c.Email, c.Address, c.Phone, c.Email,
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive); c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes);
} }
} }

View file

@ -31,17 +31,13 @@ public async Task<ActionResult<PagedResult<PriceTypeDto>>> List([FromQuery] Page
{ {
("name", false) => q.OrderBy(p => p.Name), ("name", false) => q.OrderBy(p => p.Name),
("name", true) => q.OrderByDescending(p => p.Name), ("name", true) => q.OrderByDescending(p => p.Name),
("isDefault", false) => q.OrderBy(p => p.IsDefault).ThenBy(p => p.Name), ("isRequired", false) => q.OrderBy(p => p.IsRequired).ThenBy(p => p.Name),
("isDefault", true) => q.OrderByDescending(p => p.IsDefault).ThenBy(p => p.Name), ("isRequired", true) => q.OrderByDescending(p => p.IsRequired).ThenBy(p => p.Name),
("isRetail", false) => q.OrderBy(p => p.IsRetail).ThenBy(p => p.Name), _ => q.OrderByDescending(p => p.IsSystem).ThenBy(p => p.SortOrder).ThenBy(p => p.Name),
("isRetail", true) => q.OrderByDescending(p => p.IsRetail).ThenBy(p => p.Name),
("isActive", false) => q.OrderBy(p => p.IsActive).ThenBy(p => p.Name),
("isActive", true) => q.OrderByDescending(p => p.IsActive).ThenBy(p => p.Name),
_ => q.OrderByDescending(p => p.IsDefault).ThenBy(p => p.SortOrder).ThenBy(p => p.Name),
}; };
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.IsDefault, p.IsRetail, p.SortOrder, p.IsActive)) .Select(p => new PriceTypeDto(p.Id, p.Name, p.IsRequired, p.IsSystem, p.IsDefault, 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 };
} }
@ -50,7 +46,7 @@ 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.IsDefault, p.IsRetail, p.SortOrder, p.IsActive); return p is null ? NotFound() : new PriceTypeDto(p.Id, p.Name, p.IsRequired, p.IsSystem, p.IsDefault, p.IsRetail, p.SortOrder);
} }
[HttpPost, Authorize(Roles = "Admin,Manager")] [HttpPost, Authorize(Roles = "Admin,Manager")]
@ -62,13 +58,17 @@ public async Task<ActionResult<PriceTypeDto>> Create([FromBody] PriceTypeInput i
} }
var e = new PriceType var e = new PriceType
{ {
Name = input.Name, IsDefault = input.IsDefault, IsRetail = input.IsRetail, Name = input.Name,
SortOrder = input.SortOrder, IsActive = input.IsActive, IsRequired = input.IsRequired,
IsSystem = false,
IsDefault = input.IsDefault,
IsRetail = input.IsRetail,
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.IsDefault, e.IsRetail, e.SortOrder, e.IsActive)); new PriceTypeDto(e.Id, e.Name, e.IsRequired, e.IsSystem, e.IsDefault, e.IsRetail, e.SortOrder));
} }
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
@ -84,7 +84,8 @@ public async Task<IActionResult> Update(Guid id, [FromBody] PriceTypeInput input
e.IsDefault = input.IsDefault; e.IsDefault = input.IsDefault;
e.IsRetail = input.IsRetail; e.IsRetail = input.IsRetail;
e.SortOrder = input.SortOrder; e.SortOrder = input.SortOrder;
e.IsActive = input.IsActive; // У системной записи IsRequired всегда true и не меняется.
e.IsRequired = e.IsSystem ? true : input.IsRequired;
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return NoContent(); return NoContent();
} }
@ -94,6 +95,7 @@ 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 = "Системная запись не может быть удалена." });
_db.PriceTypes.Remove(e); _db.PriceTypes.Remove(e);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return NoContent(); return NoContent();

View file

@ -40,13 +40,11 @@ public class ProductGroupsController : ControllerBase
("name", true) => q.OrderByDescending(g => g.Name), ("name", true) => q.OrderByDescending(g => g.Name),
("path", false) => q.OrderBy(g => g.Path), ("path", false) => q.OrderBy(g => g.Path),
("path", true) => q.OrderByDescending(g => g.Path), ("path", true) => q.OrderByDescending(g => g.Path),
("isActive", false) => q.OrderBy(g => g.IsActive).ThenBy(g => g.Path),
("isActive", true) => q.OrderByDescending(g => g.IsActive).ThenBy(g => g.Path),
_ => q.OrderBy(g => g.Path).ThenBy(g => g.SortOrder).ThenBy(g => g.Name), _ => q.OrderBy(g => g.Path).ThenBy(g => g.SortOrder).ThenBy(g => g.Name),
}; };
var items = await q var items = await q
.Skip(req.Skip).Take(req.Take) .Skip(req.Skip).Take(req.Take)
.Select(g => new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.IsActive, g.MarkupPercent)) .Select(g => new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.MarkupPercent))
.ToListAsync(ct); .ToListAsync(ct);
return new PagedResult<ProductGroupDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; return new PagedResult<ProductGroupDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
} }
@ -55,7 +53,7 @@ public class ProductGroupsController : ControllerBase
public async Task<ActionResult<ProductGroupDto>> Get(Guid id, CancellationToken ct) public async Task<ActionResult<ProductGroupDto>> Get(Guid id, CancellationToken ct)
{ {
var g = await _db.ProductGroups.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); var g = await _db.ProductGroups.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
return g is null ? NotFound() : new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.IsActive, g.MarkupPercent); return g is null ? NotFound() : new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.MarkupPercent);
} }
[HttpPost, Authorize(Roles = "Admin,Manager")] [HttpPost, Authorize(Roles = "Admin,Manager")]
@ -65,13 +63,13 @@ public async Task<ActionResult<ProductGroupDto>> Create([FromBody] ProductGroupI
var e = new ProductGroup var e = new ProductGroup
{ {
Name = input.Name, ParentId = input.ParentId, Path = path, Name = input.Name, ParentId = input.ParentId, Path = path,
SortOrder = input.SortOrder, IsActive = input.IsActive, SortOrder = input.SortOrder,
MarkupPercent = input.MarkupPercent, MarkupPercent = input.MarkupPercent,
}; };
_db.ProductGroups.Add(e); _db.ProductGroups.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 ProductGroupDto(e.Id, e.Name, e.ParentId, e.Path, e.SortOrder, e.IsActive, e.MarkupPercent)); new ProductGroupDto(e.Id, e.Name, e.ParentId, e.Path, e.SortOrder, e.MarkupPercent));
} }
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
@ -85,7 +83,6 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductGroupInput in
e.ParentId = input.ParentId; e.ParentId = input.ParentId;
e.Path = await BuildPathAsync(input.ParentId, input.Name, ct); e.Path = await BuildPathAsync(input.ParentId, input.Name, ct);
e.SortOrder = input.SortOrder; e.SortOrder = input.SortOrder;
e.IsActive = input.IsActive;
e.MarkupPercent = input.MarkupPercent; e.MarkupPercent = input.MarkupPercent;
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return NoContent(); return NoContent();

View file

@ -92,7 +92,6 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
[FromQuery] bool? isService, [FromQuery] bool? isService,
[FromQuery] Packaging? packaging, [FromQuery] Packaging? packaging,
[FromQuery] bool? isMarked, [FromQuery] bool? isMarked,
[FromQuery] bool? isActive,
[FromQuery] decimal? purchasePriceFrom, [FromQuery] decimal? purchasePriceFrom,
[FromQuery] decimal? purchasePriceTo, [FromQuery] decimal? purchasePriceTo,
CancellationToken ct) CancellationToken ct)
@ -116,7 +115,6 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
if (isService is not null) q = q.Where(p => p.IsService == isService); if (isService is not null) q = q.Where(p => p.IsService == isService);
if (packaging is not null) q = q.Where(p => p.Packaging == packaging); if (packaging is not null) q = q.Where(p => p.Packaging == packaging);
if (isMarked is not null) q = q.Where(p => p.IsMarked == isMarked); if (isMarked is not null) q = q.Where(p => p.IsMarked == isMarked);
if (isActive is not null) q = q.Where(p => p.IsActive == isActive);
if (purchasePriceFrom is not null) q = q.Where(p => p.ReferencePrice >= purchasePriceFrom); if (purchasePriceFrom is not null) q = q.Where(p => p.ReferencePrice >= purchasePriceFrom);
if (purchasePriceTo is not null) q = q.Where(p => p.ReferencePrice <= purchasePriceTo); if (purchasePriceTo is not null) q = q.Where(p => p.ReferencePrice <= purchasePriceTo);
@ -144,8 +142,6 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
("purchasePrice", true) => q.OrderByDescending(p => p.ReferencePrice).ThenBy(p => p.Name), ("purchasePrice", true) => q.OrderByDescending(p => p.ReferencePrice).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),
("isActive", false) => q.OrderBy(p => p.IsActive).ThenBy(p => p.Name),
("isActive", true) => q.OrderByDescending(p => p.IsActive).ThenBy(p => p.Name),
("name", true) => q.OrderByDescending(p => p.Name), ("name", true) => q.OrderByDescending(p => p.Name),
_ => q.OrderBy(p => p.Name), _ => q.OrderBy(p => p.Name),
}; };
@ -294,14 +290,14 @@ public async Task<IActionResult> RecalcRetail(Guid id, CancellationToken ct)
: Math.Ceiling(raw); : Math.Ceiling(raw);
var defaultType = await _db.PriceTypes var defaultType = await _db.PriceTypes
.Where(pt => pt.IsActive) .OrderByDescending(pt => pt.IsSystem)
.OrderByDescending(pt => pt.IsDefault) .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)
.FirstOrDefaultAsync(ct); .FirstOrDefaultAsync(ct);
if (defaultType is null) if (defaultType is null)
return BadRequest(new { error = "Нет ни одного активного типа цен. Создайте его в настройках." }); return BadRequest(new { error = "Нет ни одного типа цен. Создайте его в настройках." });
var fallbackCurrency = await _db.Organizations.Select(o => o.DefaultCurrencyId).FirstOrDefaultAsync(ct) var fallbackCurrency = await _db.Organizations.Select(o => o.DefaultCurrencyId).FirstOrDefaultAsync(ct)
?? await _db.Currencies.OrderBy(c => c.Code).Select(c => (Guid?)c.Id).FirstOrDefaultAsync(ct); ?? await _db.Currencies.OrderBy(c => c.Code).Select(c => (Guid?)c.Id).FirstOrDefaultAsync(ct);
@ -384,7 +380,7 @@ public async Task<ActionResult<IReadOnlyList<BarcodeDuplicate>>> BarcodeDuplicat
p.ReferencePrice, p.ReferencePriceUpdatedAt, p.ReferencePrice, p.ReferencePriceUpdatedAt,
p.PurchaseCurrencyId, p.PurchaseCurrency != null ? p.PurchaseCurrency.Code : null, p.PurchaseCurrencyId, p.PurchaseCurrency != null ? p.PurchaseCurrency.Code : null,
p.Cost, p.LastSupplyAt, p.Cost, p.LastSupplyAt,
p.ImageUrl, p.IsActive, p.ImageUrl, p.ShelfLifeDays,
p.Prices.Select(pr => new ProductPriceDto(pr.Id, pr.PriceTypeId, pr.PriceType!.Name, pr.Amount, pr.CurrencyId, pr.Currency!.Code)).ToList(), p.Prices.Select(pr => new ProductPriceDto(pr.Id, pr.PriceTypeId, pr.PriceType!.Name, pr.Amount, pr.CurrencyId, pr.Currency!.Code)).ToList(),
p.Barcodes.Select(b => new ProductBarcodeDto(b.Id, b.Code, b.Type, b.IsPrimary)).ToList()); p.Barcodes.Select(b => new ProductBarcodeDto(b.Id, b.Code, b.Type, b.IsPrimary)).ToList());
@ -414,6 +410,6 @@ private static void Apply(Product e, ProductInput i)
} }
e.PurchaseCurrencyId = i.PurchaseCurrencyId; e.PurchaseCurrencyId = i.PurchaseCurrencyId;
e.ImageUrl = i.ImageUrl; e.ImageUrl = i.ImageUrl;
e.IsActive = i.IsActive; e.ShelfLifeDays = i.ShelfLifeDays;
} }
} }

View file

@ -31,14 +31,12 @@ public async Task<ActionResult<PagedResult<UnitOfMeasureDto>>> List([FromQuery]
{ {
("code", false) => q.OrderBy(u => u.Code), ("code", false) => q.OrderBy(u => u.Code),
("code", true) => q.OrderByDescending(u => u.Code), ("code", true) => q.OrderByDescending(u => u.Code),
("isActive", false) => q.OrderBy(u => u.IsActive).ThenBy(u => u.Name),
("isActive", true) => q.OrderByDescending(u => u.IsActive).ThenBy(u => u.Name),
("name", true) => q.OrderByDescending(u => u.Name), ("name", true) => q.OrderByDescending(u => u.Name),
_ => q.OrderBy(u => u.Name), _ => q.OrderBy(u => u.Name),
}; };
var items = await q var items = await q
.Skip(req.Skip).Take(req.Take) .Skip(req.Skip).Take(req.Take)
.Select(u => new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.IsActive)) .Select(u => new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description))
.ToListAsync(ct); .ToListAsync(ct);
return new PagedResult<UnitOfMeasureDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take }; return new PagedResult<UnitOfMeasureDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
} }
@ -47,7 +45,7 @@ public async Task<ActionResult<PagedResult<UnitOfMeasureDto>>> List([FromQuery]
public async Task<ActionResult<UnitOfMeasureDto>> Get(Guid id, CancellationToken ct) public async Task<ActionResult<UnitOfMeasureDto>> Get(Guid id, CancellationToken ct)
{ {
var u = await _db.UnitsOfMeasure.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct); var u = await _db.UnitsOfMeasure.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
return u is null ? NotFound() : new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.IsActive); return u is null ? NotFound() : new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description);
} }
[HttpPost, Authorize(Roles = "Admin,Manager")] [HttpPost, Authorize(Roles = "Admin,Manager")]
@ -58,12 +56,11 @@ public async Task<ActionResult<UnitOfMeasureDto>> Create([FromBody] UnitOfMeasur
Code = input.Code, Code = input.Code,
Name = input.Name, Name = input.Name,
Description = input.Description, Description = input.Description,
IsActive = input.IsActive,
}; };
_db.UnitsOfMeasure.Add(e); _db.UnitsOfMeasure.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 UnitOfMeasureDto(e.Id, e.Code, e.Name, e.Description, e.IsActive)); new UnitOfMeasureDto(e.Id, e.Code, e.Name, e.Description));
} }
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")] [HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
@ -75,7 +72,6 @@ public async Task<IActionResult> Update(Guid id, [FromBody] UnitOfMeasureInput i
e.Code = input.Code; e.Code = input.Code;
e.Name = input.Name; e.Name = input.Name;
e.Description = input.Description; e.Description = input.Description;
e.IsActive = input.IsActive;
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
return NoContent(); return NoContent();
} }

View file

@ -35,7 +35,6 @@ public record OrgSettingsDto(
bool ShowMarkedOnProduct, bool ShowMarkedOnProduct,
bool ShowMinMaxStock, bool ShowMinMaxStock,
bool AllowFractionalPrices, bool AllowFractionalPrices,
bool MultiplePriceTypesEnabled,
bool ShowReferencePriceOnProduct); bool ShowReferencePriceOnProduct);
// DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId). // DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId).
@ -48,7 +47,6 @@ public record OrgSettingsInput(
bool ShowMarkedOnProduct, bool ShowMarkedOnProduct,
bool ShowMinMaxStock, bool ShowMinMaxStock,
bool AllowFractionalPrices, bool AllowFractionalPrices,
bool MultiplePriceTypesEnabled,
bool ShowReferencePriceOnProduct); bool ShowReferencePriceOnProduct);
[HttpGet("settings")] [HttpGet("settings")]
@ -85,7 +83,6 @@ public async Task<ActionResult<OrgSettingsDto>> Update([FromBody] OrgSettingsInp
o.ShowMarkedOnProduct = input.ShowMarkedOnProduct; o.ShowMarkedOnProduct = input.ShowMarkedOnProduct;
o.ShowMinMaxStock = input.ShowMinMaxStock; o.ShowMinMaxStock = input.ShowMinMaxStock;
o.AllowFractionalPrices = input.AllowFractionalPrices; o.AllowFractionalPrices = input.AllowFractionalPrices;
o.MultiplePriceTypesEnabled = input.MultiplePriceTypesEnabled;
o.ShowReferencePriceOnProduct = input.ShowReferencePriceOnProduct; o.ShowReferencePriceOnProduct = input.ShowReferencePriceOnProduct;
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
@ -115,6 +112,5 @@ private async Task<decimal> ReadVatRateAsync(string countryCode, CancellationTok
o.ShowMarkedOnProduct, o.ShowMarkedOnProduct,
o.ShowMinMaxStock, o.ShowMinMaxStock,
o.AllowFractionalPrices, o.AllowFractionalPrices,
o.MultiplePriceTypesEnabled,
o.ShowReferencePriceOnProduct); o.ShowReferencePriceOnProduct);
} }

View file

@ -301,8 +301,8 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
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
.Where(pt => pt.IsActive) .OrderByDescending(pt => pt.IsSystem)
.OrderByDescending(pt => pt.IsDefault) .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

@ -66,7 +66,6 @@ Guid AddGroup(string name, Guid? parentId)
db.ProductGroups.Add(new ProductGroup db.ProductGroups.Add(new ProductGroup
{ {
Id = id, OrganizationId = orgId, Name = name, ParentId = parentId, Id = id, OrganizationId = orgId, Name = name, ParentId = parentId,
Path = path, SortOrder = groups.Count, IsActive = true,
}); });
groups[path] = id; groups[path] = id;
return id; return id;
@ -92,7 +91,6 @@ Guid AddGroup(string name, Guid? parentId)
Bin = "100140005678", CountryId = kz?.Id, Bin = "100140005678", CountryId = kz?.Id,
Address = "Алматы, ул. Абая 15", Phone = "+7 (727) 100-00-01", Address = "Алматы, ул. Абая 15", Phone = "+7 (727) 100-00-01",
Email = "order@prodtrade.kz", BankName = "Kaspi Bank", BankAccount = "KZ000000000000000001", Bik = "CASPKZKA", Email = "order@prodtrade.kz", BankName = "Kaspi Bank", BankAccount = "KZ000000000000000001", Bik = "CASPKZKA",
IsActive = true,
}; };
var supplier2 = new Counterparty var supplier2 = new Counterparty
{ {
@ -100,7 +98,6 @@ Guid AddGroup(string name, Guid? parentId)
Type = CounterpartyType.Individual, Type = CounterpartyType.Individual,
Iin = "850101300000", CountryId = kz?.Id, Iin = "850101300000", CountryId = kz?.Id,
Phone = "+7 (777) 100-00-02", ContactPerson = "Иванов Алексей", Phone = "+7 (777) 100-00-02", ContactPerson = "Иванов Алексей",
IsActive = true,
}; };
db.Counterparties.AddRange(supplier1, supplier2); db.Counterparties.AddRange(supplier1, supplier2);
@ -166,7 +163,6 @@ Guid AddGroup(string name, Guid? parentId)
ProductGroupId = d.Group, ProductGroupId = d.Group,
CountryOfOriginId = d.Country, CountryOfOriginId = d.Country,
Packaging = d.IsWeighed ? Packaging.Weight : Packaging.Piece, Packaging = d.IsWeighed ? Packaging.Weight : Packaging.Piece,
IsActive = true,
ReferencePrice = Math.Round(d.RetailPrice * 0.72m, 2), ReferencePrice = Math.Round(d.RetailPrice * 0.72m, 2),
PurchaseCurrencyId = kzt.Id, PurchaseCurrencyId = kzt.Id,
Prices = Prices =

View file

@ -12,10 +12,11 @@ public record CountryDto(
public record CurrencyDto(Guid Id, string Code, string Name, string Symbol); public record CurrencyDto(Guid Id, string Code, string Name, string Symbol);
public record UnitOfMeasureDto( public record UnitOfMeasureDto(
Guid Id, string Code, string Name, string? Description, bool IsActive); Guid Id, string Code, string Name, string? Description);
public record PriceTypeDto( public record PriceTypeDto(
Guid Id, string Name, bool IsDefault, bool IsRetail, int SortOrder, bool IsActive); Guid Id, string Name, bool IsRequired, bool IsSystem,
bool IsDefault, 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,
@ -26,14 +27,14 @@ public record RetailPointDto(
string? Address, string? Phone, string? FiscalSerial, string? FiscalRegNumber, bool IsActive); string? Address, string? Phone, string? FiscalSerial, string? FiscalRegNumber, bool IsActive);
public record ProductGroupDto( public record ProductGroupDto(
Guid Id, string Name, Guid? ParentId, string Path, int SortOrder, bool IsActive, Guid Id, string Name, Guid? ParentId, string Path, int SortOrder,
decimal? MarkupPercent); decimal? MarkupPercent);
public record CounterpartyDto( public record CounterpartyDto(
Guid Id, string Name, string? LegalName, CounterpartyType Type, Guid Id, string Name, string? LegalName, CounterpartyType Type,
string? Bin, string? Iin, string? TaxNumber, Guid? CountryId, string? CountryName, string? Bin, string? Iin, string? TaxNumber, Guid? CountryId, string? CountryName,
string? Address, string? Phone, string? Email, string? Address, string? Phone, string? Email,
string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes, bool IsActive); string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes);
public record ProductBarcodeDto(Guid Id, string Code, BarcodeType Type, bool IsPrimary); public record ProductBarcodeDto(Guid Id, string Code, BarcodeType Type, bool IsPrimary);
@ -51,7 +52,7 @@ public record ProductDto(
decimal? ReferencePrice, DateTime? ReferencePriceUpdatedAt, decimal? ReferencePrice, DateTime? ReferencePriceUpdatedAt,
Guid? PurchaseCurrencyId, string? PurchaseCurrencyCode, Guid? PurchaseCurrencyId, string? PurchaseCurrencyCode,
decimal Cost, DateTime? LastSupplyAt, decimal Cost, DateTime? LastSupplyAt,
string? ImageUrl, bool IsActive, string? ImageUrl, int? ShelfLifeDays,
IReadOnlyList<ProductPriceDto> Prices, IReadOnlyList<ProductPriceDto> Prices,
IReadOnlyList<ProductBarcodeDto> Barcodes); IReadOnlyList<ProductBarcodeDto> Barcodes);
@ -60,8 +61,10 @@ public record CountryInput(
string Code, string Name, string Code, string Name,
Guid? DefaultCurrencyId = null, decimal VatRate = 0m); Guid? DefaultCurrencyId = null, decimal VatRate = 0m);
public record CurrencyInput(string Code, string Name, string Symbol); public record CurrencyInput(string Code, string Name, string Symbol);
public record UnitOfMeasureInput(string Code, string Name, string? Description = null, bool IsActive = true); public record UnitOfMeasureInput(string Code, string Name, string? Description = null);
public record PriceTypeInput(string Name, bool IsDefault = false, bool IsRetail = false, int SortOrder = 0, bool IsActive = true); public record PriceTypeInput(
string Name, bool IsRequired = false,
bool IsDefault = false, 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,
@ -71,13 +74,13 @@ public record RetailPointInput(
string? Address = null, string? Phone = null, string? Address = null, string? Phone = null,
string? FiscalSerial = null, string? FiscalRegNumber = null, bool IsActive = true); string? FiscalSerial = null, string? FiscalRegNumber = null, bool IsActive = true);
public record ProductGroupInput( public record ProductGroupInput(
string Name, Guid? ParentId, int SortOrder = 0, bool IsActive = true, string Name, Guid? ParentId, int SortOrder = 0,
[Range(0, 1000)] decimal? MarkupPercent = null); [Range(0, 1000)] decimal? MarkupPercent = null);
public record CounterpartyInput( public record CounterpartyInput(
string Name, string? LegalName, CounterpartyType Type, string Name, string? LegalName, CounterpartyType Type,
string? Bin, string? Iin, string? TaxNumber, Guid? CountryId, string? Bin, string? Iin, string? TaxNumber, Guid? CountryId,
string? Address, string? Phone, string? Email, string? Address, string? Phone, string? Email,
string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes, bool IsActive = true); string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes);
public record ProductBarcodeInput(string Code, BarcodeType Type = BarcodeType.Ean13, bool IsPrimary = false); public record ProductBarcodeInput(string Code, BarcodeType Type = BarcodeType.Ean13, bool IsPrimary = false);
public record ProductPriceInput(Guid PriceTypeId, [Range(0, 1e10)] decimal Amount, Guid CurrencyId); public record ProductPriceInput(Guid PriceTypeId, [Range(0, 1e10)] decimal Amount, Guid CurrencyId);
public record ProductInput( public record ProductInput(
@ -87,6 +90,6 @@ public record ProductInput(
bool IsService = false, Packaging Packaging = Packaging.Piece, bool IsMarked = false, bool IsService = false, Packaging Packaging = Packaging.Piece, bool IsMarked = false,
[Range(0, 1e10)] decimal? MinStock = null, [Range(0, 1e10)] decimal? MaxStock = null, [Range(0, 1e10)] decimal? MinStock = null, [Range(0, 1e10)] decimal? MaxStock = null,
[Range(0, 1e10)] decimal? ReferencePrice = null, Guid? PurchaseCurrencyId = null, [Range(0, 1e10)] decimal? ReferencePrice = null, Guid? PurchaseCurrencyId = null,
string? ImageUrl = null, bool IsActive = true, string? ImageUrl = null, [Range(0, 100000)] int? ShelfLifeDays = null,
IReadOnlyList<ProductPriceInput>? Prices = null, IReadOnlyList<ProductPriceInput>? Prices = null,
IReadOnlyList<ProductBarcodeInput>? Barcodes = null); IReadOnlyList<ProductBarcodeInput>? Barcodes = null);

View file

@ -20,5 +20,4 @@ public class Counterparty : TenantEntity
public string? Bik { get; set; } public string? Bik { get; set; }
public string? ContactPerson { get; set; } public string? ContactPerson { get; set; }
public string? Notes { get; set; } public string? Notes { get; set; }
public bool IsActive { get; set; } = true;
} }

View file

@ -6,8 +6,12 @@ namespace foodmarket.Domain.Catalog;
public class PriceType : TenantEntity public class PriceType : TenantEntity
{ {
public string Name { get; set; } = null!; public string Name { get; set; } = null!;
/// <summary>true — цена должна быть заполнена у каждого товара (валидация на UI и сервере).</summary>
public bool IsRequired { get; set; }
/// <summary>true — системная запись «Розничная цена», не удаляется и
/// IsRequired всегда true. Имя можно переименовать. Сидируется при первом старте.</summary>
public bool IsSystem { get; set; }
public bool IsDefault { get; set; } // цена по умолчанию для новых товаров public bool IsDefault { get; set; } // цена по умолчанию для новых товаров
public bool IsRetail { get; set; } // используется на кассе public bool IsRetail { get; set; } // используется на кассе
public int SortOrder { get; set; } public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
} }

View file

@ -54,7 +54,8 @@ public class Product : TenantEntity
public DateTime? LastSupplyAt { get; set; } public DateTime? LastSupplyAt { get; set; }
public string? ImageUrl { get; set; } // основное изображение (остальные в ProductImage) public string? ImageUrl { get; set; } // основное изображение (остальные в ProductImage)
public bool IsActive { get; set; } = true; /// <summary>Срок годности в днях (для отчётов и фильтрации). Не обязательное.</summary>
public int? ShelfLifeDays { get; set; }
public ICollection<ProductPrice> Prices { get; set; } = []; public ICollection<ProductPrice> Prices { get; set; } = [];
public ICollection<ProductBarcode> Barcodes { get; set; } = []; public ICollection<ProductBarcode> Barcodes { get; set; } = [];

View file

@ -11,7 +11,6 @@ public class ProductGroup : TenantEntity
public ICollection<ProductGroup> Children { get; set; } = []; public ICollection<ProductGroup> Children { get; set; } = [];
public string Path { get; set; } = ""; // денормализованный путь "Электроника/Телефоны/Смартфоны" для фильтрации public string Path { get; set; } = ""; // денормализованный путь "Электроника/Телефоны/Смартфоны" для фильтрации
public int SortOrder { get; set; } public int SortOrder { get; set; }
public bool IsActive { get; set; } = true;
/// <summary>Процент наценки на себестоимость для автоматического расчёта /// <summary>Процент наценки на себестоимость для автоматического расчёта
/// розничной цены при проведении приёмки. NULL = автонаценка отключена.</summary> /// розничной цены при проведении приёмки. NULL = автонаценка отключена.</summary>

View file

@ -8,5 +8,4 @@ public class UnitOfMeasure : TenantEntity
public string Code { get; set; } = null!; // ОКЕИ код: "796" (шт), "166" (кг), "112" (л) public string Code { get; set; } = null!; // ОКЕИ код: "796" (шт), "166" (кг), "112" (л)
public string Name { get; set; } = null!; // "штука", "килограмм", "литр" public string Name { get; set; } = null!; // "штука", "килограмм", "литр"
public string? Description { get; set; } public string? Description { get; set; }
public bool IsActive { get; set; } = true;
} }

View file

@ -54,11 +54,6 @@ public class Organization : Entity
/// дробное через API.</summary> /// дробное через API.</summary>
public bool AllowFractionalPrices { get; set; } public bool AllowFractionalPrices { get; set; }
/// <summary>Если true — в карточке товара рендерится список цен по всем
/// PriceType, есть страница «Настройки → Типы цен». Если false (default)
/// — одно поле «Розничная цена», работающее с дефолтным PriceType.</summary>
public bool MultiplePriceTypesEnabled { get; set; }
/// <summary>Показывать ли в карточке товара поле «Эталонная цена». /// <summary>Показывать ли в карточке товара поле «Эталонная цена».
/// Default: true.</summary> /// Default: true.</summary>
public bool ShowReferencePriceOnProduct { get; set; } = true; public bool ShowReferencePriceOnProduct { get; set; } = true;

View file

@ -131,7 +131,6 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
entity.Email = Trim(c.Email, 255); entity.Email = Trim(c.Email, 255);
entity.Address = Trim(c.ActualAddress ?? c.LegalAddress, 500); entity.Address = Trim(c.ActualAddress ?? c.LegalAddress, 500);
entity.Notes = Trim(c.Description, 1000); entity.Notes = Trim(c.Description, 1000);
entity.IsActive = !c.Archived;
} }
public async Task<MoySkladImportResult> ImportProductsAsync( public async Task<MoySkladImportResult> ImportProductsAsync(
@ -168,7 +167,6 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
OrganizationId = orgId, OrganizationId = orgId,
Name = "Продукты питания", Name = "Продукты питания",
Path = "Продукты питания", Path = "Продукты питания",
IsActive = true,
}; };
_db.ProductGroups.Add(defaultGroup); _db.ProductGroups.Add(defaultGroup);
await _db.SaveChangesAsync(ct); await _db.SaveChangesAsync(ct);
@ -195,7 +193,6 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
OrganizationId = orgId, OrganizationId = orgId,
Name = f.Name, Name = f.Name,
Path = string.IsNullOrEmpty(f.PathName) ? f.Name : $"{f.PathName}/{f.Name}", Path = string.IsNullOrEmpty(f.PathName) ? f.Name : $"{f.PathName}/{f.Name}",
IsActive = !f.Archived,
}; };
_db.ProductGroups.Add(g); _db.ProductGroups.Add(g);
localGroupByMsId[f.Id] = g.Id; localGroupByMsId[f.Id] = g.Id;
@ -262,7 +259,6 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
product.CountryOfOriginId = countryId ?? product.CountryOfOriginId; product.CountryOfOriginId = countryId ?? product.CountryOfOriginId;
product.Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece; product.Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece;
product.IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED"; product.IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED";
product.IsActive = !p.Archived;
product.ReferencePrice = p.BuyPrice is null ? product.ReferencePrice : p.BuyPrice.Value / 100m; product.ReferencePrice = p.BuyPrice is null ? product.ReferencePrice : p.BuyPrice.Value / 100m;
updated++; updated++;
if (progress is not null) progress.Updated = updated; if (progress is not null) progress.Updated = updated;
@ -282,7 +278,6 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
CountryOfOriginId = countryId, CountryOfOriginId = countryId,
Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece, Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece,
IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED", IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED",
IsActive = !p.Archived,
ReferencePrice = p.BuyPrice is null ? null : p.BuyPrice.Value / 100m, ReferencePrice = p.BuyPrice is null ? null : p.BuyPrice.Value / 100m,
PurchaseCurrencyId = kzt.Id, PurchaseCurrencyId = kzt.Id,
}; };

View file

@ -133,7 +133,6 @@ private static void ConfigureProduct(EntityTypeBuilder<Product> b)
b.HasIndex(x => new { x.OrganizationId, x.Name }); b.HasIndex(x => new { x.OrganizationId, x.Name });
b.HasIndex(x => new { x.OrganizationId, x.Article }); b.HasIndex(x => new { x.OrganizationId, x.Article });
b.HasIndex(x => new { x.OrganizationId, x.ProductGroupId }); b.HasIndex(x => new { x.OrganizationId, x.ProductGroupId });
b.HasIndex(x => new { x.OrganizationId, x.IsActive });
} }
private static void ConfigureProductPrice(EntityTypeBuilder<ProductPrice> b) private static void ConfigureProductPrice(EntityTypeBuilder<ProductPrice> b)

View file

@ -0,0 +1,87 @@
using Microsoft.EntityFrameworkCore.Migrations;
#nullable disable
namespace foodmarket.Infrastructure.Persistence.Migrations
{
/// <summary>Phase3b — рационализация модели:
/// - DROP IsActive у products / product_groups / units_of_measure / counterparties / price_types.
/// - DROP organizations.MultiplePriceTypesEnabled (раздел «Типы цен» теперь всегда виден).
/// - DROP price_types.IsActive (системная запись неудаляема, остальные удаляются физически).
/// - ADD price_types.IsRequired bool default false.
/// - ADD price_types.IsSystem bool default false.
/// - ADD products.ShelfLifeDays integer NULL.
/// - Гарантирует существование одной системной записи «Розничная цена»
/// (IsSystem=true, IsRequired=true) в каждой организации.</summary>
public partial class Phase3b_PricingCleanup : Migration
{
protected override void Up(MigrationBuilder b)
{
// На products был индекс (OrganizationId, IsActive) — удалим до dropколонки.
b.Sql("DROP INDEX IF EXISTS public.\"IX_products_OrganizationId_IsActive\";");
b.DropColumn(name: "IsActive", schema: "public", table: "products");
b.DropColumn(name: "IsActive", schema: "public", table: "product_groups");
b.DropColumn(name: "IsActive", schema: "public", table: "units_of_measure");
b.DropColumn(name: "IsActive", schema: "public", table: "counterparties");
b.DropColumn(name: "IsActive", schema: "public", table: "price_types");
b.DropColumn(name: "MultiplePriceTypesEnabled", schema: "public", table: "organizations");
b.AddColumn<bool>(
name: "IsRequired", schema: "public", table: "price_types",
type: "boolean", nullable: false, defaultValue: false);
b.AddColumn<bool>(
name: "IsSystem", schema: "public", table: "price_types",
type: "boolean", nullable: false, defaultValue: false);
b.AddColumn<int>(
name: "ShelfLifeDays", schema: "public", table: "products",
type: "integer", nullable: true);
// Гарантируем системную «Розничная цена» в каждой организации.
b.Sql("""
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" AND pt."IsSystem" = true
);
""");
// Если в организации есть «Розничная цена», но она не помечена системной — пометим её.
b.Sql("""
UPDATE public.price_types
SET "IsSystem" = true, "IsRequired" = true
WHERE "Name" = 'Розничная цена'
AND "IsSystem" = false
AND "OrganizationId" IN (
SELECT "OrganizationId" FROM public.price_types
GROUP BY "OrganizationId"
HAVING SUM(CASE WHEN "IsSystem" THEN 1 ELSE 0 END) = 0
);
""");
}
protected override void Down(MigrationBuilder b)
{
b.DropColumn(name: "ShelfLifeDays", schema: "public", table: "products");
b.DropColumn(name: "IsSystem", schema: "public", table: "price_types");
b.DropColumn(name: "IsRequired", schema: "public", table: "price_types");
b.AddColumn<bool>(name: "MultiplePriceTypesEnabled", schema: "public", table: "organizations",
type: "boolean", nullable: false, defaultValue: false);
b.AddColumn<bool>(name: "IsActive", schema: "public", table: "price_types",
type: "boolean", nullable: false, defaultValue: true);
b.AddColumn<bool>(name: "IsActive", schema: "public", table: "counterparties",
type: "boolean", nullable: false, defaultValue: true);
b.AddColumn<bool>(name: "IsActive", schema: "public", table: "units_of_measure",
type: "boolean", nullable: false, defaultValue: true);
b.AddColumn<bool>(name: "IsActive", schema: "public", table: "product_groups",
type: "boolean", nullable: false, defaultValue: true);
b.AddColumn<bool>(name: "IsActive", schema: "public", table: "products",
type: "boolean", nullable: false, defaultValue: true);
}
}
}

View file

@ -377,9 +377,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasMaxLength(20) .HasMaxLength(20)
.HasColumnType("character varying(20)"); .HasColumnType("character varying(20)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("LegalName") b.Property<string>("LegalName")
.HasMaxLength(500) .HasMaxLength(500)
.HasColumnType("character varying(500)"); .HasColumnType("character varying(500)");
@ -506,15 +503,18 @@ 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>("IsActive")
.HasColumnType("boolean");
b.Property<bool>("IsDefault") b.Property<bool>("IsDefault")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<bool>("IsRequired")
.HasColumnType("boolean");
b.Property<bool>("IsRetail") b.Property<bool>("IsRetail")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(100) .HasMaxLength(100)
@ -563,9 +563,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasMaxLength(1000) .HasMaxLength(1000)
.HasColumnType("character varying(1000)"); .HasColumnType("character varying(1000)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<bool>("IsMarked") b.Property<bool>("IsMarked")
.HasColumnType("boolean"); .HasColumnType("boolean");
@ -611,6 +608,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<DateTime?>("ReferencePriceUpdatedAt") b.Property<DateTime?>("ReferencePriceUpdatedAt")
.HasColumnType("timestamp with time zone"); .HasColumnType("timestamp with time zone");
b.Property<int?>("ShelfLifeDays")
.HasColumnType("integer");
b.Property<Guid>("UnitOfMeasureId") b.Property<Guid>("UnitOfMeasureId")
.HasColumnType("uuid"); .HasColumnType("uuid");
@ -640,8 +640,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.HasIndex("OrganizationId", "Article"); b.HasIndex("OrganizationId", "Article");
b.HasIndex("OrganizationId", "IsActive");
b.HasIndex("OrganizationId", "Name"); b.HasIndex("OrganizationId", "Name");
b.HasIndex("OrganizationId", "ProductGroupId"); b.HasIndex("OrganizationId", "ProductGroupId");
@ -697,9 +695,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>("IsActive")
.HasColumnType("boolean");
b.Property<decimal?>("MarkupPercent") b.Property<decimal?>("MarkupPercent")
.HasPrecision(5, 2) .HasPrecision(5, 2)
.HasColumnType("numeric(5,2)"); .HasColumnType("numeric(5,2)");
@ -933,9 +928,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasMaxLength(500) .HasMaxLength(500)
.HasColumnType("character varying(500)"); .HasColumnType("character varying(500)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Name") b.Property<string>("Name")
.IsRequired() .IsRequired()
.HasMaxLength(100) .HasMaxLength(100)
@ -1107,9 +1099,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<bool>("MultiCurrencyEnabled") b.Property<bool>("MultiCurrencyEnabled")
.HasColumnType("boolean"); .HasColumnType("boolean");
b.Property<bool>("MultiplePriceTypesEnabled")
.HasColumnType("boolean");
b.Property<bool>("ShowMarkedOnProduct") b.Property<bool>("ShowMarkedOnProduct")
.HasColumnType("boolean"); .HasColumnType("boolean");

View file

@ -6,7 +6,6 @@ import { CountriesPage } from '@/pages/CountriesPage'
import { CurrenciesPage } from '@/pages/CurrenciesPage' import { CurrenciesPage } from '@/pages/CurrenciesPage'
import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage' import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage'
import { PriceTypesPage } from '@/pages/PriceTypesPage' import { PriceTypesPage } from '@/pages/PriceTypesPage'
import { GroupMarkupsPage } from '@/pages/GroupMarkupsPage'
import { StoresPage } from '@/pages/StoresPage' import { StoresPage } from '@/pages/StoresPage'
import { RetailPointsPage } from '@/pages/RetailPointsPage' import { RetailPointsPage } from '@/pages/RetailPointsPage'
import { ProductGroupsPage } from '@/pages/ProductGroupsPage' import { ProductGroupsPage } from '@/pages/ProductGroupsPage'
@ -48,7 +47,6 @@ export default function App() {
<Route path="/catalog/product-groups" element={<ProductGroupsPage />} /> <Route path="/catalog/product-groups" element={<ProductGroupsPage />} />
<Route path="/catalog/units" element={<UnitsOfMeasurePage />} /> <Route path="/catalog/units" element={<UnitsOfMeasurePage />} />
<Route path="/catalog/price-types" element={<PriceTypesPage />} /> <Route path="/catalog/price-types" element={<PriceTypesPage />} />
<Route path="/settings/group-markups" element={<GroupMarkupsPage />} />
<Route path="/catalog/counterparties" element={<CounterpartiesPage />} /> <Route path="/catalog/counterparties" element={<CounterpartiesPage />} />
<Route path="/catalog/stores" element={<StoresPage />} /> <Route path="/catalog/stores" element={<StoresPage />} />
<Route path="/catalog/retail-points" element={<RetailPointsPage />} /> <Route path="/catalog/retail-points" element={<RetailPointsPage />} />

View file

@ -10,8 +10,6 @@ import {
Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X, Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X,
} from 'lucide-react' } from 'lucide-react'
import { Logo } from './Logo' import { Logo } from './Logo'
import { Percent } from 'lucide-react'
import { useOrgSettings } from '@/lib/useOrgSettings'
interface MeResponse { interface MeResponse {
sub: string sub: string
@ -24,13 +22,13 @@ interface MeResponse {
type NavItem = { to: string; icon: typeof LayoutDashboard; label: string; end?: boolean } type NavItem = { to: string; icon: typeof LayoutDashboard; label: string; end?: boolean }
type NavSection = { group: string; items: NavItem[] } type NavSection = { group: string; items: NavItem[] }
function buildNav(showPriceTypes: boolean): NavSection[] { function buildNav(): NavSection[] {
const catalog: NavItem[] = [ const catalog: NavItem[] = [
{ to: '/catalog/products', icon: Package, label: 'Товары' }, { to: '/catalog/products', icon: Package, label: 'Товары' },
{ to: '/catalog/product-groups', icon: FolderTree, label: 'Группы' }, { to: '/catalog/product-groups', icon: FolderTree, label: 'Группы' },
{ to: '/catalog/units', icon: Ruler, label: 'Ед. измерения' }, { to: '/catalog/units', icon: Ruler, label: 'Ед. измерения' },
{ to: '/catalog/price-types', icon: Tag, label: 'Типы цен' },
] ]
if (showPriceTypes) catalog.push({ to: '/catalog/price-types', icon: Tag, label: 'Типы цен' })
return [ return [
{ group: 'Главное', items: [ { group: 'Главное', items: [
{ to: '/', icon: LayoutDashboard, label: 'Dashboard', end: true }, { to: '/', icon: LayoutDashboard, label: 'Dashboard', end: true },
@ -62,7 +60,6 @@ function buildNav(showPriceTypes: boolean): NavSection[] {
]}, ]},
{ group: 'Настройки', items: [ { group: 'Настройки', items: [
{ to: '/settings/organization', icon: Settings, label: 'Организация' }, { to: '/settings/organization', icon: Settings, label: 'Организация' },
{ to: '/settings/group-markups', icon: Percent, label: 'Наценки по группам' },
]}, ]},
] ]
} }
@ -74,8 +71,7 @@ export function AppLayout() {
staleTime: 5 * 60 * 1000, staleTime: 5 * 60 * 1000,
}) })
const org = useOrgSettings() const nav = buildNav()
const nav = buildNav(org.data?.multiplePriceTypesEnabled ?? false)
const [drawerOpen, setDrawerOpen] = useState(false) const [drawerOpen, setDrawerOpen] = useState(false)
const location = useLocation() const location = useLocation()

View file

@ -28,8 +28,8 @@ export interface Country {
vatRate: number vatRate: number
} }
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; isActive: boolean } export interface UnitOfMeasure { id: string; code: string; name: string; description: string | null }
export interface PriceType { id: string; name: string; isDefault: boolean; isRetail: boolean; sortOrder: number; isActive: boolean } export interface PriceType { id: string; name: string; isRequired: boolean; isSystem: boolean; isDefault: 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
@ -38,13 +38,13 @@ export interface RetailPoint {
id: string; name: string; code: string | null; storeId: string; storeName: string | null; id: string; name: string; code: string | null; storeId: string; storeName: string | null;
address: string | null; phone: string | null; fiscalSerial: string | null; fiscalRegNumber: string | null; isActive: boolean address: string | null; phone: string | null; fiscalSerial: string | null; fiscalRegNumber: string | null; isActive: boolean
} }
export interface ProductGroup { id: string; name: string; parentId: string | null; path: string; sortOrder: number; isActive: boolean; markupPercent: number | null } export interface ProductGroup { id: string; name: string; parentId: string | null; path: string; sortOrder: number; markupPercent: number | null }
export interface Counterparty { export interface Counterparty {
id: string; name: string; legalName: string | null; type: CounterpartyType; id: string; name: string; legalName: string | null; type: CounterpartyType;
bin: string | null; iin: string | null; taxNumber: string | null; countryId: string | null; countryName: string | null; bin: string | null; iin: string | null; taxNumber: string | null; countryId: string | null; countryName: string | null;
address: string | null; phone: string | null; email: string | null; address: string | null; phone: string | null; email: string | null;
bankName: string | null; bankAccount: string | null; bik: string | null; bankName: string | null; bankAccount: string | null; bik: string | null;
contactPerson: string | null; notes: string | null; isActive: boolean contactPerson: string | null; notes: string | null
} }
export interface ProductBarcode { id: string; code: string; type: BarcodeType; isPrimary: boolean } export interface ProductBarcode { id: string; code: string; type: BarcodeType; isPrimary: boolean }
export interface ProductPrice { id: string; priceTypeId: string; priceTypeName: string; amount: number; currencyId: string; currencyCode: string } export interface ProductPrice { id: string; priceTypeId: string; priceTypeName: string; amount: number; currencyId: string; currencyCode: string }
@ -60,7 +60,7 @@ export interface Product {
referencePrice: number | null; referencePriceUpdatedAt: string | null; referencePrice: number | null; referencePriceUpdatedAt: string | null;
purchaseCurrencyId: string | null; purchaseCurrencyCode: string | null; purchaseCurrencyId: string | null; purchaseCurrencyCode: string | null;
cost: number; lastSupplyAt: string | null; cost: number; lastSupplyAt: string | null;
imageUrl: string | null; isActive: boolean; imageUrl: string | null; shelfLifeDays: number | null;
prices: ProductPrice[]; barcodes: ProductBarcode[] prices: ProductPrice[]; barcodes: ProductBarcode[]
} }

View file

@ -15,7 +15,6 @@ export interface OrgSettings {
showMarkedOnProduct: boolean showMarkedOnProduct: boolean
showMinMaxStock: boolean showMinMaxStock: boolean
allowFractionalPrices: boolean allowFractionalPrices: boolean
multiplePriceTypesEnabled: boolean
showReferencePriceOnProduct: boolean showReferencePriceOnProduct: boolean
} }

View file

@ -8,7 +8,7 @@ import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar' import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal' import { Modal } from '@/components/Modal'
import { Field, TextInput, TextArea, Select, Checkbox } from '@/components/Field' import { Field, TextInput, TextArea, Select } from '@/components/Field'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog' import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import { type Counterparty, type Country, type PagedResult, CounterpartyType } from '@/lib/types' import { type Counterparty, type Country, type PagedResult, CounterpartyType } from '@/lib/types'
@ -31,7 +31,6 @@ interface Form {
bik: string bik: string
contactPerson: string contactPerson: string
notes: string notes: string
isActive: boolean
} }
const blankForm: Form = { const blankForm: Form = {
@ -39,7 +38,7 @@ const blankForm: Form = {
bin: '', iin: '', taxNumber: '', countryId: '', bin: '', iin: '', taxNumber: '', countryId: '',
address: '', phone: '', email: '', address: '', phone: '', email: '',
bankName: '', bankAccount: '', bik: '', bankName: '', bankAccount: '', bik: '',
contactPerson: '', notes: '', isActive: true, contactPerson: '', notes: '',
} }
const typeLabel: Record<CounterpartyType, string> = { const typeLabel: Record<CounterpartyType, string> = {
@ -94,7 +93,7 @@ export function CounterpartiesPage() {
bin: r.bin ?? '', iin: r.iin ?? '', taxNumber: r.taxNumber ?? '', bin: r.bin ?? '', iin: r.iin ?? '', taxNumber: r.taxNumber ?? '',
countryId: r.countryId ?? '', address: r.address ?? '', phone: r.phone ?? '', email: r.email ?? '', countryId: r.countryId ?? '', address: r.address ?? '', phone: r.phone ?? '', email: r.email ?? '',
bankName: r.bankName ?? '', bankAccount: r.bankAccount ?? '', bik: r.bik ?? '', bankName: r.bankName ?? '', bankAccount: r.bankAccount ?? '', bik: r.bik ?? '',
contactPerson: r.contactPerson ?? '', notes: r.notes ?? '', isActive: r.isActive, contactPerson: r.contactPerson ?? '', notes: r.notes ?? '',
})} })}
columns={[ columns={[
{ header: 'Название', sortKey: 'name', cell: (r) => r.name }, { header: 'Название', sortKey: 'name', cell: (r) => r.name },
@ -102,7 +101,6 @@ export function CounterpartiesPage() {
{ header: 'БИН/ИИН', width: '140px', cell: (r) => <span className="font-mono">{r.bin ?? r.iin ?? '—'}</span> }, { header: 'БИН/ИИН', width: '140px', cell: (r) => <span className="font-mono">{r.bin ?? r.iin ?? '—'}</span> },
{ header: 'Телефон', width: '160px', sortKey: 'phone', cell: (r) => r.phone ?? '—' }, { header: 'Телефон', width: '160px', sortKey: 'phone', cell: (r) => r.phone ?? '—' },
{ header: 'Страна', width: '120px', sortKey: 'country', cell: (r) => r.countryName ?? '—' }, { header: 'Страна', width: '120px', sortKey: 'country', cell: (r) => r.countryName ?? '—' },
{ header: 'Активен', width: '100px', sortKey: 'isActive', cell: (r) => r.isActive ? '✓' : '—' },
]} ]}
/> />
</ListPageShell> </ListPageShell>
@ -183,7 +181,6 @@ export function CounterpartiesPage() {
<TextArea rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} /> <TextArea rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
</Field> </Field>
<div className="col-span-2"> <div className="col-span-2">
<Checkbox label="Активен" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
</div> </div>
</div> </div>
)} )}

View file

@ -1,86 +0,0 @@
import { useEffect, useState } from 'react'
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import { Save } from 'lucide-react'
import { api } from '@/lib/api'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { TextInput } from '@/components/Field'
import { Button } from '@/components/Button'
import type { ProductGroup, PagedResult } from '@/lib/types'
interface DraftRow { id: string; name: string; path: string; markupPercent: number | null; original: number | null }
/** Удобная массовая страница: список всех групп товаров с inline-вводом
* % наценки. Сохранение по группам только тех строк, которые изменились. */
export function GroupMarkupsPage() {
const qc = useQueryClient()
const groups = useQuery({
queryKey: ['/api/catalog/product-groups', 'all'],
queryFn: async () => (await api.get<PagedResult<ProductGroup>>('/api/catalog/product-groups?pageSize=500')).data.items,
})
const [rows, setRows] = useState<DraftRow[]>([])
useEffect(() => {
if (groups.data) setRows(groups.data.map((g) => ({
id: g.id, name: g.name, path: g.path,
markupPercent: g.markupPercent, original: g.markupPercent,
})))
}, [groups.data])
const dirty = rows.filter((r) => r.markupPercent !== r.original)
const save = useMutation({
mutationFn: async () => {
// Для PUT нужен полный ProductGroupInput. Подгружаем целиком из груп
// и патчим только markupPercent.
const byId = new Map(groups.data?.map((g) => [g.id, g]) ?? [])
for (const r of dirty) {
const orig = byId.get(r.id)
if (!orig) continue
await api.put(`/api/catalog/product-groups/${r.id}`, {
name: orig.name,
parentId: orig.parentId,
sortOrder: orig.sortOrder,
isActive: orig.isActive,
markupPercent: r.markupPercent,
})
}
},
onSuccess: async () => {
await qc.invalidateQueries({ queryKey: ['/api/catalog/product-groups'] })
},
})
return (
<ListPageShell
title="Наценки по группам"
description="Массовая правка процента наценки для авто-расчёта розничной цены при проведении приёмки."
actions={
<Button onClick={() => save.mutate()} disabled={dirty.length === 0 || save.isPending}>
<Save className="w-4 h-4" /> {save.isPending ? 'Сохраняю…' : `Сохранить (${dirty.length})`}
</Button>
}
>
<DataTable
rows={rows}
isLoading={groups.isLoading}
rowKey={(r) => r.id}
columns={[
{ header: 'Группа', cell: (r) => <span className="text-slate-500">{r.path}</span> },
{ header: 'Наценка %', width: '180px', className: 'text-right', cell: (r) => (
<TextInput
type="number" step="0.01"
value={r.markupPercent ?? ''}
placeholder="—"
onChange={(e) => {
const v = e.target.value === '' ? null : Number(e.target.value)
setRows((rs) => rs.map((x) => x.id === r.id ? { ...x, markupPercent: v } : x))
}}
className="text-right"
/>
)},
]}
/>
</ListPageShell>
)
}

View file

@ -45,7 +45,6 @@ export function OrganizationSettingsPage() {
showMarkedOnProduct: form.showMarkedOnProduct, showMarkedOnProduct: form.showMarkedOnProduct,
showMinMaxStock: form.showMinMaxStock, showMinMaxStock: form.showMinMaxStock,
allowFractionalPrices: form.allowFractionalPrices, allowFractionalPrices: form.allowFractionalPrices,
multiplePriceTypesEnabled: form.multiplePriceTypesEnabled,
showReferencePriceOnProduct: form.showReferencePriceOnProduct, showReferencePriceOnProduct: form.showReferencePriceOnProduct,
} }
return (await api.put<OrgSettings>('/api/organization/settings', payload)).data return (await api.put<OrgSettings>('/api/organization/settings', payload)).data
@ -153,16 +152,6 @@ export function OrganizationSettingsPage() {
По умолчанию целые тенге, без копеек. По умолчанию целые тенге, без копеек.
</p> </p>
<Checkbox
label='Несколько типов цен (Опт, VIP и т.п.)'
checked={form.multiplePriceTypesEnabled}
onChange={(v) => setForm({ ...form, multiplePriceTypesEnabled: v })}
/>
<p className="text-xs text-slate-500 -mt-2">
Если включено в меню появляется «Типы цен», на карточке товара
список цен по всем типам. По умолчанию одна розничная цена.
</p>
<Checkbox <Checkbox
label='Показывать «Эталонную цену» на товаре' label='Показывать «Эталонную цену» на товаре'
checked={form.showReferencePriceOnProduct} checked={form.showReferencePriceOnProduct}

View file

@ -1,5 +1,5 @@
import { useState } from 'react' import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react' import { Plus, Trash2, Lock } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell' import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable' import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination' import { Pagination } from '@/components/Pagination'
@ -12,8 +12,16 @@ import type { PriceType } from '@/lib/types'
const URL = '/api/catalog/price-types' const URL = '/api/catalog/price-types'
interface Form { id?: string; name: string; isDefault: boolean; isRetail: boolean; sortOrder: number; isActive: boolean } interface Form {
const blankForm: Form = { name: '', isDefault: false, isRetail: false, sortOrder: 0, isActive: true } id?: string
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 }
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)
@ -22,7 +30,8 @@ export function PriceTypesPage() {
const save = async () => { const save = async () => {
if (!form) return if (!form) return
const { id, ...payload } = form const { id, isSystem: _omit, ...payload } = form
void _omit
if (id) await update.mutateAsync({ id, input: payload }) if (id) await update.mutateAsync({ id, input: payload })
else await create.mutateAsync(payload) else await create.mutateAsync(payload)
setForm(null) setForm(null)
@ -50,13 +59,20 @@ export function PriceTypesPage() {
sortKey={sortKey} sortKey={sortKey}
sortOrder={sortOrder} sortOrder={sortOrder}
onSortChange={setSort} onSortChange={setSort}
onRowClick={(r) => setForm({ ...r })} onRowClick={(r) => setForm({
id: r.id, name: r.name,
isRequired: r.isRequired, isSystem: r.isSystem,
isDefault: r.isDefault, isRetail: r.isRetail, sortOrder: r.sortOrder,
})}
columns={[ columns={[
{ header: 'Название', sortKey: 'name', cell: (r) => r.name }, { header: 'Название', sortKey: 'name', cell: (r) => (
{ header: 'Розничная', width: '120px', sortKey: 'isRetail', cell: (r) => r.isRetail ? '✓' : '—' }, <span className="flex items-center gap-1.5">
{ header: 'По умолчанию', width: '140px', sortKey: 'isDefault', cell: (r) => r.isDefault ? '✓' : '—' }, {r.name}
{r.isSystem && <Lock className="w-3.5 h-3.5 text-slate-400" />}
</span>
)},
{ header: 'Обязательно', width: '140px', sortKey: 'isRequired', cell: (r) => r.isRequired ? '✓' : '—' },
{ header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder }, { header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder },
{ header: 'Активна', width: '100px', sortKey: 'isActive', cell: (r) => r.isActive ? '✓' : '—' },
]} ]}
/> />
</ListPageShell> </ListPageShell>
@ -67,7 +83,7 @@ export function PriceTypesPage() {
title={form?.id ? 'Редактировать тип цены' : 'Новый тип цены'} title={form?.id ? 'Редактировать тип цены' : 'Новый тип цены'}
footer={ footer={
<> <>
{form?.id && ( {form?.id && !form.isSystem && (
<Button variant="danger" size="sm" onClick={async () => { <Button variant="danger" size="sm" onClick={async () => {
if (confirm('Удалить тип цены?')) { if (confirm('Удалить тип цены?')) {
await remove.mutateAsync(form.id!) await remove.mutateAsync(form.id!)
@ -84,6 +100,11 @@ export function PriceTypesPage() {
> >
{form && ( {form && (
<div className="space-y-3"> <div className="space-y-3">
{form.isSystem && (
<p className="text-xs text-slate-500 bg-slate-50 dark:bg-slate-800/40 p-2 rounded border border-slate-200 dark:border-slate-700">
Системная запись. Имя можно переименовать; флаг «Обязательно» зафиксирован, удаление запрещено.
</p>
)}
<Field label="Название"> <Field label="Название">
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} /> <TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field> </Field>
@ -93,9 +114,14 @@ export function PriceTypesPage() {
onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })} onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })}
/> />
</Field> </Field>
<Checkbox
label="Обязательная (требуется заполнение у каждого товара)"
checked={form.isRequired}
disabled={form.isSystem}
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 })} /> <Checkbox label="По умолчанию" checked={form.isDefault} onChange={(v) => setForm({ ...form, isDefault: v })} />
<Checkbox label="Активна" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
</div> </div>
)} )}
</Modal> </Modal>

View file

@ -29,7 +29,6 @@ interface Form {
isService: boolean isService: boolean
packaging: Packaging packaging: Packaging
isMarked: boolean isMarked: boolean
isActive: boolean
minStock: string minStock: string
maxStock: string maxStock: string
referencePrice: string referencePrice: string
@ -44,7 +43,7 @@ const emptyForm: Form = {
name: '', article: '', description: '', name: '', article: '', description: '',
unitOfMeasureId: '', vat: 16, vatEnabled: true, unitOfMeasureId: '', vat: 16, vatEnabled: true,
productGroupId: '', defaultSupplierId: '', countryOfOriginId: '', productGroupId: '', defaultSupplierId: '', countryOfOriginId: '',
isService: false, packaging: Packaging.Piece, isMarked: false, isActive: true, isService: false, packaging: Packaging.Piece, isMarked: false,
minStock: '', maxStock: '', minStock: '', maxStock: '',
referencePrice: '', purchaseCurrencyId: '', referencePrice: '', purchaseCurrencyId: '',
imageUrl: '', imageUrl: '',
@ -84,7 +83,7 @@ export function ProductEditPage() {
productGroupId: p.productGroupId ?? '', defaultSupplierId: p.defaultSupplierId ?? '', productGroupId: p.productGroupId ?? '', defaultSupplierId: p.defaultSupplierId ?? '',
countryOfOriginId: p.countryOfOriginId ?? '', countryOfOriginId: p.countryOfOriginId ?? '',
isService: p.isService, packaging: p.packaging, isMarked: p.isMarked, isService: p.isService, packaging: p.packaging, isMarked: p.isMarked,
isActive: p.isActive,
minStock: p.minStock?.toString() ?? '', minStock: p.minStock?.toString() ?? '',
maxStock: p.maxStock?.toString() ?? '', maxStock: p.maxStock?.toString() ?? '',
referencePrice: p.referencePrice?.toString() ?? '', referencePrice: p.referencePrice?.toString() ?? '',
@ -144,7 +143,7 @@ export function ProductEditPage() {
isService: form.isService, isService: form.isService,
packaging: form.packaging, packaging: form.packaging,
isMarked: form.isMarked, isMarked: form.isMarked,
isActive: form.isActive,
minStock: form.minStock === '' ? null : Number(form.minStock), minStock: form.minStock === '' ? null : Number(form.minStock),
maxStock: form.maxStock === '' ? null : Number(form.maxStock), maxStock: form.maxStock === '' ? null : Number(form.maxStock),
referencePrice: form.referencePrice === '' ? null : Number(form.referencePrice), referencePrice: form.referencePrice === '' ? null : Number(form.referencePrice),
@ -311,7 +310,6 @@ export function ProductEditPage() {
{org.data?.showMarkedOnProduct && ( {org.data?.showMarkedOnProduct && (
<Checkbox label="Маркируемый (Честный знак / Datamatrix)" checked={form.isMarked} onChange={(v) => setForm({ ...form, isMarked: v })} /> <Checkbox label="Маркируемый (Честный знак / Datamatrix)" checked={form.isMarked} onChange={(v) => setForm({ ...form, isMarked: v })} />
)} )}
<Checkbox label="Активен" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
</div> </div>
</Section> </Section>
@ -371,15 +369,14 @@ export function ProductEditPage() {
)} )}
<Section <Section
title={org.data?.multiplePriceTypesEnabled ? 'Цены продажи' : 'Розничная цена'} title="Цены продажи"
action={ action={
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
{!isNew && id && ( {!isNew && id && (
<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`)
// Обновим UI значение в form.prices для дефолтного PriceType. const def = priceTypes.data?.find((pt) => pt.isSystem) ?? priceTypes.data?.find((pt) => pt.isDefault) ?? priceTypes.data?.[0]
const def = priceTypes.data?.find((pt) => pt.isDefault) ?? 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)
@ -400,9 +397,7 @@ export function ProductEditPage() {
Привести к себестоимости Привести к себестоимости
</Button> </Button>
)} )}
{org.data?.multiplePriceTypesEnabled && (
<Button type="button" variant="secondary" size="sm" onClick={addPrice}><Plus className="w-3.5 h-3.5" /> Добавить</Button> <Button type="button" variant="secondary" size="sm" onClick={addPrice}><Plus className="w-3.5 h-3.5" /> Добавить</Button>
)}
</div> </div>
} }
> >

View file

@ -6,14 +6,14 @@ import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar' import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal' import { Modal } from '@/components/Modal'
import { Field, TextInput, Select, Checkbox } from '@/components/Field' import { Field, TextInput, Select } from '@/components/Field'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog' import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import type { ProductGroup } from '@/lib/types' import type { ProductGroup } from '@/lib/types'
const URL = '/api/catalog/product-groups' const URL = '/api/catalog/product-groups'
interface Form { id?: string; name: string; parentId: string | null; sortOrder: number; isActive: boolean; markupPercent: number | null } interface Form { id?: string; name: string; parentId: string | null; sortOrder: number; markupPercent: number | null }
const blankForm: Form = { name: '', parentId: null, sortOrder: 0, isActive: true, markupPercent: null } const blankForm: Form = { name: '', parentId: null, sortOrder: 0, markupPercent: null }
export function ProductGroupsPage() { export function ProductGroupsPage() {
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<ProductGroup>(URL) const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<ProductGroup>(URL)
@ -50,13 +50,12 @@ export function ProductGroupsPage() {
sortKey={sortKey} sortKey={sortKey}
sortOrder={sortOrder} sortOrder={sortOrder}
onSortChange={setSort} onSortChange={setSort}
onRowClick={(r) => setForm({ id: r.id, name: r.name, parentId: r.parentId, sortOrder: r.sortOrder, isActive: r.isActive, markupPercent: r.markupPercent })} onRowClick={(r) => setForm({ id: r.id, name: r.name, parentId: r.parentId, sortOrder: r.sortOrder, markupPercent: r.markupPercent })}
columns={[ columns={[
{ header: 'Название', sortKey: 'name', cell: (r) => r.name }, { header: 'Название', sortKey: 'name', cell: (r) => r.name },
{ header: 'Путь', sortKey: 'path', cell: (r) => <span className="text-slate-500">{r.path}</span> }, { header: 'Путь', sortKey: 'path', cell: (r) => <span className="text-slate-500">{r.path}</span> },
{ header: 'Наценка', width: '110px', className: 'text-right', cell: (r) => r.markupPercent != null ? `${r.markupPercent.toFixed(2)}%` : '—' }, { header: 'Наценка', width: '110px', className: 'text-right', cell: (r) => r.markupPercent != null ? `${r.markupPercent.toFixed(2)}%` : '—' },
{ header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder }, { header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder },
{ header: 'Активна', width: '100px', sortKey: 'isActive', cell: (r) => r.isActive ? '✓' : '—' },
]} ]}
/> />
</ListPageShell> </ListPageShell>
@ -116,7 +115,6 @@ export function ProductGroupsPage() {
Пусто автонаценка отключена. Пусто автонаценка отключена.
</p> </p>
</Field> </Field>
<Checkbox label="Активна" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
</div> </div>
)} )}
</Modal> </Modal>

View file

@ -6,7 +6,7 @@ import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar' import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button' import { Button } from '@/components/Button'
import { Modal } from '@/components/Modal' import { Modal } from '@/components/Modal'
import { Field, TextInput, Checkbox } from '@/components/Field' import { Field, TextInput } from '@/components/Field'
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog' import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
import type { UnitOfMeasure } from '@/lib/types' import type { UnitOfMeasure } from '@/lib/types'
@ -17,10 +17,9 @@ interface Form {
code: string code: string
name: string name: string
description: string description: string
isActive: boolean
} }
const blankForm: Form = { code: '', name: '', description: '', isActive: true } const blankForm: Form = { code: '', name: '', description: '' }
export function UnitsOfMeasurePage() { export function UnitsOfMeasurePage() {
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<UnitOfMeasure>(URL) const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<UnitOfMeasure>(URL)
@ -59,13 +58,12 @@ export function UnitsOfMeasurePage() {
onSortChange={setSort} onSortChange={setSort}
onRowClick={(r) => setForm({ onRowClick={(r) => setForm({
id: r.id, code: r.code, name: r.name, id: r.id, code: r.code, name: r.name,
description: r.description ?? '', isActive: r.isActive, description: r.description ?? ''
})} })}
columns={[ columns={[
{ header: 'Код', width: '90px', sortKey: 'code', cell: (r) => <span className="font-mono">{r.code}</span> }, { header: 'Код', width: '90px', sortKey: 'code', cell: (r) => <span className="font-mono">{r.code}</span> },
{ header: 'Название', sortKey: 'name', cell: (r) => r.name }, { header: 'Название', sortKey: 'name', cell: (r) => r.name },
{ header: 'Описание', cell: (r) => r.description ?? '—' }, { header: 'Описание', cell: (r) => r.description ?? '—' },
{ header: 'Активна', width: '100px', sortKey: 'isActive', cell: (r) => r.isActive ? '✓' : '—' },
]} ]}
/> />
</ListPageShell> </ListPageShell>
@ -102,7 +100,6 @@ export function UnitsOfMeasurePage() {
<Field label="Описание"> <Field label="Описание">
<TextInput value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} /> <TextInput value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
</Field> </Field>
<Checkbox label="Активна" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
</div> </div>
)} )}
</Modal> </Modal>