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),
|
("legalName", true) => q.OrderByDescending(c => c.LegalName).ThenBy(c => c.Name),
|
||||||
("phone", false) => q.OrderBy(c => c.Phone).ThenBy(c => c.Name),
|
("phone", false) => q.OrderBy(c => c.Phone).ThenBy(c => c.Name),
|
||||||
("phone", true) => q.OrderByDescending(c => c.Phone).ThenBy(c => c.Name),
|
("phone", true) => q.OrderByDescending(c => c.Phone).ThenBy(c => c.Name),
|
||||||
("isActive", false) => q.OrderBy(c => c.IsActive).ThenBy(c => c.Name),
|
|
||||||
("isActive", true) => q.OrderByDescending(c => c.IsActive).ThenBy(c => c.Name),
|
|
||||||
("name", true) => q.OrderByDescending(c => c.Name),
|
("name", true) => q.OrderByDescending(c => c.Name),
|
||||||
_ => q.OrderBy(c => c.Name),
|
_ => q.OrderBy(c => c.Name),
|
||||||
};
|
};
|
||||||
|
|
@ -55,7 +53,7 @@ public class CounterpartiesController : ControllerBase
|
||||||
c.Id, c.Name, c.LegalName, c.Type,
|
c.Id, c.Name, c.LegalName, c.Type,
|
||||||
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country != null ? c.Country.Name : null,
|
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country != null ? c.Country.Name : null,
|
||||||
c.Address, c.Phone, c.Email,
|
c.Address, c.Phone, c.Email,
|
||||||
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive))
|
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
return new PagedResult<CounterpartyDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
return new PagedResult<CounterpartyDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||||
}
|
}
|
||||||
|
|
@ -68,7 +66,7 @@ public async Task<ActionResult<CounterpartyDto>> Get(Guid id, CancellationToken
|
||||||
c.Id, c.Name, c.LegalName, c.Type,
|
c.Id, c.Name, c.LegalName, c.Type,
|
||||||
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name,
|
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name,
|
||||||
c.Address, c.Phone, c.Email,
|
c.Address, c.Phone, c.Email,
|
||||||
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive);
|
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost, Authorize(Roles = "Admin,Manager,Storekeeper")]
|
[HttpPost, Authorize(Roles = "Admin,Manager,Storekeeper")]
|
||||||
|
|
@ -117,7 +115,6 @@ private static Counterparty Apply(Counterparty e, CounterpartyInput i)
|
||||||
e.Bik = i.Bik;
|
e.Bik = i.Bik;
|
||||||
e.ContactPerson = i.ContactPerson;
|
e.ContactPerson = i.ContactPerson;
|
||||||
e.Notes = i.Notes;
|
e.Notes = i.Notes;
|
||||||
e.IsActive = i.IsActive;
|
|
||||||
return e;
|
return e;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -128,6 +125,6 @@ private async Task<CounterpartyDto> ProjectAsync(Guid id, CancellationToken ct)
|
||||||
c.Id, c.Name, c.LegalName, c.Type,
|
c.Id, c.Name, c.LegalName, c.Type,
|
||||||
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name,
|
c.Bin, c.Iin, c.TaxNumber, c.CountryId, c.Country?.Name,
|
||||||
c.Address, c.Phone, c.Email,
|
c.Address, c.Phone, c.Email,
|
||||||
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes, c.IsActive);
|
c.BankName, c.BankAccount, c.Bik, c.ContactPerson, c.Notes);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,17 +31,13 @@ public async Task<ActionResult<PagedResult<PriceTypeDto>>> List([FromQuery] Page
|
||||||
{
|
{
|
||||||
("name", false) => q.OrderBy(p => p.Name),
|
("name", false) => q.OrderBy(p => p.Name),
|
||||||
("name", true) => q.OrderByDescending(p => p.Name),
|
("name", true) => q.OrderByDescending(p => p.Name),
|
||||||
("isDefault", false) => q.OrderBy(p => p.IsDefault).ThenBy(p => p.Name),
|
("isRequired", false) => q.OrderBy(p => p.IsRequired).ThenBy(p => p.Name),
|
||||||
("isDefault", true) => q.OrderByDescending(p => p.IsDefault).ThenBy(p => p.Name),
|
("isRequired", true) => q.OrderByDescending(p => p.IsRequired).ThenBy(p => p.Name),
|
||||||
("isRetail", false) => q.OrderBy(p => p.IsRetail).ThenBy(p => p.Name),
|
_ => q.OrderByDescending(p => p.IsSystem).ThenBy(p => p.SortOrder).ThenBy(p => p.Name),
|
||||||
("isRetail", true) => q.OrderByDescending(p => p.IsRetail).ThenBy(p => p.Name),
|
|
||||||
("isActive", false) => q.OrderBy(p => p.IsActive).ThenBy(p => p.Name),
|
|
||||||
("isActive", true) => q.OrderByDescending(p => p.IsActive).ThenBy(p => p.Name),
|
|
||||||
_ => q.OrderByDescending(p => p.IsDefault).ThenBy(p => p.SortOrder).ThenBy(p => p.Name),
|
|
||||||
};
|
};
|
||||||
var items = await q
|
var items = await q
|
||||||
.Skip(req.Skip).Take(req.Take)
|
.Skip(req.Skip).Take(req.Take)
|
||||||
.Select(p => new PriceTypeDto(p.Id, p.Name, p.IsDefault, p.IsRetail, p.SortOrder, p.IsActive))
|
.Select(p => new PriceTypeDto(p.Id, p.Name, p.IsRequired, p.IsSystem, p.IsDefault, p.IsRetail, p.SortOrder))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
return new PagedResult<PriceTypeDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
return new PagedResult<PriceTypeDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||||
}
|
}
|
||||||
|
|
@ -50,7 +46,7 @@ public async Task<ActionResult<PagedResult<PriceTypeDto>>> List([FromQuery] Page
|
||||||
public async Task<ActionResult<PriceTypeDto>> Get(Guid id, CancellationToken ct)
|
public async Task<ActionResult<PriceTypeDto>> Get(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var p = await _db.PriceTypes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
var p = await _db.PriceTypes.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
return p is null ? NotFound() : new PriceTypeDto(p.Id, p.Name, p.IsDefault, p.IsRetail, p.SortOrder, p.IsActive);
|
return p is null ? NotFound() : new PriceTypeDto(p.Id, p.Name, p.IsRequired, p.IsSystem, p.IsDefault, p.IsRetail, p.SortOrder);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost, Authorize(Roles = "Admin,Manager")]
|
[HttpPost, Authorize(Roles = "Admin,Manager")]
|
||||||
|
|
@ -62,13 +58,17 @@ public async Task<ActionResult<PriceTypeDto>> Create([FromBody] PriceTypeInput i
|
||||||
}
|
}
|
||||||
var e = new PriceType
|
var e = new PriceType
|
||||||
{
|
{
|
||||||
Name = input.Name, IsDefault = input.IsDefault, IsRetail = input.IsRetail,
|
Name = input.Name,
|
||||||
SortOrder = input.SortOrder, IsActive = input.IsActive,
|
IsRequired = input.IsRequired,
|
||||||
|
IsSystem = false,
|
||||||
|
IsDefault = input.IsDefault,
|
||||||
|
IsRetail = input.IsRetail,
|
||||||
|
SortOrder = input.SortOrder,
|
||||||
};
|
};
|
||||||
_db.PriceTypes.Add(e);
|
_db.PriceTypes.Add(e);
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return CreatedAtAction(nameof(Get), new { id = e.Id },
|
return CreatedAtAction(nameof(Get), new { id = e.Id },
|
||||||
new PriceTypeDto(e.Id, e.Name, e.IsDefault, e.IsRetail, e.SortOrder, e.IsActive));
|
new PriceTypeDto(e.Id, e.Name, e.IsRequired, e.IsSystem, e.IsDefault, e.IsRetail, e.SortOrder));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
|
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
|
||||||
|
|
@ -84,7 +84,8 @@ public async Task<IActionResult> Update(Guid id, [FromBody] PriceTypeInput input
|
||||||
e.IsDefault = input.IsDefault;
|
e.IsDefault = input.IsDefault;
|
||||||
e.IsRetail = input.IsRetail;
|
e.IsRetail = input.IsRetail;
|
||||||
e.SortOrder = input.SortOrder;
|
e.SortOrder = input.SortOrder;
|
||||||
e.IsActive = input.IsActive;
|
// У системной записи IsRequired всегда true и не меняется.
|
||||||
|
e.IsRequired = e.IsSystem ? true : input.IsRequired;
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
@ -94,6 +95,7 @@ public async Task<IActionResult> Delete(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct);
|
var e = await _db.PriceTypes.FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
if (e is null) return NotFound();
|
if (e is null) return NotFound();
|
||||||
|
if (e.IsSystem) return BadRequest(new { error = "Системная запись не может быть удалена." });
|
||||||
_db.PriceTypes.Remove(e);
|
_db.PriceTypes.Remove(e);
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
|
|
|
||||||
|
|
@ -40,13 +40,11 @@ public class ProductGroupsController : ControllerBase
|
||||||
("name", true) => q.OrderByDescending(g => g.Name),
|
("name", true) => q.OrderByDescending(g => g.Name),
|
||||||
("path", false) => q.OrderBy(g => g.Path),
|
("path", false) => q.OrderBy(g => g.Path),
|
||||||
("path", true) => q.OrderByDescending(g => g.Path),
|
("path", true) => q.OrderByDescending(g => g.Path),
|
||||||
("isActive", false) => q.OrderBy(g => g.IsActive).ThenBy(g => g.Path),
|
|
||||||
("isActive", true) => q.OrderByDescending(g => g.IsActive).ThenBy(g => g.Path),
|
|
||||||
_ => q.OrderBy(g => g.Path).ThenBy(g => g.SortOrder).ThenBy(g => g.Name),
|
_ => q.OrderBy(g => g.Path).ThenBy(g => g.SortOrder).ThenBy(g => g.Name),
|
||||||
};
|
};
|
||||||
var items = await q
|
var items = await q
|
||||||
.Skip(req.Skip).Take(req.Take)
|
.Skip(req.Skip).Take(req.Take)
|
||||||
.Select(g => new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.IsActive, g.MarkupPercent))
|
.Select(g => new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.MarkupPercent))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
return new PagedResult<ProductGroupDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
return new PagedResult<ProductGroupDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||||
}
|
}
|
||||||
|
|
@ -55,7 +53,7 @@ public class ProductGroupsController : ControllerBase
|
||||||
public async Task<ActionResult<ProductGroupDto>> Get(Guid id, CancellationToken ct)
|
public async Task<ActionResult<ProductGroupDto>> Get(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var g = await _db.ProductGroups.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
var g = await _db.ProductGroups.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
return g is null ? NotFound() : new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.IsActive, g.MarkupPercent);
|
return g is null ? NotFound() : new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.MarkupPercent);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost, Authorize(Roles = "Admin,Manager")]
|
[HttpPost, Authorize(Roles = "Admin,Manager")]
|
||||||
|
|
@ -65,13 +63,13 @@ public async Task<ActionResult<ProductGroupDto>> Create([FromBody] ProductGroupI
|
||||||
var e = new ProductGroup
|
var e = new ProductGroup
|
||||||
{
|
{
|
||||||
Name = input.Name, ParentId = input.ParentId, Path = path,
|
Name = input.Name, ParentId = input.ParentId, Path = path,
|
||||||
SortOrder = input.SortOrder, IsActive = input.IsActive,
|
SortOrder = input.SortOrder,
|
||||||
MarkupPercent = input.MarkupPercent,
|
MarkupPercent = input.MarkupPercent,
|
||||||
};
|
};
|
||||||
_db.ProductGroups.Add(e);
|
_db.ProductGroups.Add(e);
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return CreatedAtAction(nameof(Get), new { id = e.Id },
|
return CreatedAtAction(nameof(Get), new { id = e.Id },
|
||||||
new ProductGroupDto(e.Id, e.Name, e.ParentId, e.Path, e.SortOrder, e.IsActive, e.MarkupPercent));
|
new ProductGroupDto(e.Id, e.Name, e.ParentId, e.Path, e.SortOrder, e.MarkupPercent));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
|
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
|
||||||
|
|
@ -85,7 +83,6 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductGroupInput in
|
||||||
e.ParentId = input.ParentId;
|
e.ParentId = input.ParentId;
|
||||||
e.Path = await BuildPathAsync(input.ParentId, input.Name, ct);
|
e.Path = await BuildPathAsync(input.ParentId, input.Name, ct);
|
||||||
e.SortOrder = input.SortOrder;
|
e.SortOrder = input.SortOrder;
|
||||||
e.IsActive = input.IsActive;
|
|
||||||
e.MarkupPercent = input.MarkupPercent;
|
e.MarkupPercent = input.MarkupPercent;
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
|
|
|
||||||
|
|
@ -92,7 +92,6 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
|
||||||
[FromQuery] bool? isService,
|
[FromQuery] bool? isService,
|
||||||
[FromQuery] Packaging? packaging,
|
[FromQuery] Packaging? packaging,
|
||||||
[FromQuery] bool? isMarked,
|
[FromQuery] bool? isMarked,
|
||||||
[FromQuery] bool? isActive,
|
|
||||||
[FromQuery] decimal? purchasePriceFrom,
|
[FromQuery] decimal? purchasePriceFrom,
|
||||||
[FromQuery] decimal? purchasePriceTo,
|
[FromQuery] decimal? purchasePriceTo,
|
||||||
CancellationToken ct)
|
CancellationToken ct)
|
||||||
|
|
@ -116,7 +115,6 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
|
||||||
if (isService is not null) q = q.Where(p => p.IsService == isService);
|
if (isService is not null) q = q.Where(p => p.IsService == isService);
|
||||||
if (packaging is not null) q = q.Where(p => p.Packaging == packaging);
|
if (packaging is not null) q = q.Where(p => p.Packaging == packaging);
|
||||||
if (isMarked is not null) q = q.Where(p => p.IsMarked == isMarked);
|
if (isMarked is not null) q = q.Where(p => p.IsMarked == isMarked);
|
||||||
if (isActive is not null) q = q.Where(p => p.IsActive == isActive);
|
|
||||||
if (purchasePriceFrom is not null) q = q.Where(p => p.ReferencePrice >= purchasePriceFrom);
|
if (purchasePriceFrom is not null) q = q.Where(p => p.ReferencePrice >= purchasePriceFrom);
|
||||||
if (purchasePriceTo is not null) q = q.Where(p => p.ReferencePrice <= purchasePriceTo);
|
if (purchasePriceTo is not null) q = q.Where(p => p.ReferencePrice <= purchasePriceTo);
|
||||||
|
|
||||||
|
|
@ -144,8 +142,6 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
|
||||||
("purchasePrice", true) => q.OrderByDescending(p => p.ReferencePrice).ThenBy(p => p.Name),
|
("purchasePrice", true) => q.OrderByDescending(p => p.ReferencePrice).ThenBy(p => p.Name),
|
||||||
("vat", false) => q.OrderBy(p => p.Vat).ThenBy(p => p.Name),
|
("vat", false) => q.OrderBy(p => p.Vat).ThenBy(p => p.Name),
|
||||||
("vat", true) => q.OrderByDescending(p => p.Vat).ThenBy(p => p.Name),
|
("vat", true) => q.OrderByDescending(p => p.Vat).ThenBy(p => p.Name),
|
||||||
("isActive", false) => q.OrderBy(p => p.IsActive).ThenBy(p => p.Name),
|
|
||||||
("isActive", true) => q.OrderByDescending(p => p.IsActive).ThenBy(p => p.Name),
|
|
||||||
("name", true) => q.OrderByDescending(p => p.Name),
|
("name", true) => q.OrderByDescending(p => p.Name),
|
||||||
_ => q.OrderBy(p => p.Name),
|
_ => q.OrderBy(p => p.Name),
|
||||||
};
|
};
|
||||||
|
|
@ -294,14 +290,14 @@ public async Task<IActionResult> RecalcRetail(Guid id, CancellationToken ct)
|
||||||
: Math.Ceiling(raw);
|
: Math.Ceiling(raw);
|
||||||
|
|
||||||
var defaultType = await _db.PriceTypes
|
var defaultType = await _db.PriceTypes
|
||||||
.Where(pt => pt.IsActive)
|
.OrderByDescending(pt => pt.IsSystem)
|
||||||
.OrderByDescending(pt => pt.IsDefault)
|
.ThenByDescending(pt => pt.IsDefault)
|
||||||
.ThenByDescending(pt => pt.IsRetail)
|
.ThenByDescending(pt => pt.IsRetail)
|
||||||
.ThenBy(pt => pt.SortOrder)
|
.ThenBy(pt => pt.SortOrder)
|
||||||
.ThenBy(pt => pt.Name)
|
.ThenBy(pt => pt.Name)
|
||||||
.FirstOrDefaultAsync(ct);
|
.FirstOrDefaultAsync(ct);
|
||||||
if (defaultType is null)
|
if (defaultType is null)
|
||||||
return BadRequest(new { error = "Нет ни одного активного типа цен. Создайте его в настройках." });
|
return BadRequest(new { error = "Нет ни одного типа цен. Создайте его в настройках." });
|
||||||
|
|
||||||
var fallbackCurrency = await _db.Organizations.Select(o => o.DefaultCurrencyId).FirstOrDefaultAsync(ct)
|
var fallbackCurrency = await _db.Organizations.Select(o => o.DefaultCurrencyId).FirstOrDefaultAsync(ct)
|
||||||
?? await _db.Currencies.OrderBy(c => c.Code).Select(c => (Guid?)c.Id).FirstOrDefaultAsync(ct);
|
?? await _db.Currencies.OrderBy(c => c.Code).Select(c => (Guid?)c.Id).FirstOrDefaultAsync(ct);
|
||||||
|
|
@ -384,7 +380,7 @@ public async Task<ActionResult<IReadOnlyList<BarcodeDuplicate>>> BarcodeDuplicat
|
||||||
p.ReferencePrice, p.ReferencePriceUpdatedAt,
|
p.ReferencePrice, p.ReferencePriceUpdatedAt,
|
||||||
p.PurchaseCurrencyId, p.PurchaseCurrency != null ? p.PurchaseCurrency.Code : null,
|
p.PurchaseCurrencyId, p.PurchaseCurrency != null ? p.PurchaseCurrency.Code : null,
|
||||||
p.Cost, p.LastSupplyAt,
|
p.Cost, p.LastSupplyAt,
|
||||||
p.ImageUrl, p.IsActive,
|
p.ImageUrl, p.ShelfLifeDays,
|
||||||
p.Prices.Select(pr => new ProductPriceDto(pr.Id, pr.PriceTypeId, pr.PriceType!.Name, pr.Amount, pr.CurrencyId, pr.Currency!.Code)).ToList(),
|
p.Prices.Select(pr => new ProductPriceDto(pr.Id, pr.PriceTypeId, pr.PriceType!.Name, pr.Amount, pr.CurrencyId, pr.Currency!.Code)).ToList(),
|
||||||
p.Barcodes.Select(b => new ProductBarcodeDto(b.Id, b.Code, b.Type, b.IsPrimary)).ToList());
|
p.Barcodes.Select(b => new ProductBarcodeDto(b.Id, b.Code, b.Type, b.IsPrimary)).ToList());
|
||||||
|
|
||||||
|
|
@ -414,6 +410,6 @@ private static void Apply(Product e, ProductInput i)
|
||||||
}
|
}
|
||||||
e.PurchaseCurrencyId = i.PurchaseCurrencyId;
|
e.PurchaseCurrencyId = i.PurchaseCurrencyId;
|
||||||
e.ImageUrl = i.ImageUrl;
|
e.ImageUrl = i.ImageUrl;
|
||||||
e.IsActive = i.IsActive;
|
e.ShelfLifeDays = i.ShelfLifeDays;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -31,14 +31,12 @@ public async Task<ActionResult<PagedResult<UnitOfMeasureDto>>> List([FromQuery]
|
||||||
{
|
{
|
||||||
("code", false) => q.OrderBy(u => u.Code),
|
("code", false) => q.OrderBy(u => u.Code),
|
||||||
("code", true) => q.OrderByDescending(u => u.Code),
|
("code", true) => q.OrderByDescending(u => u.Code),
|
||||||
("isActive", false) => q.OrderBy(u => u.IsActive).ThenBy(u => u.Name),
|
|
||||||
("isActive", true) => q.OrderByDescending(u => u.IsActive).ThenBy(u => u.Name),
|
|
||||||
("name", true) => q.OrderByDescending(u => u.Name),
|
("name", true) => q.OrderByDescending(u => u.Name),
|
||||||
_ => q.OrderBy(u => u.Name),
|
_ => q.OrderBy(u => u.Name),
|
||||||
};
|
};
|
||||||
var items = await q
|
var items = await q
|
||||||
.Skip(req.Skip).Take(req.Take)
|
.Skip(req.Skip).Take(req.Take)
|
||||||
.Select(u => new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.IsActive))
|
.Select(u => new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description))
|
||||||
.ToListAsync(ct);
|
.ToListAsync(ct);
|
||||||
return new PagedResult<UnitOfMeasureDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
return new PagedResult<UnitOfMeasureDto> { Items = items, Total = total, Page = req.Page, PageSize = req.Take };
|
||||||
}
|
}
|
||||||
|
|
@ -47,7 +45,7 @@ public async Task<ActionResult<PagedResult<UnitOfMeasureDto>>> List([FromQuery]
|
||||||
public async Task<ActionResult<UnitOfMeasureDto>> Get(Guid id, CancellationToken ct)
|
public async Task<ActionResult<UnitOfMeasureDto>> Get(Guid id, CancellationToken ct)
|
||||||
{
|
{
|
||||||
var u = await _db.UnitsOfMeasure.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
var u = await _db.UnitsOfMeasure.AsNoTracking().FirstOrDefaultAsync(x => x.Id == id, ct);
|
||||||
return u is null ? NotFound() : new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description, u.IsActive);
|
return u is null ? NotFound() : new UnitOfMeasureDto(u.Id, u.Code, u.Name, u.Description);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost, Authorize(Roles = "Admin,Manager")]
|
[HttpPost, Authorize(Roles = "Admin,Manager")]
|
||||||
|
|
@ -58,12 +56,11 @@ public async Task<ActionResult<UnitOfMeasureDto>> Create([FromBody] UnitOfMeasur
|
||||||
Code = input.Code,
|
Code = input.Code,
|
||||||
Name = input.Name,
|
Name = input.Name,
|
||||||
Description = input.Description,
|
Description = input.Description,
|
||||||
IsActive = input.IsActive,
|
|
||||||
};
|
};
|
||||||
_db.UnitsOfMeasure.Add(e);
|
_db.UnitsOfMeasure.Add(e);
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return CreatedAtAction(nameof(Get), new { id = e.Id },
|
return CreatedAtAction(nameof(Get), new { id = e.Id },
|
||||||
new UnitOfMeasureDto(e.Id, e.Code, e.Name, e.Description, e.IsActive));
|
new UnitOfMeasureDto(e.Id, e.Code, e.Name, e.Description));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
|
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
|
||||||
|
|
@ -75,7 +72,6 @@ public async Task<IActionResult> Update(Guid id, [FromBody] UnitOfMeasureInput i
|
||||||
e.Code = input.Code;
|
e.Code = input.Code;
|
||||||
e.Name = input.Name;
|
e.Name = input.Name;
|
||||||
e.Description = input.Description;
|
e.Description = input.Description;
|
||||||
e.IsActive = input.IsActive;
|
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,6 @@ public record OrgSettingsDto(
|
||||||
bool ShowMarkedOnProduct,
|
bool ShowMarkedOnProduct,
|
||||||
bool ShowMinMaxStock,
|
bool ShowMinMaxStock,
|
||||||
bool AllowFractionalPrices,
|
bool AllowFractionalPrices,
|
||||||
bool MultiplePriceTypesEnabled,
|
|
||||||
bool ShowReferencePriceOnProduct);
|
bool ShowReferencePriceOnProduct);
|
||||||
|
|
||||||
// DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId).
|
// DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId).
|
||||||
|
|
@ -48,7 +47,6 @@ public record OrgSettingsInput(
|
||||||
bool ShowMarkedOnProduct,
|
bool ShowMarkedOnProduct,
|
||||||
bool ShowMinMaxStock,
|
bool ShowMinMaxStock,
|
||||||
bool AllowFractionalPrices,
|
bool AllowFractionalPrices,
|
||||||
bool MultiplePriceTypesEnabled,
|
|
||||||
bool ShowReferencePriceOnProduct);
|
bool ShowReferencePriceOnProduct);
|
||||||
|
|
||||||
[HttpGet("settings")]
|
[HttpGet("settings")]
|
||||||
|
|
@ -85,7 +83,6 @@ public async Task<ActionResult<OrgSettingsDto>> Update([FromBody] OrgSettingsInp
|
||||||
o.ShowMarkedOnProduct = input.ShowMarkedOnProduct;
|
o.ShowMarkedOnProduct = input.ShowMarkedOnProduct;
|
||||||
o.ShowMinMaxStock = input.ShowMinMaxStock;
|
o.ShowMinMaxStock = input.ShowMinMaxStock;
|
||||||
o.AllowFractionalPrices = input.AllowFractionalPrices;
|
o.AllowFractionalPrices = input.AllowFractionalPrices;
|
||||||
o.MultiplePriceTypesEnabled = input.MultiplePriceTypesEnabled;
|
|
||||||
o.ShowReferencePriceOnProduct = input.ShowReferencePriceOnProduct;
|
o.ShowReferencePriceOnProduct = input.ShowReferencePriceOnProduct;
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
|
|
@ -115,6 +112,5 @@ private async Task<decimal> ReadVatRateAsync(string countryCode, CancellationTok
|
||||||
o.ShowMarkedOnProduct,
|
o.ShowMarkedOnProduct,
|
||||||
o.ShowMinMaxStock,
|
o.ShowMinMaxStock,
|
||||||
o.AllowFractionalPrices,
|
o.AllowFractionalPrices,
|
||||||
o.MultiplePriceTypesEnabled,
|
|
||||||
o.ShowReferencePriceOnProduct);
|
o.ShowReferencePriceOnProduct);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -301,8 +301,8 @@ public async Task<IActionResult> Post(Guid id, CancellationToken ct)
|
||||||
private void SetDefaultRetail(foodmarket.Domain.Catalog.Product p, decimal value, Guid fallbackCurrencyId)
|
private void SetDefaultRetail(foodmarket.Domain.Catalog.Product p, decimal value, Guid fallbackCurrencyId)
|
||||||
{
|
{
|
||||||
var defaultType = _db.PriceTypes
|
var defaultType = _db.PriceTypes
|
||||||
.Where(pt => pt.IsActive)
|
.OrderByDescending(pt => pt.IsSystem)
|
||||||
.OrderByDescending(pt => pt.IsDefault)
|
.ThenByDescending(pt => pt.IsDefault)
|
||||||
.ThenByDescending(pt => pt.IsRetail)
|
.ThenByDescending(pt => pt.IsRetail)
|
||||||
.ThenBy(pt => pt.SortOrder)
|
.ThenBy(pt => pt.SortOrder)
|
||||||
.ThenBy(pt => pt.Name)
|
.ThenBy(pt => pt.Name)
|
||||||
|
|
|
||||||
|
|
@ -66,7 +66,6 @@ Guid AddGroup(string name, Guid? parentId)
|
||||||
db.ProductGroups.Add(new ProductGroup
|
db.ProductGroups.Add(new ProductGroup
|
||||||
{
|
{
|
||||||
Id = id, OrganizationId = orgId, Name = name, ParentId = parentId,
|
Id = id, OrganizationId = orgId, Name = name, ParentId = parentId,
|
||||||
Path = path, SortOrder = groups.Count, IsActive = true,
|
|
||||||
});
|
});
|
||||||
groups[path] = id;
|
groups[path] = id;
|
||||||
return id;
|
return id;
|
||||||
|
|
@ -92,7 +91,6 @@ Guid AddGroup(string name, Guid? parentId)
|
||||||
Bin = "100140005678", CountryId = kz?.Id,
|
Bin = "100140005678", CountryId = kz?.Id,
|
||||||
Address = "Алматы, ул. Абая 15", Phone = "+7 (727) 100-00-01",
|
Address = "Алматы, ул. Абая 15", Phone = "+7 (727) 100-00-01",
|
||||||
Email = "order@prodtrade.kz", BankName = "Kaspi Bank", BankAccount = "KZ000000000000000001", Bik = "CASPKZKA",
|
Email = "order@prodtrade.kz", BankName = "Kaspi Bank", BankAccount = "KZ000000000000000001", Bik = "CASPKZKA",
|
||||||
IsActive = true,
|
|
||||||
};
|
};
|
||||||
var supplier2 = new Counterparty
|
var supplier2 = new Counterparty
|
||||||
{
|
{
|
||||||
|
|
@ -100,7 +98,6 @@ Guid AddGroup(string name, Guid? parentId)
|
||||||
Type = CounterpartyType.Individual,
|
Type = CounterpartyType.Individual,
|
||||||
Iin = "850101300000", CountryId = kz?.Id,
|
Iin = "850101300000", CountryId = kz?.Id,
|
||||||
Phone = "+7 (777) 100-00-02", ContactPerson = "Иванов Алексей",
|
Phone = "+7 (777) 100-00-02", ContactPerson = "Иванов Алексей",
|
||||||
IsActive = true,
|
|
||||||
};
|
};
|
||||||
db.Counterparties.AddRange(supplier1, supplier2);
|
db.Counterparties.AddRange(supplier1, supplier2);
|
||||||
|
|
||||||
|
|
@ -166,7 +163,6 @@ Guid AddGroup(string name, Guid? parentId)
|
||||||
ProductGroupId = d.Group,
|
ProductGroupId = d.Group,
|
||||||
CountryOfOriginId = d.Country,
|
CountryOfOriginId = d.Country,
|
||||||
Packaging = d.IsWeighed ? Packaging.Weight : Packaging.Piece,
|
Packaging = d.IsWeighed ? Packaging.Weight : Packaging.Piece,
|
||||||
IsActive = true,
|
|
||||||
ReferencePrice = Math.Round(d.RetailPrice * 0.72m, 2),
|
ReferencePrice = Math.Round(d.RetailPrice * 0.72m, 2),
|
||||||
PurchaseCurrencyId = kzt.Id,
|
PurchaseCurrencyId = kzt.Id,
|
||||||
Prices =
|
Prices =
|
||||||
|
|
|
||||||
|
|
@ -12,10 +12,11 @@ public record CountryDto(
|
||||||
public record CurrencyDto(Guid Id, string Code, string Name, string Symbol);
|
public record CurrencyDto(Guid Id, string Code, string Name, string Symbol);
|
||||||
|
|
||||||
public record UnitOfMeasureDto(
|
public record UnitOfMeasureDto(
|
||||||
Guid Id, string Code, string Name, string? Description, bool IsActive);
|
Guid Id, string Code, string Name, string? Description);
|
||||||
|
|
||||||
public record PriceTypeDto(
|
public record PriceTypeDto(
|
||||||
Guid Id, string Name, bool IsDefault, bool IsRetail, int SortOrder, bool IsActive);
|
Guid Id, string Name, bool IsRequired, bool IsSystem,
|
||||||
|
bool IsDefault, bool IsRetail, int SortOrder);
|
||||||
|
|
||||||
public record StoreDto(
|
public record StoreDto(
|
||||||
Guid Id, string Name, string? Code, string? Address, string? Phone,
|
Guid Id, string Name, string? Code, string? Address, string? Phone,
|
||||||
|
|
@ -26,14 +27,14 @@ public record RetailPointDto(
|
||||||
string? Address, string? Phone, string? FiscalSerial, string? FiscalRegNumber, bool IsActive);
|
string? Address, string? Phone, string? FiscalSerial, string? FiscalRegNumber, bool IsActive);
|
||||||
|
|
||||||
public record ProductGroupDto(
|
public record ProductGroupDto(
|
||||||
Guid Id, string Name, Guid? ParentId, string Path, int SortOrder, bool IsActive,
|
Guid Id, string Name, Guid? ParentId, string Path, int SortOrder,
|
||||||
decimal? MarkupPercent);
|
decimal? MarkupPercent);
|
||||||
|
|
||||||
public record CounterpartyDto(
|
public record CounterpartyDto(
|
||||||
Guid Id, string Name, string? LegalName, CounterpartyType Type,
|
Guid Id, string Name, string? LegalName, CounterpartyType Type,
|
||||||
string? Bin, string? Iin, string? TaxNumber, Guid? CountryId, string? CountryName,
|
string? Bin, string? Iin, string? TaxNumber, Guid? CountryId, string? CountryName,
|
||||||
string? Address, string? Phone, string? Email,
|
string? Address, string? Phone, string? Email,
|
||||||
string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes, bool IsActive);
|
string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes);
|
||||||
|
|
||||||
public record ProductBarcodeDto(Guid Id, string Code, BarcodeType Type, bool IsPrimary);
|
public record ProductBarcodeDto(Guid Id, string Code, BarcodeType Type, bool IsPrimary);
|
||||||
|
|
||||||
|
|
@ -51,7 +52,7 @@ public record ProductDto(
|
||||||
decimal? ReferencePrice, DateTime? ReferencePriceUpdatedAt,
|
decimal? ReferencePrice, DateTime? ReferencePriceUpdatedAt,
|
||||||
Guid? PurchaseCurrencyId, string? PurchaseCurrencyCode,
|
Guid? PurchaseCurrencyId, string? PurchaseCurrencyCode,
|
||||||
decimal Cost, DateTime? LastSupplyAt,
|
decimal Cost, DateTime? LastSupplyAt,
|
||||||
string? ImageUrl, bool IsActive,
|
string? ImageUrl, int? ShelfLifeDays,
|
||||||
IReadOnlyList<ProductPriceDto> Prices,
|
IReadOnlyList<ProductPriceDto> Prices,
|
||||||
IReadOnlyList<ProductBarcodeDto> Barcodes);
|
IReadOnlyList<ProductBarcodeDto> Barcodes);
|
||||||
|
|
||||||
|
|
@ -60,8 +61,10 @@ public record CountryInput(
|
||||||
string Code, string Name,
|
string Code, string Name,
|
||||||
Guid? DefaultCurrencyId = null, decimal VatRate = 0m);
|
Guid? DefaultCurrencyId = null, decimal VatRate = 0m);
|
||||||
public record CurrencyInput(string Code, string Name, string Symbol);
|
public record CurrencyInput(string Code, string Name, string Symbol);
|
||||||
public record UnitOfMeasureInput(string Code, string Name, string? Description = null, bool IsActive = true);
|
public record UnitOfMeasureInput(string Code, string Name, string? Description = null);
|
||||||
public record PriceTypeInput(string Name, bool IsDefault = false, bool IsRetail = false, int SortOrder = 0, bool IsActive = true);
|
public record PriceTypeInput(
|
||||||
|
string Name, bool IsRequired = false,
|
||||||
|
bool IsDefault = false, bool IsRetail = false, int SortOrder = 0);
|
||||||
public record StoreInput(
|
public record StoreInput(
|
||||||
string Name, string? Code,
|
string Name, string? Code,
|
||||||
string? Address = null, string? Phone = null, string? ManagerName = null,
|
string? Address = null, string? Phone = null, string? ManagerName = null,
|
||||||
|
|
@ -71,13 +74,13 @@ public record RetailPointInput(
|
||||||
string? Address = null, string? Phone = null,
|
string? Address = null, string? Phone = null,
|
||||||
string? FiscalSerial = null, string? FiscalRegNumber = null, bool IsActive = true);
|
string? FiscalSerial = null, string? FiscalRegNumber = null, bool IsActive = true);
|
||||||
public record ProductGroupInput(
|
public record ProductGroupInput(
|
||||||
string Name, Guid? ParentId, int SortOrder = 0, bool IsActive = true,
|
string Name, Guid? ParentId, int SortOrder = 0,
|
||||||
[Range(0, 1000)] decimal? MarkupPercent = null);
|
[Range(0, 1000)] decimal? MarkupPercent = null);
|
||||||
public record CounterpartyInput(
|
public record CounterpartyInput(
|
||||||
string Name, string? LegalName, CounterpartyType Type,
|
string Name, string? LegalName, CounterpartyType Type,
|
||||||
string? Bin, string? Iin, string? TaxNumber, Guid? CountryId,
|
string? Bin, string? Iin, string? TaxNumber, Guid? CountryId,
|
||||||
string? Address, string? Phone, string? Email,
|
string? Address, string? Phone, string? Email,
|
||||||
string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes, bool IsActive = true);
|
string? BankName, string? BankAccount, string? Bik, string? ContactPerson, string? Notes);
|
||||||
public record ProductBarcodeInput(string Code, BarcodeType Type = BarcodeType.Ean13, bool IsPrimary = false);
|
public record ProductBarcodeInput(string Code, BarcodeType Type = BarcodeType.Ean13, bool IsPrimary = false);
|
||||||
public record ProductPriceInput(Guid PriceTypeId, [Range(0, 1e10)] decimal Amount, Guid CurrencyId);
|
public record ProductPriceInput(Guid PriceTypeId, [Range(0, 1e10)] decimal Amount, Guid CurrencyId);
|
||||||
public record ProductInput(
|
public record ProductInput(
|
||||||
|
|
@ -87,6 +90,6 @@ public record ProductInput(
|
||||||
bool IsService = false, Packaging Packaging = Packaging.Piece, bool IsMarked = false,
|
bool IsService = false, Packaging Packaging = Packaging.Piece, bool IsMarked = false,
|
||||||
[Range(0, 1e10)] decimal? MinStock = null, [Range(0, 1e10)] decimal? MaxStock = null,
|
[Range(0, 1e10)] decimal? MinStock = null, [Range(0, 1e10)] decimal? MaxStock = null,
|
||||||
[Range(0, 1e10)] decimal? ReferencePrice = null, Guid? PurchaseCurrencyId = null,
|
[Range(0, 1e10)] decimal? ReferencePrice = null, Guid? PurchaseCurrencyId = null,
|
||||||
string? ImageUrl = null, bool IsActive = true,
|
string? ImageUrl = null, [Range(0, 100000)] int? ShelfLifeDays = null,
|
||||||
IReadOnlyList<ProductPriceInput>? Prices = null,
|
IReadOnlyList<ProductPriceInput>? Prices = null,
|
||||||
IReadOnlyList<ProductBarcodeInput>? Barcodes = null);
|
IReadOnlyList<ProductBarcodeInput>? Barcodes = null);
|
||||||
|
|
|
||||||
|
|
@ -20,5 +20,4 @@ public class Counterparty : TenantEntity
|
||||||
public string? Bik { get; set; }
|
public string? Bik { get; set; }
|
||||||
public string? ContactPerson { get; set; }
|
public string? ContactPerson { get; set; }
|
||||||
public string? Notes { get; set; }
|
public string? Notes { get; set; }
|
||||||
public bool IsActive { get; set; } = true;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,12 @@ namespace foodmarket.Domain.Catalog;
|
||||||
public class PriceType : TenantEntity
|
public class PriceType : TenantEntity
|
||||||
{
|
{
|
||||||
public string Name { get; set; } = null!;
|
public string Name { get; set; } = null!;
|
||||||
|
/// <summary>true — цена должна быть заполнена у каждого товара (валидация на UI и сервере).</summary>
|
||||||
|
public bool IsRequired { get; set; }
|
||||||
|
/// <summary>true — системная запись «Розничная цена», не удаляется и
|
||||||
|
/// IsRequired всегда true. Имя можно переименовать. Сидируется при первом старте.</summary>
|
||||||
|
public bool IsSystem { get; set; }
|
||||||
public bool IsDefault { get; set; } // цена по умолчанию для новых товаров
|
public bool IsDefault { get; set; } // цена по умолчанию для новых товаров
|
||||||
public bool IsRetail { get; set; } // используется на кассе
|
public bool IsRetail { get; set; } // используется на кассе
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
public bool IsActive { get; set; } = true;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,7 +54,8 @@ public class Product : TenantEntity
|
||||||
public DateTime? LastSupplyAt { get; set; }
|
public DateTime? LastSupplyAt { get; set; }
|
||||||
|
|
||||||
public string? ImageUrl { get; set; } // основное изображение (остальные в ProductImage)
|
public string? ImageUrl { get; set; } // основное изображение (остальные в ProductImage)
|
||||||
public bool IsActive { get; set; } = true;
|
/// <summary>Срок годности в днях (для отчётов и фильтрации). Не обязательное.</summary>
|
||||||
|
public int? ShelfLifeDays { get; set; }
|
||||||
|
|
||||||
public ICollection<ProductPrice> Prices { get; set; } = [];
|
public ICollection<ProductPrice> Prices { get; set; } = [];
|
||||||
public ICollection<ProductBarcode> Barcodes { get; set; } = [];
|
public ICollection<ProductBarcode> Barcodes { get; set; } = [];
|
||||||
|
|
|
||||||
|
|
@ -11,7 +11,6 @@ public class ProductGroup : TenantEntity
|
||||||
public ICollection<ProductGroup> Children { get; set; } = [];
|
public ICollection<ProductGroup> Children { get; set; } = [];
|
||||||
public string Path { get; set; } = ""; // денормализованный путь "Электроника/Телефоны/Смартфоны" для фильтрации
|
public string Path { get; set; } = ""; // денормализованный путь "Электроника/Телефоны/Смартфоны" для фильтрации
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
public bool IsActive { get; set; } = true;
|
|
||||||
|
|
||||||
/// <summary>Процент наценки на себестоимость для автоматического расчёта
|
/// <summary>Процент наценки на себестоимость для автоматического расчёта
|
||||||
/// розничной цены при проведении приёмки. NULL = автонаценка отключена.</summary>
|
/// розничной цены при проведении приёмки. NULL = автонаценка отключена.</summary>
|
||||||
|
|
|
||||||
|
|
@ -8,5 +8,4 @@ public class UnitOfMeasure : TenantEntity
|
||||||
public string Code { get; set; } = null!; // ОКЕИ код: "796" (шт), "166" (кг), "112" (л)
|
public string Code { get; set; } = null!; // ОКЕИ код: "796" (шт), "166" (кг), "112" (л)
|
||||||
public string Name { get; set; } = null!; // "штука", "килограмм", "литр"
|
public string Name { get; set; } = null!; // "штука", "килограмм", "литр"
|
||||||
public string? Description { get; set; }
|
public string? Description { get; set; }
|
||||||
public bool IsActive { get; set; } = true;
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -54,11 +54,6 @@ public class Organization : Entity
|
||||||
/// дробное через API.</summary>
|
/// дробное через API.</summary>
|
||||||
public bool AllowFractionalPrices { get; set; }
|
public bool AllowFractionalPrices { get; set; }
|
||||||
|
|
||||||
/// <summary>Если true — в карточке товара рендерится список цен по всем
|
|
||||||
/// PriceType, есть страница «Настройки → Типы цен». Если false (default)
|
|
||||||
/// — одно поле «Розничная цена», работающее с дефолтным PriceType.</summary>
|
|
||||||
public bool MultiplePriceTypesEnabled { get; set; }
|
|
||||||
|
|
||||||
/// <summary>Показывать ли в карточке товара поле «Эталонная цена».
|
/// <summary>Показывать ли в карточке товара поле «Эталонная цена».
|
||||||
/// Default: true.</summary>
|
/// Default: true.</summary>
|
||||||
public bool ShowReferencePriceOnProduct { get; set; } = true;
|
public bool ShowReferencePriceOnProduct { get; set; } = true;
|
||||||
|
|
|
||||||
|
|
@ -131,7 +131,6 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
|
||||||
entity.Email = Trim(c.Email, 255);
|
entity.Email = Trim(c.Email, 255);
|
||||||
entity.Address = Trim(c.ActualAddress ?? c.LegalAddress, 500);
|
entity.Address = Trim(c.ActualAddress ?? c.LegalAddress, 500);
|
||||||
entity.Notes = Trim(c.Description, 1000);
|
entity.Notes = Trim(c.Description, 1000);
|
||||||
entity.IsActive = !c.Archived;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
public async Task<MoySkladImportResult> ImportProductsAsync(
|
public async Task<MoySkladImportResult> ImportProductsAsync(
|
||||||
|
|
@ -168,7 +167,6 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
|
||||||
OrganizationId = orgId,
|
OrganizationId = orgId,
|
||||||
Name = "Продукты питания",
|
Name = "Продукты питания",
|
||||||
Path = "Продукты питания",
|
Path = "Продукты питания",
|
||||||
IsActive = true,
|
|
||||||
};
|
};
|
||||||
_db.ProductGroups.Add(defaultGroup);
|
_db.ProductGroups.Add(defaultGroup);
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
@ -195,7 +193,6 @@ await foreach (var c in _client.StreamCounterpartiesAsync(token, ct))
|
||||||
OrganizationId = orgId,
|
OrganizationId = orgId,
|
||||||
Name = f.Name,
|
Name = f.Name,
|
||||||
Path = string.IsNullOrEmpty(f.PathName) ? f.Name : $"{f.PathName}/{f.Name}",
|
Path = string.IsNullOrEmpty(f.PathName) ? f.Name : $"{f.PathName}/{f.Name}",
|
||||||
IsActive = !f.Archived,
|
|
||||||
};
|
};
|
||||||
_db.ProductGroups.Add(g);
|
_db.ProductGroups.Add(g);
|
||||||
localGroupByMsId[f.Id] = g.Id;
|
localGroupByMsId[f.Id] = g.Id;
|
||||||
|
|
@ -262,7 +259,6 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
|
||||||
product.CountryOfOriginId = countryId ?? product.CountryOfOriginId;
|
product.CountryOfOriginId = countryId ?? product.CountryOfOriginId;
|
||||||
product.Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece;
|
product.Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece;
|
||||||
product.IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED";
|
product.IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED";
|
||||||
product.IsActive = !p.Archived;
|
|
||||||
product.ReferencePrice = p.BuyPrice is null ? product.ReferencePrice : p.BuyPrice.Value / 100m;
|
product.ReferencePrice = p.BuyPrice is null ? product.ReferencePrice : p.BuyPrice.Value / 100m;
|
||||||
updated++;
|
updated++;
|
||||||
if (progress is not null) progress.Updated = updated;
|
if (progress is not null) progress.Updated = updated;
|
||||||
|
|
@ -282,7 +278,6 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
|
||||||
CountryOfOriginId = countryId,
|
CountryOfOriginId = countryId,
|
||||||
Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece,
|
Packaging = p.Weighed ? Packaging.Weight : Packaging.Piece,
|
||||||
IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED",
|
IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED",
|
||||||
IsActive = !p.Archived,
|
|
||||||
ReferencePrice = p.BuyPrice is null ? null : p.BuyPrice.Value / 100m,
|
ReferencePrice = p.BuyPrice is null ? null : p.BuyPrice.Value / 100m,
|
||||||
PurchaseCurrencyId = kzt.Id,
|
PurchaseCurrencyId = kzt.Id,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -133,7 +133,6 @@ private static void ConfigureProduct(EntityTypeBuilder<Product> b)
|
||||||
b.HasIndex(x => new { x.OrganizationId, x.Name });
|
b.HasIndex(x => new { x.OrganizationId, x.Name });
|
||||||
b.HasIndex(x => new { x.OrganizationId, x.Article });
|
b.HasIndex(x => new { x.OrganizationId, x.Article });
|
||||||
b.HasIndex(x => new { x.OrganizationId, x.ProductGroupId });
|
b.HasIndex(x => new { x.OrganizationId, x.ProductGroupId });
|
||||||
b.HasIndex(x => new { x.OrganizationId, x.IsActive });
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private static void ConfigureProductPrice(EntityTypeBuilder<ProductPrice> b)
|
private static void ConfigureProductPrice(EntityTypeBuilder<ProductPrice> b)
|
||||||
|
|
|
||||||
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,9 +377,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
.HasMaxLength(20)
|
.HasMaxLength(20)
|
||||||
.HasColumnType("character varying(20)");
|
.HasColumnType("character varying(20)");
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<string>("LegalName")
|
b.Property<string>("LegalName")
|
||||||
.HasMaxLength(500)
|
.HasMaxLength(500)
|
||||||
.HasColumnType("character varying(500)");
|
.HasColumnType("character varying(500)");
|
||||||
|
|
@ -506,15 +503,18 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<bool>("IsDefault")
|
b.Property<bool>("IsDefault")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsRequired")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<bool>("IsRetail")
|
b.Property<bool>("IsRetail")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<bool>("IsSystem")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
|
|
@ -563,9 +563,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
.HasMaxLength(1000)
|
.HasMaxLength(1000)
|
||||||
.HasColumnType("character varying(1000)");
|
.HasColumnType("character varying(1000)");
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<bool>("IsMarked")
|
b.Property<bool>("IsMarked")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
|
@ -611,6 +608,9 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
b.Property<DateTime?>("ReferencePriceUpdatedAt")
|
b.Property<DateTime?>("ReferencePriceUpdatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
|
b.Property<int?>("ShelfLifeDays")
|
||||||
|
.HasColumnType("integer");
|
||||||
|
|
||||||
b.Property<Guid>("UnitOfMeasureId")
|
b.Property<Guid>("UnitOfMeasureId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
|
@ -640,8 +640,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
|
|
||||||
b.HasIndex("OrganizationId", "Article");
|
b.HasIndex("OrganizationId", "Article");
|
||||||
|
|
||||||
b.HasIndex("OrganizationId", "IsActive");
|
|
||||||
|
|
||||||
b.HasIndex("OrganizationId", "Name");
|
b.HasIndex("OrganizationId", "Name");
|
||||||
|
|
||||||
b.HasIndex("OrganizationId", "ProductGroupId");
|
b.HasIndex("OrganizationId", "ProductGroupId");
|
||||||
|
|
@ -697,9 +695,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
b.Property<DateTime>("CreatedAt")
|
b.Property<DateTime>("CreatedAt")
|
||||||
.HasColumnType("timestamp with time zone");
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<decimal?>("MarkupPercent")
|
b.Property<decimal?>("MarkupPercent")
|
||||||
.HasPrecision(5, 2)
|
.HasPrecision(5, 2)
|
||||||
.HasColumnType("numeric(5,2)");
|
.HasColumnType("numeric(5,2)");
|
||||||
|
|
@ -933,9 +928,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
.HasMaxLength(500)
|
.HasMaxLength(500)
|
||||||
.HasColumnType("character varying(500)");
|
.HasColumnType("character varying(500)");
|
||||||
|
|
||||||
b.Property<bool>("IsActive")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(100)
|
.HasMaxLength(100)
|
||||||
|
|
@ -1107,9 +1099,6 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
b.Property<bool>("MultiCurrencyEnabled")
|
b.Property<bool>("MultiCurrencyEnabled")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<bool>("MultiplePriceTypesEnabled")
|
|
||||||
.HasColumnType("boolean");
|
|
||||||
|
|
||||||
b.Property<bool>("ShowMarkedOnProduct")
|
b.Property<bool>("ShowMarkedOnProduct")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import { CountriesPage } from '@/pages/CountriesPage'
|
||||||
import { CurrenciesPage } from '@/pages/CurrenciesPage'
|
import { CurrenciesPage } from '@/pages/CurrenciesPage'
|
||||||
import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage'
|
import { UnitsOfMeasurePage } from '@/pages/UnitsOfMeasurePage'
|
||||||
import { PriceTypesPage } from '@/pages/PriceTypesPage'
|
import { PriceTypesPage } from '@/pages/PriceTypesPage'
|
||||||
import { GroupMarkupsPage } from '@/pages/GroupMarkupsPage'
|
|
||||||
import { StoresPage } from '@/pages/StoresPage'
|
import { StoresPage } from '@/pages/StoresPage'
|
||||||
import { RetailPointsPage } from '@/pages/RetailPointsPage'
|
import { RetailPointsPage } from '@/pages/RetailPointsPage'
|
||||||
import { ProductGroupsPage } from '@/pages/ProductGroupsPage'
|
import { ProductGroupsPage } from '@/pages/ProductGroupsPage'
|
||||||
|
|
@ -48,7 +47,6 @@ export default function App() {
|
||||||
<Route path="/catalog/product-groups" element={<ProductGroupsPage />} />
|
<Route path="/catalog/product-groups" element={<ProductGroupsPage />} />
|
||||||
<Route path="/catalog/units" element={<UnitsOfMeasurePage />} />
|
<Route path="/catalog/units" element={<UnitsOfMeasurePage />} />
|
||||||
<Route path="/catalog/price-types" element={<PriceTypesPage />} />
|
<Route path="/catalog/price-types" element={<PriceTypesPage />} />
|
||||||
<Route path="/settings/group-markups" element={<GroupMarkupsPage />} />
|
|
||||||
<Route path="/catalog/counterparties" element={<CounterpartiesPage />} />
|
<Route path="/catalog/counterparties" element={<CounterpartiesPage />} />
|
||||||
<Route path="/catalog/stores" element={<StoresPage />} />
|
<Route path="/catalog/stores" element={<StoresPage />} />
|
||||||
<Route path="/catalog/retail-points" element={<RetailPointsPage />} />
|
<Route path="/catalog/retail-points" element={<RetailPointsPage />} />
|
||||||
|
|
|
||||||
|
|
@ -10,8 +10,6 @@ import {
|
||||||
Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X,
|
Boxes, History, TruckIcon, ShoppingCart, Settings, Menu, X,
|
||||||
} from 'lucide-react'
|
} from 'lucide-react'
|
||||||
import { Logo } from './Logo'
|
import { Logo } from './Logo'
|
||||||
import { Percent } from 'lucide-react'
|
|
||||||
import { useOrgSettings } from '@/lib/useOrgSettings'
|
|
||||||
|
|
||||||
interface MeResponse {
|
interface MeResponse {
|
||||||
sub: string
|
sub: string
|
||||||
|
|
@ -24,13 +22,13 @@ interface MeResponse {
|
||||||
type NavItem = { to: string; icon: typeof LayoutDashboard; label: string; end?: boolean }
|
type NavItem = { to: string; icon: typeof LayoutDashboard; label: string; end?: boolean }
|
||||||
type NavSection = { group: string; items: NavItem[] }
|
type NavSection = { group: string; items: NavItem[] }
|
||||||
|
|
||||||
function buildNav(showPriceTypes: boolean): NavSection[] {
|
function buildNav(): NavSection[] {
|
||||||
const catalog: NavItem[] = [
|
const catalog: NavItem[] = [
|
||||||
{ to: '/catalog/products', icon: Package, label: 'Товары' },
|
{ to: '/catalog/products', icon: Package, label: 'Товары' },
|
||||||
{ to: '/catalog/product-groups', icon: FolderTree, label: 'Группы' },
|
{ to: '/catalog/product-groups', icon: FolderTree, label: 'Группы' },
|
||||||
{ to: '/catalog/units', icon: Ruler, label: 'Ед. измерения' },
|
{ to: '/catalog/units', icon: Ruler, label: 'Ед. измерения' },
|
||||||
|
{ to: '/catalog/price-types', icon: Tag, label: 'Типы цен' },
|
||||||
]
|
]
|
||||||
if (showPriceTypes) catalog.push({ to: '/catalog/price-types', icon: Tag, label: 'Типы цен' })
|
|
||||||
return [
|
return [
|
||||||
{ group: 'Главное', items: [
|
{ group: 'Главное', items: [
|
||||||
{ to: '/', icon: LayoutDashboard, label: 'Dashboard', end: true },
|
{ to: '/', icon: LayoutDashboard, label: 'Dashboard', end: true },
|
||||||
|
|
@ -62,7 +60,6 @@ function buildNav(showPriceTypes: boolean): NavSection[] {
|
||||||
]},
|
]},
|
||||||
{ group: 'Настройки', items: [
|
{ group: 'Настройки', items: [
|
||||||
{ to: '/settings/organization', icon: Settings, label: 'Организация' },
|
{ to: '/settings/organization', icon: Settings, label: 'Организация' },
|
||||||
{ to: '/settings/group-markups', icon: Percent, label: 'Наценки по группам' },
|
|
||||||
]},
|
]},
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
@ -74,8 +71,7 @@ export function AppLayout() {
|
||||||
staleTime: 5 * 60 * 1000,
|
staleTime: 5 * 60 * 1000,
|
||||||
})
|
})
|
||||||
|
|
||||||
const org = useOrgSettings()
|
const nav = buildNav()
|
||||||
const nav = buildNav(org.data?.multiplePriceTypesEnabled ?? false)
|
|
||||||
|
|
||||||
const [drawerOpen, setDrawerOpen] = useState(false)
|
const [drawerOpen, setDrawerOpen] = useState(false)
|
||||||
const location = useLocation()
|
const location = useLocation()
|
||||||
|
|
|
||||||
|
|
@ -28,8 +28,8 @@ export interface Country {
|
||||||
vatRate: number
|
vatRate: number
|
||||||
}
|
}
|
||||||
export interface Currency { id: string; code: string; name: string; symbol: string }
|
export interface Currency { id: string; code: string; name: string; symbol: string }
|
||||||
export interface UnitOfMeasure { id: string; code: string; name: string; description: string | null; isActive: boolean }
|
export interface UnitOfMeasure { id: string; code: string; name: string; description: string | null }
|
||||||
export interface PriceType { id: string; name: string; isDefault: boolean; isRetail: boolean; sortOrder: number; isActive: boolean }
|
export interface PriceType { id: string; name: string; isRequired: boolean; isSystem: boolean; isDefault: boolean; isRetail: boolean; sortOrder: number }
|
||||||
export interface Store {
|
export interface Store {
|
||||||
id: string; name: string; code: string | null; address: string | null; phone: string | null;
|
id: string; name: string; code: string | null; address: string | null; phone: string | null;
|
||||||
managerName: string | null; isMain: boolean; isActive: boolean
|
managerName: string | null; isMain: boolean; isActive: boolean
|
||||||
|
|
@ -38,13 +38,13 @@ export interface RetailPoint {
|
||||||
id: string; name: string; code: string | null; storeId: string; storeName: string | null;
|
id: string; name: string; code: string | null; storeId: string; storeName: string | null;
|
||||||
address: string | null; phone: string | null; fiscalSerial: string | null; fiscalRegNumber: string | null; isActive: boolean
|
address: string | null; phone: string | null; fiscalSerial: string | null; fiscalRegNumber: string | null; isActive: boolean
|
||||||
}
|
}
|
||||||
export interface ProductGroup { id: string; name: string; parentId: string | null; path: string; sortOrder: number; isActive: boolean; markupPercent: number | null }
|
export interface ProductGroup { id: string; name: string; parentId: string | null; path: string; sortOrder: number; markupPercent: number | null }
|
||||||
export interface Counterparty {
|
export interface Counterparty {
|
||||||
id: string; name: string; legalName: string | null; type: CounterpartyType;
|
id: string; name: string; legalName: string | null; type: CounterpartyType;
|
||||||
bin: string | null; iin: string | null; taxNumber: string | null; countryId: string | null; countryName: string | null;
|
bin: string | null; iin: string | null; taxNumber: string | null; countryId: string | null; countryName: string | null;
|
||||||
address: string | null; phone: string | null; email: string | null;
|
address: string | null; phone: string | null; email: string | null;
|
||||||
bankName: string | null; bankAccount: string | null; bik: string | null;
|
bankName: string | null; bankAccount: string | null; bik: string | null;
|
||||||
contactPerson: string | null; notes: string | null; isActive: boolean
|
contactPerson: string | null; notes: string | null
|
||||||
}
|
}
|
||||||
export interface ProductBarcode { id: string; code: string; type: BarcodeType; isPrimary: boolean }
|
export interface ProductBarcode { id: string; code: string; type: BarcodeType; isPrimary: boolean }
|
||||||
export interface ProductPrice { id: string; priceTypeId: string; priceTypeName: string; amount: number; currencyId: string; currencyCode: string }
|
export interface ProductPrice { id: string; priceTypeId: string; priceTypeName: string; amount: number; currencyId: string; currencyCode: string }
|
||||||
|
|
@ -60,7 +60,7 @@ export interface Product {
|
||||||
referencePrice: number | null; referencePriceUpdatedAt: string | null;
|
referencePrice: number | null; referencePriceUpdatedAt: string | null;
|
||||||
purchaseCurrencyId: string | null; purchaseCurrencyCode: string | null;
|
purchaseCurrencyId: string | null; purchaseCurrencyCode: string | null;
|
||||||
cost: number; lastSupplyAt: string | null;
|
cost: number; lastSupplyAt: string | null;
|
||||||
imageUrl: string | null; isActive: boolean;
|
imageUrl: string | null; shelfLifeDays: number | null;
|
||||||
prices: ProductPrice[]; barcodes: ProductBarcode[]
|
prices: ProductPrice[]; barcodes: ProductBarcode[]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ export interface OrgSettings {
|
||||||
showMarkedOnProduct: boolean
|
showMarkedOnProduct: boolean
|
||||||
showMinMaxStock: boolean
|
showMinMaxStock: boolean
|
||||||
allowFractionalPrices: boolean
|
allowFractionalPrices: boolean
|
||||||
multiplePriceTypesEnabled: boolean
|
|
||||||
showReferencePriceOnProduct: boolean
|
showReferencePriceOnProduct: boolean
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,7 @@ import { Pagination } from '@/components/Pagination'
|
||||||
import { SearchBar } from '@/components/SearchBar'
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
import { Button } from '@/components/Button'
|
import { Button } from '@/components/Button'
|
||||||
import { Modal } from '@/components/Modal'
|
import { Modal } from '@/components/Modal'
|
||||||
import { Field, TextInput, TextArea, Select, Checkbox } from '@/components/Field'
|
import { Field, TextInput, TextArea, Select } from '@/components/Field'
|
||||||
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
|
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
|
||||||
import { type Counterparty, type Country, type PagedResult, CounterpartyType } from '@/lib/types'
|
import { type Counterparty, type Country, type PagedResult, CounterpartyType } from '@/lib/types'
|
||||||
|
|
||||||
|
|
@ -31,7 +31,6 @@ interface Form {
|
||||||
bik: string
|
bik: string
|
||||||
contactPerson: string
|
contactPerson: string
|
||||||
notes: string
|
notes: string
|
||||||
isActive: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const blankForm: Form = {
|
const blankForm: Form = {
|
||||||
|
|
@ -39,7 +38,7 @@ const blankForm: Form = {
|
||||||
bin: '', iin: '', taxNumber: '', countryId: '',
|
bin: '', iin: '', taxNumber: '', countryId: '',
|
||||||
address: '', phone: '', email: '',
|
address: '', phone: '', email: '',
|
||||||
bankName: '', bankAccount: '', bik: '',
|
bankName: '', bankAccount: '', bik: '',
|
||||||
contactPerson: '', notes: '', isActive: true,
|
contactPerson: '', notes: '',
|
||||||
}
|
}
|
||||||
|
|
||||||
const typeLabel: Record<CounterpartyType, string> = {
|
const typeLabel: Record<CounterpartyType, string> = {
|
||||||
|
|
@ -94,7 +93,7 @@ export function CounterpartiesPage() {
|
||||||
bin: r.bin ?? '', iin: r.iin ?? '', taxNumber: r.taxNumber ?? '',
|
bin: r.bin ?? '', iin: r.iin ?? '', taxNumber: r.taxNumber ?? '',
|
||||||
countryId: r.countryId ?? '', address: r.address ?? '', phone: r.phone ?? '', email: r.email ?? '',
|
countryId: r.countryId ?? '', address: r.address ?? '', phone: r.phone ?? '', email: r.email ?? '',
|
||||||
bankName: r.bankName ?? '', bankAccount: r.bankAccount ?? '', bik: r.bik ?? '',
|
bankName: r.bankName ?? '', bankAccount: r.bankAccount ?? '', bik: r.bik ?? '',
|
||||||
contactPerson: r.contactPerson ?? '', notes: r.notes ?? '', isActive: r.isActive,
|
contactPerson: r.contactPerson ?? '', notes: r.notes ?? '',
|
||||||
})}
|
})}
|
||||||
columns={[
|
columns={[
|
||||||
{ header: 'Название', sortKey: 'name', cell: (r) => r.name },
|
{ header: 'Название', sortKey: 'name', cell: (r) => r.name },
|
||||||
|
|
@ -102,7 +101,6 @@ export function CounterpartiesPage() {
|
||||||
{ header: 'БИН/ИИН', width: '140px', cell: (r) => <span className="font-mono">{r.bin ?? r.iin ?? '—'}</span> },
|
{ header: 'БИН/ИИН', width: '140px', cell: (r) => <span className="font-mono">{r.bin ?? r.iin ?? '—'}</span> },
|
||||||
{ header: 'Телефон', width: '160px', sortKey: 'phone', cell: (r) => r.phone ?? '—' },
|
{ header: 'Телефон', width: '160px', sortKey: 'phone', cell: (r) => r.phone ?? '—' },
|
||||||
{ header: 'Страна', width: '120px', sortKey: 'country', cell: (r) => r.countryName ?? '—' },
|
{ header: 'Страна', width: '120px', sortKey: 'country', cell: (r) => r.countryName ?? '—' },
|
||||||
{ header: 'Активен', width: '100px', sortKey: 'isActive', cell: (r) => r.isActive ? '✓' : '—' },
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</ListPageShell>
|
</ListPageShell>
|
||||||
|
|
@ -183,7 +181,6 @@ export function CounterpartiesPage() {
|
||||||
<TextArea rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
<TextArea rows={3} value={form.notes} onChange={(e) => setForm({ ...form, notes: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<div className="col-span-2">
|
<div className="col-span-2">
|
||||||
<Checkbox label="Активен" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
|
|
@ -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,
|
showMarkedOnProduct: form.showMarkedOnProduct,
|
||||||
showMinMaxStock: form.showMinMaxStock,
|
showMinMaxStock: form.showMinMaxStock,
|
||||||
allowFractionalPrices: form.allowFractionalPrices,
|
allowFractionalPrices: form.allowFractionalPrices,
|
||||||
multiplePriceTypesEnabled: form.multiplePriceTypesEnabled,
|
|
||||||
showReferencePriceOnProduct: form.showReferencePriceOnProduct,
|
showReferencePriceOnProduct: form.showReferencePriceOnProduct,
|
||||||
}
|
}
|
||||||
return (await api.put<OrgSettings>('/api/organization/settings', payload)).data
|
return (await api.put<OrgSettings>('/api/organization/settings', payload)).data
|
||||||
|
|
@ -153,16 +152,6 @@ export function OrganizationSettingsPage() {
|
||||||
По умолчанию — целые тенге, без копеек.
|
По умолчанию — целые тенге, без копеек.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<Checkbox
|
|
||||||
label='Несколько типов цен (Опт, VIP и т.п.)'
|
|
||||||
checked={form.multiplePriceTypesEnabled}
|
|
||||||
onChange={(v) => setForm({ ...form, multiplePriceTypesEnabled: v })}
|
|
||||||
/>
|
|
||||||
<p className="text-xs text-slate-500 -mt-2">
|
|
||||||
Если включено — в меню появляется «Типы цен», на карточке товара —
|
|
||||||
список цен по всем типам. По умолчанию одна розничная цена.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<Checkbox
|
<Checkbox
|
||||||
label='Показывать «Эталонную цену» на товаре'
|
label='Показывать «Эталонную цену» на товаре'
|
||||||
checked={form.showReferencePriceOnProduct}
|
checked={form.showReferencePriceOnProduct}
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,5 @@
|
||||||
import { useState } from 'react'
|
import { useState } from 'react'
|
||||||
import { Plus, Trash2 } from 'lucide-react'
|
import { Plus, Trash2, Lock } from 'lucide-react'
|
||||||
import { ListPageShell } from '@/components/ListPageShell'
|
import { ListPageShell } from '@/components/ListPageShell'
|
||||||
import { DataTable } from '@/components/DataTable'
|
import { DataTable } from '@/components/DataTable'
|
||||||
import { Pagination } from '@/components/Pagination'
|
import { Pagination } from '@/components/Pagination'
|
||||||
|
|
@ -12,8 +12,16 @@ import type { PriceType } from '@/lib/types'
|
||||||
|
|
||||||
const URL = '/api/catalog/price-types'
|
const URL = '/api/catalog/price-types'
|
||||||
|
|
||||||
interface Form { id?: string; name: string; isDefault: boolean; isRetail: boolean; sortOrder: number; isActive: boolean }
|
interface Form {
|
||||||
const blankForm: Form = { name: '', isDefault: false, isRetail: false, sortOrder: 0, isActive: true }
|
id?: string
|
||||||
|
name: string
|
||||||
|
isRequired: boolean
|
||||||
|
isSystem: boolean
|
||||||
|
isDefault: boolean
|
||||||
|
isRetail: boolean
|
||||||
|
sortOrder: number
|
||||||
|
}
|
||||||
|
const blankForm: Form = { name: '', isRequired: false, isSystem: false, isDefault: false, isRetail: false, sortOrder: 0 }
|
||||||
|
|
||||||
export function PriceTypesPage() {
|
export function PriceTypesPage() {
|
||||||
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<PriceType>(URL)
|
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<PriceType>(URL)
|
||||||
|
|
@ -22,7 +30,8 @@ export function PriceTypesPage() {
|
||||||
|
|
||||||
const save = async () => {
|
const save = async () => {
|
||||||
if (!form) return
|
if (!form) return
|
||||||
const { id, ...payload } = form
|
const { id, isSystem: _omit, ...payload } = form
|
||||||
|
void _omit
|
||||||
if (id) await update.mutateAsync({ id, input: payload })
|
if (id) await update.mutateAsync({ id, input: payload })
|
||||||
else await create.mutateAsync(payload)
|
else await create.mutateAsync(payload)
|
||||||
setForm(null)
|
setForm(null)
|
||||||
|
|
@ -50,13 +59,20 @@ export function PriceTypesPage() {
|
||||||
sortKey={sortKey}
|
sortKey={sortKey}
|
||||||
sortOrder={sortOrder}
|
sortOrder={sortOrder}
|
||||||
onSortChange={setSort}
|
onSortChange={setSort}
|
||||||
onRowClick={(r) => setForm({ ...r })}
|
onRowClick={(r) => setForm({
|
||||||
|
id: r.id, name: r.name,
|
||||||
|
isRequired: r.isRequired, isSystem: r.isSystem,
|
||||||
|
isDefault: r.isDefault, isRetail: r.isRetail, sortOrder: r.sortOrder,
|
||||||
|
})}
|
||||||
columns={[
|
columns={[
|
||||||
{ header: 'Название', sortKey: 'name', cell: (r) => r.name },
|
{ header: 'Название', sortKey: 'name', cell: (r) => (
|
||||||
{ header: 'Розничная', width: '120px', sortKey: 'isRetail', cell: (r) => r.isRetail ? '✓' : '—' },
|
<span className="flex items-center gap-1.5">
|
||||||
{ header: 'По умолчанию', width: '140px', sortKey: 'isDefault', cell: (r) => r.isDefault ? '✓' : '—' },
|
{r.name}
|
||||||
|
{r.isSystem && <Lock className="w-3.5 h-3.5 text-slate-400" />}
|
||||||
|
</span>
|
||||||
|
)},
|
||||||
|
{ header: 'Обязательно', width: '140px', sortKey: 'isRequired', cell: (r) => r.isRequired ? '✓' : '—' },
|
||||||
{ header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder },
|
{ header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder },
|
||||||
{ header: 'Активна', width: '100px', sortKey: 'isActive', cell: (r) => r.isActive ? '✓' : '—' },
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</ListPageShell>
|
</ListPageShell>
|
||||||
|
|
@ -67,7 +83,7 @@ export function PriceTypesPage() {
|
||||||
title={form?.id ? 'Редактировать тип цены' : 'Новый тип цены'}
|
title={form?.id ? 'Редактировать тип цены' : 'Новый тип цены'}
|
||||||
footer={
|
footer={
|
||||||
<>
|
<>
|
||||||
{form?.id && (
|
{form?.id && !form.isSystem && (
|
||||||
<Button variant="danger" size="sm" onClick={async () => {
|
<Button variant="danger" size="sm" onClick={async () => {
|
||||||
if (confirm('Удалить тип цены?')) {
|
if (confirm('Удалить тип цены?')) {
|
||||||
await remove.mutateAsync(form.id!)
|
await remove.mutateAsync(form.id!)
|
||||||
|
|
@ -84,6 +100,11 @@ export function PriceTypesPage() {
|
||||||
>
|
>
|
||||||
{form && (
|
{form && (
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
|
{form.isSystem && (
|
||||||
|
<p className="text-xs text-slate-500 bg-slate-50 dark:bg-slate-800/40 p-2 rounded border border-slate-200 dark:border-slate-700">
|
||||||
|
Системная запись. Имя можно переименовать; флаг «Обязательно» зафиксирован, удаление запрещено.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
<Field label="Название">
|
<Field label="Название">
|
||||||
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
<TextInput value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
|
|
@ -93,9 +114,14 @@ export function PriceTypesPage() {
|
||||||
onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })}
|
onChange={(e) => setForm({ ...form, sortOrder: Number(e.target.value) })}
|
||||||
/>
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
<Checkbox
|
||||||
|
label="Обязательная (требуется заполнение у каждого товара)"
|
||||||
|
checked={form.isRequired}
|
||||||
|
disabled={form.isSystem}
|
||||||
|
onChange={(v) => setForm({ ...form, isRequired: v })}
|
||||||
|
/>
|
||||||
<Checkbox label="Розничная (используется на кассе)" checked={form.isRetail} onChange={(v) => setForm({ ...form, isRetail: v })} />
|
<Checkbox label="Розничная (используется на кассе)" checked={form.isRetail} onChange={(v) => setForm({ ...form, isRetail: v })} />
|
||||||
<Checkbox label="По умолчанию" checked={form.isDefault} onChange={(v) => setForm({ ...form, isDefault: v })} />
|
<Checkbox label="По умолчанию" checked={form.isDefault} onChange={(v) => setForm({ ...form, isDefault: v })} />
|
||||||
<Checkbox label="Активна" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
||||||
|
|
@ -29,7 +29,6 @@ interface Form {
|
||||||
isService: boolean
|
isService: boolean
|
||||||
packaging: Packaging
|
packaging: Packaging
|
||||||
isMarked: boolean
|
isMarked: boolean
|
||||||
isActive: boolean
|
|
||||||
minStock: string
|
minStock: string
|
||||||
maxStock: string
|
maxStock: string
|
||||||
referencePrice: string
|
referencePrice: string
|
||||||
|
|
@ -44,7 +43,7 @@ const emptyForm: Form = {
|
||||||
name: '', article: '', description: '',
|
name: '', article: '', description: '',
|
||||||
unitOfMeasureId: '', vat: 16, vatEnabled: true,
|
unitOfMeasureId: '', vat: 16, vatEnabled: true,
|
||||||
productGroupId: '', defaultSupplierId: '', countryOfOriginId: '',
|
productGroupId: '', defaultSupplierId: '', countryOfOriginId: '',
|
||||||
isService: false, packaging: Packaging.Piece, isMarked: false, isActive: true,
|
isService: false, packaging: Packaging.Piece, isMarked: false,
|
||||||
minStock: '', maxStock: '',
|
minStock: '', maxStock: '',
|
||||||
referencePrice: '', purchaseCurrencyId: '',
|
referencePrice: '', purchaseCurrencyId: '',
|
||||||
imageUrl: '',
|
imageUrl: '',
|
||||||
|
|
@ -84,7 +83,7 @@ export function ProductEditPage() {
|
||||||
productGroupId: p.productGroupId ?? '', defaultSupplierId: p.defaultSupplierId ?? '',
|
productGroupId: p.productGroupId ?? '', defaultSupplierId: p.defaultSupplierId ?? '',
|
||||||
countryOfOriginId: p.countryOfOriginId ?? '',
|
countryOfOriginId: p.countryOfOriginId ?? '',
|
||||||
isService: p.isService, packaging: p.packaging, isMarked: p.isMarked,
|
isService: p.isService, packaging: p.packaging, isMarked: p.isMarked,
|
||||||
isActive: p.isActive,
|
|
||||||
minStock: p.minStock?.toString() ?? '',
|
minStock: p.minStock?.toString() ?? '',
|
||||||
maxStock: p.maxStock?.toString() ?? '',
|
maxStock: p.maxStock?.toString() ?? '',
|
||||||
referencePrice: p.referencePrice?.toString() ?? '',
|
referencePrice: p.referencePrice?.toString() ?? '',
|
||||||
|
|
@ -144,7 +143,7 @@ export function ProductEditPage() {
|
||||||
isService: form.isService,
|
isService: form.isService,
|
||||||
packaging: form.packaging,
|
packaging: form.packaging,
|
||||||
isMarked: form.isMarked,
|
isMarked: form.isMarked,
|
||||||
isActive: form.isActive,
|
|
||||||
minStock: form.minStock === '' ? null : Number(form.minStock),
|
minStock: form.minStock === '' ? null : Number(form.minStock),
|
||||||
maxStock: form.maxStock === '' ? null : Number(form.maxStock),
|
maxStock: form.maxStock === '' ? null : Number(form.maxStock),
|
||||||
referencePrice: form.referencePrice === '' ? null : Number(form.referencePrice),
|
referencePrice: form.referencePrice === '' ? null : Number(form.referencePrice),
|
||||||
|
|
@ -311,7 +310,6 @@ export function ProductEditPage() {
|
||||||
{org.data?.showMarkedOnProduct && (
|
{org.data?.showMarkedOnProduct && (
|
||||||
<Checkbox label="Маркируемый (Честный знак / Datamatrix)" checked={form.isMarked} onChange={(v) => setForm({ ...form, isMarked: v })} />
|
<Checkbox label="Маркируемый (Честный знак / Datamatrix)" checked={form.isMarked} onChange={(v) => setForm({ ...form, isMarked: v })} />
|
||||||
)}
|
)}
|
||||||
<Checkbox label="Активен" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
|
|
||||||
</div>
|
</div>
|
||||||
</Section>
|
</Section>
|
||||||
|
|
||||||
|
|
@ -371,15 +369,14 @@ export function ProductEditPage() {
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Section
|
<Section
|
||||||
title={org.data?.multiplePriceTypesEnabled ? 'Цены продажи' : 'Розничная цена'}
|
title="Цены продажи"
|
||||||
action={
|
action={
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
{!isNew && id && (
|
{!isNew && id && (
|
||||||
<Button type="button" variant="secondary" size="sm" onClick={async () => {
|
<Button type="button" variant="secondary" size="sm" onClick={async () => {
|
||||||
try {
|
try {
|
||||||
const res = await api.post<{ retail: number }>(`/api/catalog/products/${id}/recalc-retail`)
|
const res = await api.post<{ retail: number }>(`/api/catalog/products/${id}/recalc-retail`)
|
||||||
// Обновим UI значение в form.prices для дефолтного PriceType.
|
const def = priceTypes.data?.find((pt) => pt.isSystem) ?? priceTypes.data?.find((pt) => pt.isDefault) ?? priceTypes.data?.[0]
|
||||||
const def = priceTypes.data?.find((pt) => pt.isDefault) ?? priceTypes.data?.find((pt) => pt.isRetail) ?? priceTypes.data?.[0]
|
|
||||||
if (def) {
|
if (def) {
|
||||||
setForm((f) => {
|
setForm((f) => {
|
||||||
const has = f.prices.some(p => p.priceTypeId === def.id)
|
const has = f.prices.some(p => p.priceTypeId === def.id)
|
||||||
|
|
@ -400,9 +397,7 @@ export function ProductEditPage() {
|
||||||
Привести к себестоимости
|
Привести к себестоимости
|
||||||
</Button>
|
</Button>
|
||||||
)}
|
)}
|
||||||
{org.data?.multiplePriceTypesEnabled && (
|
|
||||||
<Button type="button" variant="secondary" size="sm" onClick={addPrice}><Plus className="w-3.5 h-3.5" /> Добавить</Button>
|
<Button type="button" variant="secondary" size="sm" onClick={addPrice}><Plus className="w-3.5 h-3.5" /> Добавить</Button>
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
>
|
>
|
||||||
|
|
|
||||||
|
|
@ -6,14 +6,14 @@ import { Pagination } from '@/components/Pagination'
|
||||||
import { SearchBar } from '@/components/SearchBar'
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
import { Button } from '@/components/Button'
|
import { Button } from '@/components/Button'
|
||||||
import { Modal } from '@/components/Modal'
|
import { Modal } from '@/components/Modal'
|
||||||
import { Field, TextInput, Select, Checkbox } from '@/components/Field'
|
import { Field, TextInput, Select } from '@/components/Field'
|
||||||
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
|
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
|
||||||
import type { ProductGroup } from '@/lib/types'
|
import type { ProductGroup } from '@/lib/types'
|
||||||
|
|
||||||
const URL = '/api/catalog/product-groups'
|
const URL = '/api/catalog/product-groups'
|
||||||
|
|
||||||
interface Form { id?: string; name: string; parentId: string | null; sortOrder: number; isActive: boolean; markupPercent: number | null }
|
interface Form { id?: string; name: string; parentId: string | null; sortOrder: number; markupPercent: number | null }
|
||||||
const blankForm: Form = { name: '', parentId: null, sortOrder: 0, isActive: true, markupPercent: null }
|
const blankForm: Form = { name: '', parentId: null, sortOrder: 0, markupPercent: null }
|
||||||
|
|
||||||
export function ProductGroupsPage() {
|
export function ProductGroupsPage() {
|
||||||
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<ProductGroup>(URL)
|
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<ProductGroup>(URL)
|
||||||
|
|
@ -50,13 +50,12 @@ export function ProductGroupsPage() {
|
||||||
sortKey={sortKey}
|
sortKey={sortKey}
|
||||||
sortOrder={sortOrder}
|
sortOrder={sortOrder}
|
||||||
onSortChange={setSort}
|
onSortChange={setSort}
|
||||||
onRowClick={(r) => setForm({ id: r.id, name: r.name, parentId: r.parentId, sortOrder: r.sortOrder, isActive: r.isActive, markupPercent: r.markupPercent })}
|
onRowClick={(r) => setForm({ id: r.id, name: r.name, parentId: r.parentId, sortOrder: r.sortOrder, markupPercent: r.markupPercent })}
|
||||||
columns={[
|
columns={[
|
||||||
{ header: 'Название', sortKey: 'name', cell: (r) => r.name },
|
{ header: 'Название', sortKey: 'name', cell: (r) => r.name },
|
||||||
{ header: 'Путь', sortKey: 'path', cell: (r) => <span className="text-slate-500">{r.path}</span> },
|
{ header: 'Путь', sortKey: 'path', cell: (r) => <span className="text-slate-500">{r.path}</span> },
|
||||||
{ header: 'Наценка', width: '110px', className: 'text-right', cell: (r) => r.markupPercent != null ? `${r.markupPercent.toFixed(2)}%` : '—' },
|
{ header: 'Наценка', width: '110px', className: 'text-right', cell: (r) => r.markupPercent != null ? `${r.markupPercent.toFixed(2)}%` : '—' },
|
||||||
{ header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder },
|
{ header: 'Порядок', width: '100px', className: 'text-right', cell: (r) => r.sortOrder },
|
||||||
{ header: 'Активна', width: '100px', sortKey: 'isActive', cell: (r) => r.isActive ? '✓' : '—' },
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</ListPageShell>
|
</ListPageShell>
|
||||||
|
|
@ -116,7 +115,6 @@ export function ProductGroupsPage() {
|
||||||
Пусто — автонаценка отключена.
|
Пусто — автонаценка отключена.
|
||||||
</p>
|
</p>
|
||||||
</Field>
|
</Field>
|
||||||
<Checkbox label="Активна" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import { Pagination } from '@/components/Pagination'
|
||||||
import { SearchBar } from '@/components/SearchBar'
|
import { SearchBar } from '@/components/SearchBar'
|
||||||
import { Button } from '@/components/Button'
|
import { Button } from '@/components/Button'
|
||||||
import { Modal } from '@/components/Modal'
|
import { Modal } from '@/components/Modal'
|
||||||
import { Field, TextInput, Checkbox } from '@/components/Field'
|
import { Field, TextInput } from '@/components/Field'
|
||||||
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
|
import { useCatalogList, useCatalogMutations } from '@/lib/useCatalog'
|
||||||
import type { UnitOfMeasure } from '@/lib/types'
|
import type { UnitOfMeasure } from '@/lib/types'
|
||||||
|
|
||||||
|
|
@ -17,10 +17,9 @@ interface Form {
|
||||||
code: string
|
code: string
|
||||||
name: string
|
name: string
|
||||||
description: string
|
description: string
|
||||||
isActive: boolean
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const blankForm: Form = { code: '', name: '', description: '', isActive: true }
|
const blankForm: Form = { code: '', name: '', description: '' }
|
||||||
|
|
||||||
export function UnitsOfMeasurePage() {
|
export function UnitsOfMeasurePage() {
|
||||||
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<UnitOfMeasure>(URL)
|
const { data, isLoading, page, setPage, search, setSearch, sortKey, sortOrder, setSort } = useCatalogList<UnitOfMeasure>(URL)
|
||||||
|
|
@ -59,13 +58,12 @@ export function UnitsOfMeasurePage() {
|
||||||
onSortChange={setSort}
|
onSortChange={setSort}
|
||||||
onRowClick={(r) => setForm({
|
onRowClick={(r) => setForm({
|
||||||
id: r.id, code: r.code, name: r.name,
|
id: r.id, code: r.code, name: r.name,
|
||||||
description: r.description ?? '', isActive: r.isActive,
|
description: r.description ?? ''
|
||||||
})}
|
})}
|
||||||
columns={[
|
columns={[
|
||||||
{ header: 'Код', width: '90px', sortKey: 'code', cell: (r) => <span className="font-mono">{r.code}</span> },
|
{ header: 'Код', width: '90px', sortKey: 'code', cell: (r) => <span className="font-mono">{r.code}</span> },
|
||||||
{ header: 'Название', sortKey: 'name', cell: (r) => r.name },
|
{ header: 'Название', sortKey: 'name', cell: (r) => r.name },
|
||||||
{ header: 'Описание', cell: (r) => r.description ?? '—' },
|
{ header: 'Описание', cell: (r) => r.description ?? '—' },
|
||||||
{ header: 'Активна', width: '100px', sortKey: 'isActive', cell: (r) => r.isActive ? '✓' : '—' },
|
|
||||||
]}
|
]}
|
||||||
/>
|
/>
|
||||||
</ListPageShell>
|
</ListPageShell>
|
||||||
|
|
@ -102,7 +100,6 @@ export function UnitsOfMeasurePage() {
|
||||||
<Field label="Описание">
|
<Field label="Описание">
|
||||||
<TextInput value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
<TextInput value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} />
|
||||||
</Field>
|
</Field>
|
||||||
<Checkbox label="Активна" checked={form.isActive} onChange={(v) => setForm({ ...form, isActive: v })} />
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</Modal>
|
</Modal>
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue