feat(phase3b): drop IsActive, add ShelfLifeDays, restore PriceType IsSystem/IsRequired
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 46s
CI / Web (React + Vite) (push) Successful in 34s
Docker API / Build + push API (push) Successful in 41s
Docker Web / Build + push Web (push) Successful in 25s
Docker API / Deploy API on stage (push) Successful in 12s
Docker Web / Deploy Web on stage (push) Successful in 11s

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 и MoySklad-импортёр: больше не пишут 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 8d9937a04b
commit 3c274541e9
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),
("phone", false) => q.OrderBy(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),
_ => q.OrderBy(c => c.Name),
};
@ -55,7 +53,7 @@ public class CounterpartiesController : ControllerBase
c.Id, c.Name, c.LegalName, c.Type,
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country != null ? c.Country.Name : null,
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);
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.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name,
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")]
@ -117,7 +115,6 @@ private static Counterparty Apply(Counterparty e, CounterpartyInput i)
e.Bik = i.Bik;
e.ContactPerson = i.ContactPerson;
e.Notes = i.Notes;
e.IsActive = i.IsActive;
return e;
}
@ -128,6 +125,6 @@ private async Task<CounterpartyDto> ProjectAsync(Guid id, CancellationToken ct)
c.Id, c.Name, c.LegalName, c.Type,
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name,
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", true) => q.OrderByDescending(p => p.Name),
("isDefault", false) => q.OrderBy(p => p.IsDefault).ThenBy(p => p.Name),
("isDefault", true) => q.OrderByDescending(p => p.IsDefault).ThenBy(p => p.Name),
("isRetail", false) => q.OrderBy(p => p.IsRetail).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),
("isRequired", false) => q.OrderBy(p => p.IsRequired).ThenBy(p => p.Name),
("isRequired", true) => q.OrderByDescending(p => p.IsRequired).ThenBy(p => p.Name),
_ => q.OrderByDescending(p => p.IsSystem).ThenBy(p => p.SortOrder).ThenBy(p => p.Name),
};
var items = await q
.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);
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)
{
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")]
@ -62,13 +58,17 @@ public async Task<ActionResult<PriceTypeDto>> Create([FromBody] PriceTypeInput i
}
var e = new PriceType
{
Name = input.Name, IsDefault = input.IsDefault, IsRetail = input.IsRetail,
SortOrder = input.SortOrder, IsActive = input.IsActive,
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.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")]
@ -84,7 +84,8 @@ public async Task<IActionResult> Update(Guid id, [FromBody] PriceTypeInput input
e.IsDefault = input.IsDefault;
e.IsRetail = input.IsRetail;
e.SortOrder = input.SortOrder;
e.IsActive = input.IsActive;
// У системной записи IsRequired всегда true и не меняется.
e.IsRequired = e.IsSystem ? true : input.IsRequired;
await _db.SaveChangesAsync(ct);
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);
if (e is null) return NotFound();
if (e.IsSystem) return BadRequest(new { error = "Системная запись не может быть удалена." });
_db.PriceTypes.Remove(e);
await _db.SaveChangesAsync(ct);
return NoContent();

View file

@ -40,13 +40,11 @@ public class ProductGroupsController : ControllerBase
("name", true) => q.OrderByDescending(g => g.Name),
("path", false) => q.OrderBy(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),
};
var items = await q
.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);
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)
{
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")]
@ -65,13 +63,13 @@ public async Task<ActionResult<ProductGroupDto>> Create([FromBody] ProductGroupI
var e = new ProductGroup
{
Name = input.Name, ParentId = input.ParentId, Path = path,
SortOrder = input.SortOrder, IsActive = input.IsActive,
SortOrder = input.SortOrder,
MarkupPercent = input.MarkupPercent,
};
_db.ProductGroups.Add(e);
await _db.SaveChangesAsync(ct);
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")]
@ -85,7 +83,6 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductGroupInput in
e.ParentId = input.ParentId;
e.Path = await BuildPathAsync(input.ParentId, input.Name, ct);
e.SortOrder = input.SortOrder;
e.IsActive = input.IsActive;
e.MarkupPercent = input.MarkupPercent;
await _db.SaveChangesAsync(ct);
return NoContent();

View file

@ -92,7 +92,6 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
[FromQuery] bool? isService,
[FromQuery] Packaging? packaging,
[FromQuery] bool? isMarked,
[FromQuery] bool? isActive,
[FromQuery] decimal? purchasePriceFrom,
[FromQuery] decimal? purchasePriceTo,
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 (packaging is not null) q = q.Where(p => p.Packaging == packaging);
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 (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),
("vat", false) => q.OrderBy(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),
_ => q.OrderBy(p => p.Name),
};
@ -294,14 +290,14 @@ public async Task<IActionResult> RecalcRetail(Guid id, CancellationToken ct)
: Math.Ceiling(raw);
var defaultType = await _db.PriceTypes
.Where(pt => pt.IsActive)
.OrderByDescending(pt => pt.IsDefault)
.OrderByDescending(pt => pt.IsSystem)
.ThenByDescending(pt => pt.IsDefault)
.ThenByDescending(pt => pt.IsRetail)
.ThenBy(pt => pt.SortOrder)
.ThenBy(pt => pt.Name)
.FirstOrDefaultAsync(ct);
if (defaultType is null)
return BadRequest(new { error = "Нет ни одного активного типа цен. Создайте его в настройках." });
return BadRequest(new { error = "Нет ни одного типа цен. Создайте его в настройках." });
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);
@ -384,7 +380,7 @@ public async Task<ActionResult<IReadOnlyList<BarcodeDuplicate>>> BarcodeDuplicat
p.ReferencePrice, p.ReferencePriceUpdatedAt,
p.PurchaseCurrencyId, p.PurchaseCurrency != null ? p.PurchaseCurrency.Code : null,
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.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.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", 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),
_ => q.OrderBy(u => u.Name),
};
var items = await q
.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);
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)
{
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")]
@ -58,12 +56,11 @@ public async Task<ActionResult<UnitOfMeasureDto>> Create([FromBody] UnitOfMeasur
Code = input.Code,
Name = input.Name,
Description = input.Description,
IsActive = input.IsActive,
};
_db.UnitsOfMeasure.Add(e);
await _db.SaveChangesAsync(ct);
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")]
@ -75,7 +72,6 @@ public async Task<IActionResult> Update(Guid id, [FromBody] UnitOfMeasureInput i
e.Code = input.Code;
e.Name = input.Name;
e.Description = input.Description;
e.IsActive = input.IsActive;
await _db.SaveChangesAsync(ct);
return NoContent();
}

View file

@ -35,7 +35,6 @@ public record OrgSettingsDto(
bool ShowMarkedOnProduct,
bool ShowMinMaxStock,
bool AllowFractionalPrices,
bool MultiplePriceTypesEnabled,
bool ShowReferencePriceOnProduct);
// DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId).
@ -48,7 +47,6 @@ public record OrgSettingsInput(
bool ShowMarkedOnProduct,
bool ShowMinMaxStock,
bool AllowFractionalPrices,
bool MultiplePriceTypesEnabled,
bool ShowReferencePriceOnProduct);
[HttpGet("settings")]
@ -85,7 +83,6 @@ public async Task<ActionResult<OrgSettingsDto>> Update([FromBody] OrgSettingsInp
o.ShowMarkedOnProduct = input.ShowMarkedOnProduct;
o.ShowMinMaxStock = input.ShowMinMaxStock;
o.AllowFractionalPrices = input.AllowFractionalPrices;
o.MultiplePriceTypesEnabled = input.MultiplePriceTypesEnabled;
o.ShowReferencePriceOnProduct = input.ShowReferencePriceOnProduct;
await _db.SaveChangesAsync(ct);
@ -115,6 +112,5 @@ private async Task<decimal> ReadVatRateAsync(string countryCode, CancellationTok
o.ShowMarkedOnProduct,
o.ShowMinMaxStock,
o.AllowFractionalPrices,
o.MultiplePriceTypesEnabled,
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)
{
var defaultType = _db.PriceTypes
.Where(pt => pt.IsActive)
.OrderByDescending(pt => pt.IsDefault)
.OrderByDescending(pt => pt.IsSystem)
.ThenByDescending(pt => pt.IsDefault)
.ThenByDescending(pt => pt.IsRetail)
.ThenBy(pt => pt.SortOrder)
.ThenBy(pt => pt.Name)

View file

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

View file

@ -12,10 +12,11 @@ public record CountryDto(
public record CurrencyDto(Guid Id, string Code, string Name, string Symbol);
public record UnitOfMeasureDto(
Guid Id, string Code, string Name, string? Description, bool IsActive);
Guid Id, string Code, string Name, string? Description);
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(
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);
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);
public record CounterpartyDto(
Guid Id, string Name, string? LegalName, CounterpartyType Type,
string? Bin, string? Iin, string? TaxNumber, Guid? CountryId, string? CountryName,
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);
@ -51,7 +52,7 @@ public record ProductDto(
decimal? ReferencePrice, DateTime? ReferencePriceUpdatedAt,
Guid? PurchaseCurrencyId, string? PurchaseCurrencyCode,
decimal Cost, DateTime? LastSupplyAt,
string? ImageUrl, bool IsActive,
string? ImageUrl, int? ShelfLifeDays,
IReadOnlyList<ProductPriceDto> Prices,
IReadOnlyList<ProductBarcodeDto> Barcodes);
@ -60,8 +61,10 @@ public record CountryInput(
string Code, string Name,
Guid? DefaultCurrencyId = null, decimal VatRate = 0m);
public record CurrencyInput(string Code, string Name, string Symbol);
public record UnitOfMeasureInput(string Code, string Name, string? Description = null, bool IsActive = true);
public record PriceTypeInput(string Name, bool IsDefault = false, bool IsRetail = false, int SortOrder = 0, bool IsActive = true);
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);
public record StoreInput(
string Name, string? Code,
string? Address = null, string? Phone = null, string? ManagerName = null,
@ -71,13 +74,13 @@ public record RetailPointInput(
string? Address = null, string? Phone = null,
string? FiscalSerial = null, string? FiscalRegNumber = null, bool IsActive = true);
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);
public record CounterpartyInput(
string Name, string? LegalName, CounterpartyType Type,
string? Bin, string? Iin, string? TaxNumber, Guid? CountryId,
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 ProductPriceInput(Guid PriceTypeId, [Range(0, 1e10)] decimal Amount, Guid CurrencyId);
public record ProductInput(
@ -87,6 +90,6 @@ public record ProductInput(
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? 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<ProductBarcodeInput>? Barcodes = null);

View file

@ -20,5 +20,4 @@ public class Counterparty : TenantEntity
public string? Bik { get; set; }
public string? ContactPerson { 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 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 IsRetail { 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 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<ProductBarcode> Barcodes { get; set; } = [];

View file

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

View file

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

View file

@ -54,11 +54,6 @@ public class Organization : Entity
/// дробное через API.</summary>
public bool AllowFractionalPrices { get; set; }
/// <summary>Если true — в карточке товара рендерится список цен по всем
/// PriceType, есть страница «Настройки → Типы цен». Если false (default)
/// — одно поле «Розничная цена», работающее с дефолтным PriceType.</summary>
public bool MultiplePriceTypesEnabled { get; set; }
/// <summary>Показывать ли в карточке товара поле «Эталонная цена».
/// Default: true.</summary>
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.Address = Trim(c.ActualAddress ?? c.LegalAddress, 500);
entity.Notes = Trim(c.Description, 1000);
entity.IsActive = !c.Archived;
}
public async Task<MoySkladImportResult> ImportProductsAsync(
@ -168,7 +167,6 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
OrganizationId = orgId,
Name = "Продукты питания",
Path = "Продукты питания",
IsActive = true,
};
_db.ProductGroups.Add(defaultGroup);
await _db.SaveChangesAsync(ct);
@ -195,7 +193,6 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
OrganizationId = orgId,
Name = f.Name,
Path = string.IsNullOrEmpty(f.PathName) ? f.Name : $"{f.PathName}/{f.Name}",
IsActive = !f.Archived,
};
_db.ProductGroups.Add(g);
localGroupByMsId[f.Id] = g.Id;
@ -262,7 +259,6 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
product.CountryOfOriginId = countryId ?? product.CountryOfOriginId;
product.Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece;
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;
updated++;
if (progress is not null) progress.Updated = updated;
@ -282,7 +278,6 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
CountryOfOriginId = countryId,
Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece,
IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED",
IsActive = !p.Archived,
ReferencePrice = p.BuyPrice is null ? null : p.BuyPrice.Value / 100m,
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.Article });
b.HasIndex(x => new { x.OrganizationId, x.ProductGroupId });
b.HasIndex(x => new { x.OrganizationId, x.IsActive });
}
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)
.HasColumnType("character varying(20)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("LegalName")
.HasMaxLength(500)
.HasColumnType("character varying(500)");
@ -506,15 +503,18 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<bool>("IsDefault")
.HasColumnType("boolean");
b.Property<bool>("IsRequired")
.HasColumnType("boolean");
b.Property<bool>("IsRetail")
.HasColumnType("boolean");
b.Property<bool>("IsSystem")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
@ -563,9 +563,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasMaxLength(1000)
.HasColumnType("character varying(1000)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<bool>("IsMarked")
.HasColumnType("boolean");
@ -611,6 +608,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<DateTime?>("ReferencePriceUpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<int?>("ShelfLifeDays")
.HasColumnType("integer");
b.Property<Guid>("UnitOfMeasureId")
.HasColumnType("uuid");
@ -640,8 +640,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.HasIndex("OrganizationId", "Article");
b.HasIndex("OrganizationId", "IsActive");
b.HasIndex("OrganizationId", "Name");
b.HasIndex("OrganizationId", "ProductGroupId");
@ -697,9 +695,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<DateTime>("CreatedAt")
.HasColumnType("timestamp with time zone");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<decimal?>("MarkupPercent")
.HasPrecision(5, 2)
.HasColumnType("numeric(5,2)");
@ -933,9 +928,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasMaxLength(500)
.HasColumnType("character varying(500)");
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(100)
@ -1107,9 +1099,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<bool>("MultiCurrencyEnabled")
.HasColumnType("boolean");
b.Property<bool>("MultiplePriceTypesEnabled")
.HasColumnType("boolean");
b.Property<bool>("ShowMarkedOnProduct")
.HasColumnType("boolean");

View file

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

View file

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

View file

@ -28,8 +28,8 @@ export interface Country {
vatRate: number
}
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 PriceType { id: string; name: string; isDefault: boolean; isRetail: boolean; sortOrder: number; isActive: boolean }
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 Store {
id: string; name: string; code: string | null; address: string | null; phone: string | null;
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;
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 {
id: string; name: string; legalName: string | null; type: CounterpartyType;
bin: string | null; iin: string | null; taxNumber: string | null; countryId: string | null; countryName: string | null;
address: string | null; phone: string | null; email: 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 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;
purchaseCurrencyId: string | null; purchaseCurrencyCode: string | null;
cost: number; lastSupplyAt: string | null;
imageUrl: string | null; isActive: boolean;
imageUrl: string | null; shelfLifeDays: number | null;
prices: ProductPrice[]; barcodes: ProductBarcode[]
}

View file

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

View file

@ -8,7 +8,7 @@ import { Pagination } from '@/components/Pagination'
import { SearchBar } from '@/components/SearchBar'
import { Button } from '@/components/Button'
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 { type Counterparty, type Country, type PagedResult, CounterpartyType } from '@/lib/types'
@ -31,7 +31,6 @@ interface Form {
bik: string
contactPerson: string
notes: string
isActive: boolean
}
const blankForm: Form = {
@ -39,7 +38,7 @@ const blankForm: Form = {
bin: '', iin: '', taxNumber: '', countryId: '',
address: '', phone: '', email: '',
bankName: '', bankAccount: '', bik: '',
contactPerson: '', notes: '', isActive: true,
contactPerson: '', notes: '',
}
const typeLabel: Record<CounterpartyType, string> = {
@ -94,7 +93,7 @@ export function CounterpartiesPage() {
bin: r.bin ?? '', iin: r.iin ?? '', taxNumber: r.taxNumber ?? '',
countryId: r.countryId ?? '', address: r.address ?? '', phone: r.phone ?? '', email: r.email ?? '',
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={[
{ 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: '160px', sortKey: 'phone', cell: (r) => r.phone ?? '—' },
{ header: 'Страна', width: '120px', sortKey: 'country', cell: (r) => r.countryName ?? '—' },
{ header: 'Активен', width: '100px', sortKey: 'isActive', cell: (r) => r.isActive ? '✓' : '—' },
]}
/>
</ListPageShell>
@ -183,7 +181,6 @@ export function CounterpartiesPage() {
<TextArea rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
</Field>
<div className="col-span-2">
<Checkbox label="Активен" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
</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,
showMinMaxStock: form.showMinMaxStock,
allowFractionalPrices: form.allowFractionalPrices,
multiplePriceTypesEnabled: form.multiplePriceTypesEnabled,
showReferencePriceOnProduct: form.showReferencePriceOnProduct,
}
return (await api.put<OrgSettings>('/api/organization/settings', payload)).data
@ -153,16 +152,6 @@ export function OrganizationSettingsPage() {
По умолчанию целые тенге, без копеек.
</p>
<Checkbox
label='Несколько типов цен (Опт, VIP и т.п.)'
checked={form.multiplePriceTypesEnabled}
onChange={(v) => setForm({ ...form, multiplePriceTypesEnabled: v })}
/>
<p className="text-xs text-slate-500 -mt-2">
Если включено в меню появляется «Типы цен», на карточке товара
список цен по всем типам. По умолчанию одна розничная цена.
</p>
<Checkbox
label='Показывать «Эталонную цену» на товаре'
checked={form.showReferencePriceOnProduct}

View file

@ -1,5 +1,5 @@
import { useState } from 'react'
import { Plus, Trash2 } from 'lucide-react'
import { Plus, Trash2, Lock } from 'lucide-react'
import { ListPageShell } from '@/components/ListPageShell'
import { DataTable } from '@/components/DataTable'
import { Pagination } from '@/components/Pagination'
@ -12,8 +12,16 @@ import type { PriceType } from '@/lib/types'
const URL = '/api/catalog/price-types'
interface Form { id?: string; name: string; isDefault: boolean; isRetail: boolean; sortOrder: number; isActive: boolean }
const blankForm: Form = { name: '', isDefault: false, isRetail: false, sortOrder: 0, isActive: true }
interface Form {
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() {
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<PriceType>(URL)
@ -22,7 +30,8 @@ export function PriceTypesPage() {
const save = async () => {
if (!form) return
const { id, ...payload } = form
const { id, isSystem: _omit, ...payload } = form
void _omit
if (id) await update.mutateAsync({ id, input: payload })
else await create.mutateAsync(payload)
setForm(null)
@ -50,13 +59,20 @@ export function PriceTypesPage() {
sortKey={sortKey}
sortOrder={sortOrder}
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={[
{ header: 'Название', sortKey: 'name', cell: (r) => r.name },
{ header: 'Розничная', width: '120px', sortKey: 'isRetail', cell: (r) => r.isRetail ? '✓' : '—' },
{ header: 'По умолчанию', width: '140px', sortKey: 'isDefault', cell: (r) => r.isDefault ? '✓' : '—' },
{ header: 'Название', sortKey: 'name', cell: (r) => (
<span className="flex items-center gap-1.5">
{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', sortKey: 'isActive', cell: (r) => r.isActive ? '✓' : '—' },
]}
/>
</ListPageShell>
@ -67,7 +83,7 @@ export function PriceTypesPage() {
title={form?.id ? 'Редактировать тип цены' : 'Новый тип цены'}
footer={
<>
{form?.id && (
{form?.id && !form.isSystem && (
<Button variant="danger" size="sm" onClick={async () => {
if (confirm('Удалить тип цены?')) {
await remove.mutateAsync(form.id!)
@ -84,6 +100,11 @@ export function PriceTypesPage() {
>
{form && (
<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="Название">
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
</Field>
@ -93,9 +114,14 @@ export function PriceTypesPage() {
onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })}
/>
</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.isDefault} onChange={(v) => setForm({ ...form, isDefault: v })} />
<Checkbox label="Активна" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
</div>
)}
</Modal>

View file

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

View file

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

View file

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