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:
parent
9c0e9494f3
commit
b79c71591d
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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 =
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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; } = [];
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
1904
src/food-market.infrastructure/Persistence/Migrations/20260425170000_Phase3b_PricingCleanup.Designer.cs
generated
Normal file
1904
src/food-market.infrastructure/Persistence/Migrations/20260425170000_Phase3b_PricingCleanup.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -377,10 +377,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
|||
.HasMaxLength(20)
|
||||
.HasColumnType("character varying(20)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("LegalName")
|
||||
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,10 +563,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
|||
.HasMaxLength(1000)
|
||||
.HasColumnType("character varying(1000)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsMarked")
|
||||
b.Property<bool>("IsMarked")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<bool>("IsService")
|
||||
|
|
@ -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,10 +695,7 @@ 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")
|
||||
b.Property<decimal?>("MarkupPercent")
|
||||
.HasPrecision(5, 2)
|
||||
.HasColumnType("numeric(5,2)");
|
||||
|
||||
|
|
@ -933,10 +928,7 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
|||
.HasMaxLength(500)
|
||||
.HasColumnType("character varying(500)");
|
||||
|
||||
b.Property<bool>("IsActive")
|
||||
.HasColumnType("boolean");
|
||||
|
||||
b.Property<string>("Name")
|
||||
b.Property<string>("Name")
|
||||
.IsRequired()
|
||||
.HasMaxLength(100)
|
||||
.HasColumnType("character varying(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");
|
||||
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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[]
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ export interface OrgSettings {
|
|||
showMarkedOnProduct: boolean
|
||||
showMinMaxStock: boolean
|
||||
allowFractionalPrices: boolean
|
||||
multiplePriceTypesEnabled: boolean
|
||||
showReferencePriceOnProduct: boolean
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)
|
||||
}
|
||||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
)}
|
||||
<Button type="button" variant="secondary" size="sm" onClick={addPrice}><Plus className="w-3.5 h-3.5" /> Добавить</Button>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Reference in a new issue