feat(domain): pricing model rename and new fields (Phase3a)
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 44s
CI / Web (React + Vite) (push) Successful in 34s
Docker API / Build + push API (push) Successful in 41s
Docker Web / Build + push Web (push) Successful in 28s
Docker API / Deploy API on stage (push) Successful in 17s
Docker Web / Deploy Web on stage (push) Successful in 11s
Some checks are pending
CI / POS (WPF, Windows) (push) Waiting to run
CI / Backend (.NET 8) (push) Successful in 44s
CI / Web (React + Vite) (push) Successful in 34s
Docker API / Build + push API (push) Successful in 41s
Docker Web / Build + push Web (push) Successful in 28s
Docker API / Deploy API on stage (push) Successful in 17s
Docker Web / Deploy Web on stage (push) Successful in 11s
Подготовка к новой модели цен МойСклад-style: - Product.PurchasePrice → ReferencePrice (справочная закупочная, не обязательная). + ReferencePriceUpdatedAt для 30-дневного таймера. - Product.+ Cost numeric(18,4) — себестоимость по скользящему среднему. - Product.+ LastSupplyAt — UTC последней Posted приёмки. - ProductGroup.+ MarkupPercent (5,2) — % наценки на cost для авто-розничной. - Organization.+ MultiplePriceTypesEnabled (default false) и ShowReferencePriceOnProduct (default true). - SupplyLine.+ RetailPriceManuallyOverridden + RetailPriceOverride — отметка ручной правки розничной в строке приёмки. Миграция Phase3a_PricingModel: RENAME + AddColumn'ы. Logic перерасчёта себестоимости, автонаценки, recalc-endpoint и Hangfire job — следующими коммитами. DTO/контроллеры/MoySklad-импорт/UI поля переименованы в referencePrice (включая фильтры списка товаров). UI-логика следующего коммита будет показывать Cost и кнопку «привести розничную к себестоимости»; пока referencePrice работает как раньше. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
453d04b7d1
commit
23d6f2bd5a
|
|
@ -46,7 +46,7 @@ public class ProductGroupsController : ControllerBase
|
||||||
};
|
};
|
||||||
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))
|
.Select(g => new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.IsActive, 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 +55,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);
|
return g is null ? NotFound() : new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.IsActive, g.MarkupPercent);
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPost, Authorize(Roles = "Admin,Manager")]
|
[HttpPost, Authorize(Roles = "Admin,Manager")]
|
||||||
|
|
@ -66,11 +66,12 @@ public async Task<ActionResult<ProductGroupDto>> Create([FromBody] ProductGroupI
|
||||||
{
|
{
|
||||||
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, IsActive = input.IsActive,
|
||||||
|
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));
|
new ProductGroupDto(e.Id, e.Name, e.ParentId, e.Path, e.SortOrder, e.IsActive, e.MarkupPercent));
|
||||||
}
|
}
|
||||||
|
|
||||||
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
|
[HttpPut("{id:guid}"), Authorize(Roles = "Admin,Manager")]
|
||||||
|
|
@ -85,6 +86,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductGroupInput in
|
||||||
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.IsActive = input.IsActive;
|
||||||
|
e.MarkupPercent = input.MarkupPercent;
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
return NoContent();
|
return NoContent();
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -117,8 +117,8 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
|
||||||
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 (isActive is not null) q = q.Where(p => p.IsActive == isActive);
|
||||||
if (purchasePriceFrom is not null) q = q.Where(p => p.PurchasePrice >= purchasePriceFrom);
|
if (purchasePriceFrom is not null) q = q.Where(p => p.ReferencePrice >= purchasePriceFrom);
|
||||||
if (purchasePriceTo is not null) q = q.Where(p => p.PurchasePrice <= purchasePriceTo);
|
if (purchasePriceTo is not null) q = q.Where(p => p.ReferencePrice <= purchasePriceTo);
|
||||||
|
|
||||||
if (!string.IsNullOrWhiteSpace(req.Search))
|
if (!string.IsNullOrWhiteSpace(req.Search))
|
||||||
{
|
{
|
||||||
|
|
@ -140,8 +140,8 @@ private async Task<decimal> ResolveDefaultVatAsync(CancellationToken ct)
|
||||||
("unit", true) => q.OrderByDescending(p => p.UnitOfMeasure!.Name).ThenBy(p => p.Name),
|
("unit", true) => q.OrderByDescending(p => p.UnitOfMeasure!.Name).ThenBy(p => p.Name),
|
||||||
("packaging", false) => q.OrderBy(p => p.Packaging).ThenBy(p => p.Name),
|
("packaging", false) => q.OrderBy(p => p.Packaging).ThenBy(p => p.Name),
|
||||||
("packaging", true) => q.OrderByDescending(p => p.Packaging).ThenBy(p => p.Name),
|
("packaging", true) => q.OrderByDescending(p => p.Packaging).ThenBy(p => p.Name),
|
||||||
("purchasePrice", false) => q.OrderBy(p => p.PurchasePrice).ThenBy(p => p.Name),
|
("purchasePrice", false) => q.OrderBy(p => p.ReferencePrice).ThenBy(p => p.Name),
|
||||||
("purchasePrice", true) => q.OrderByDescending(p => p.PurchasePrice).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", false) => q.OrderBy(p => p.IsActive).ThenBy(p => p.Name),
|
||||||
|
|
@ -174,7 +174,7 @@ public async Task<ActionResult<ProductDto>> Create([FromBody] ProductInput input
|
||||||
var allowFractional = await AllowFractionalAsync(ct);
|
var allowFractional = await AllowFractionalAsync(ct);
|
||||||
var e = new Product();
|
var e = new Product();
|
||||||
Apply(e, input);
|
Apply(e, input);
|
||||||
e.PurchasePrice = RoundIfNeeded(e.PurchasePrice, allowFractional);
|
e.ReferencePrice = RoundIfNeeded(e.ReferencePrice, allowFractional);
|
||||||
// Если UI скрывает поле Vat (showVatEnabledOnProduct=false) и прислал null — дефолт из страны.
|
// Если UI скрывает поле Vat (showVatEnabledOnProduct=false) и прислал null — дефолт из страны.
|
||||||
if (input.Vat is null) e.Vat = await ResolveDefaultVatAsync(ct);
|
if (input.Vat is null) e.Vat = await ResolveDefaultVatAsync(ct);
|
||||||
// Авто-артикул: если пользователь не указал — генерируем числовой.
|
// Авто-артикул: если пользователь не указал — генерируем числовой.
|
||||||
|
|
@ -214,7 +214,7 @@ public async Task<IActionResult> Update(Guid id, [FromBody] ProductInput input,
|
||||||
|
|
||||||
var allowFractional = await AllowFractionalAsync(ct);
|
var allowFractional = await AllowFractionalAsync(ct);
|
||||||
Apply(e, input);
|
Apply(e, input);
|
||||||
e.PurchasePrice = RoundIfNeeded(e.PurchasePrice, allowFractional);
|
e.ReferencePrice = RoundIfNeeded(e.ReferencePrice, allowFractional);
|
||||||
|
|
||||||
// Merge barcodes по Code: одинаковые — обновляем поля, лишние удаляем,
|
// Merge barcodes по Code: одинаковые — обновляем поля, лишние удаляем,
|
||||||
// новые добавляем. Это позволяет избежать массового DELETE+INSERT, на
|
// новые добавляем. Это позволяет избежать массового DELETE+INSERT, на
|
||||||
|
|
@ -328,7 +328,9 @@ public async Task<ActionResult<IReadOnlyList<BarcodeDuplicate>>> BarcodeDuplicat
|
||||||
p.CountryOfOriginId, p.CountryOfOrigin != null ? p.CountryOfOrigin.Name : null,
|
p.CountryOfOriginId, p.CountryOfOrigin != null ? p.CountryOfOrigin.Name : null,
|
||||||
p.IsService, p.Packaging, p.IsMarked,
|
p.IsService, p.Packaging, p.IsMarked,
|
||||||
p.MinStock, p.MaxStock,
|
p.MinStock, p.MaxStock,
|
||||||
p.PurchasePrice, p.PurchaseCurrencyId, p.PurchaseCurrency != null ? p.PurchaseCurrency.Code : null,
|
p.ReferencePrice, p.ReferencePriceUpdatedAt,
|
||||||
|
p.PurchaseCurrencyId, p.PurchaseCurrency != null ? p.PurchaseCurrency.Code : null,
|
||||||
|
p.Cost, p.LastSupplyAt,
|
||||||
p.ImageUrl, p.IsActive,
|
p.ImageUrl, p.IsActive,
|
||||||
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());
|
||||||
|
|
@ -349,7 +351,14 @@ private static void Apply(Product e, ProductInput i)
|
||||||
e.IsMarked = i.IsMarked;
|
e.IsMarked = i.IsMarked;
|
||||||
e.MinStock = i.MinStock;
|
e.MinStock = i.MinStock;
|
||||||
e.MaxStock = i.MaxStock;
|
e.MaxStock = i.MaxStock;
|
||||||
e.PurchasePrice = i.PurchasePrice;
|
// ReferencePriceUpdatedAt подбиваем только при реальной смене цены
|
||||||
|
// (включая переход с null на значение и обратно). Этим помечаем,
|
||||||
|
// что цена «свежая», 30-дневный таймер автоперезаписи отсчитывается заново.
|
||||||
|
if (e.ReferencePrice != i.ReferencePrice)
|
||||||
|
{
|
||||||
|
e.ReferencePrice = i.ReferencePrice;
|
||||||
|
e.ReferencePriceUpdatedAt = i.ReferencePrice.HasValue ? DateTime.UtcNow : null;
|
||||||
|
}
|
||||||
e.PurchaseCurrencyId = i.PurchaseCurrencyId;
|
e.PurchaseCurrencyId = i.PurchaseCurrencyId;
|
||||||
e.ImageUrl = i.ImageUrl;
|
e.ImageUrl = i.ImageUrl;
|
||||||
e.IsActive = i.IsActive;
|
e.IsActive = i.IsActive;
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,9 @@ public record OrgSettingsDto(
|
||||||
bool ShowServiceOnProduct,
|
bool ShowServiceOnProduct,
|
||||||
bool ShowMarkedOnProduct,
|
bool ShowMarkedOnProduct,
|
||||||
bool ShowMinMaxStock,
|
bool ShowMinMaxStock,
|
||||||
bool AllowFractionalPrices);
|
bool AllowFractionalPrices,
|
||||||
|
bool MultiplePriceTypesEnabled,
|
||||||
|
bool ShowReferencePriceOnProduct);
|
||||||
|
|
||||||
// DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId).
|
// DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId).
|
||||||
public record OrgSettingsInput(
|
public record OrgSettingsInput(
|
||||||
|
|
@ -45,7 +47,9 @@ public record OrgSettingsInput(
|
||||||
bool ShowServiceOnProduct,
|
bool ShowServiceOnProduct,
|
||||||
bool ShowMarkedOnProduct,
|
bool ShowMarkedOnProduct,
|
||||||
bool ShowMinMaxStock,
|
bool ShowMinMaxStock,
|
||||||
bool AllowFractionalPrices);
|
bool AllowFractionalPrices,
|
||||||
|
bool MultiplePriceTypesEnabled,
|
||||||
|
bool ShowReferencePriceOnProduct);
|
||||||
|
|
||||||
[HttpGet("settings")]
|
[HttpGet("settings")]
|
||||||
public async Task<ActionResult<OrgSettingsDto>> Get(CancellationToken ct)
|
public async Task<ActionResult<OrgSettingsDto>> Get(CancellationToken ct)
|
||||||
|
|
@ -81,6 +85,8 @@ 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;
|
||||||
await _db.SaveChangesAsync(ct);
|
await _db.SaveChangesAsync(ct);
|
||||||
|
|
||||||
await _db.Entry(o).Reference(x => x.DefaultCurrency).LoadAsync(ct);
|
await _db.Entry(o).Reference(x => x.DefaultCurrency).LoadAsync(ct);
|
||||||
|
|
@ -108,5 +114,7 @@ private async Task<decimal> ReadVatRateAsync(string countryCode, CancellationTok
|
||||||
o.ShowServiceOnProduct,
|
o.ShowServiceOnProduct,
|
||||||
o.ShowMarkedOnProduct,
|
o.ShowMarkedOnProduct,
|
||||||
o.ShowMinMaxStock,
|
o.ShowMinMaxStock,
|
||||||
o.AllowFractionalPrices);
|
o.AllowFractionalPrices,
|
||||||
|
o.MultiplePriceTypesEnabled,
|
||||||
|
o.ShowReferencePriceOnProduct);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -167,7 +167,7 @@ Guid AddGroup(string name, Guid? parentId)
|
||||||
CountryOfOriginId = d.Country,
|
CountryOfOriginId = d.Country,
|
||||||
Packaging = d.IsWeighed ? Packaging.Weight : Packaging.Piece,
|
Packaging = d.IsWeighed ? Packaging.Weight : Packaging.Piece,
|
||||||
IsActive = true,
|
IsActive = true,
|
||||||
PurchasePrice = Math.Round(d.RetailPrice * 0.72m, 2),
|
ReferencePrice = Math.Round(d.RetailPrice * 0.72m, 2),
|
||||||
PurchaseCurrencyId = kzt.Id,
|
PurchaseCurrencyId = kzt.Id,
|
||||||
Prices =
|
Prices =
|
||||||
[
|
[
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,8 @@ 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, bool IsActive,
|
||||||
|
decimal? MarkupPercent);
|
||||||
|
|
||||||
public record CounterpartyDto(
|
public record CounterpartyDto(
|
||||||
Guid Id, string Name, string? LegalName, CounterpartyType Type,
|
Guid Id, string Name, string? LegalName, CounterpartyType Type,
|
||||||
|
|
@ -47,7 +48,9 @@ public record ProductDto(
|
||||||
Guid? CountryOfOriginId, string? CountryOfOriginName,
|
Guid? CountryOfOriginId, string? CountryOfOriginName,
|
||||||
bool IsService, Packaging Packaging, bool IsMarked,
|
bool IsService, Packaging Packaging, bool IsMarked,
|
||||||
decimal? MinStock, decimal? MaxStock,
|
decimal? MinStock, decimal? MaxStock,
|
||||||
decimal? PurchasePrice, Guid? PurchaseCurrencyId, string? PurchaseCurrencyCode,
|
decimal? ReferencePrice, DateTime? ReferencePriceUpdatedAt,
|
||||||
|
Guid? PurchaseCurrencyId, string? PurchaseCurrencyCode,
|
||||||
|
decimal Cost, DateTime? LastSupplyAt,
|
||||||
string? ImageUrl, bool IsActive,
|
string? ImageUrl, bool IsActive,
|
||||||
IReadOnlyList<ProductPriceDto> Prices,
|
IReadOnlyList<ProductPriceDto> Prices,
|
||||||
IReadOnlyList<ProductBarcodeDto> Barcodes);
|
IReadOnlyList<ProductBarcodeDto> Barcodes);
|
||||||
|
|
@ -67,7 +70,9 @@ public record RetailPointInput(
|
||||||
string Name, string? Code, Guid StoreId,
|
string Name, string? Code, Guid StoreId,
|
||||||
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(string Name, Guid? ParentId, int SortOrder = 0, bool IsActive = true);
|
public record ProductGroupInput(
|
||||||
|
string Name, Guid? ParentId, int SortOrder = 0, bool IsActive = true,
|
||||||
|
[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,
|
||||||
|
|
@ -81,7 +86,7 @@ public record ProductInput(
|
||||||
Guid ProductGroupId, Guid? DefaultSupplierId, Guid? CountryOfOriginId,
|
Guid ProductGroupId, Guid? DefaultSupplierId, Guid? CountryOfOriginId,
|
||||||
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? PurchasePrice = null, Guid? PurchaseCurrencyId = null,
|
[Range(0, 1e10)] decimal? ReferencePrice = null, Guid? PurchaseCurrencyId = null,
|
||||||
string? ImageUrl = null, bool IsActive = true,
|
string? ImageUrl = null, bool IsActive = true,
|
||||||
IReadOnlyList<ProductPriceInput>? Prices = null,
|
IReadOnlyList<ProductPriceInput>? Prices = null,
|
||||||
IReadOnlyList<ProductBarcodeInput>? Barcodes = null);
|
IReadOnlyList<ProductBarcodeInput>? Barcodes = null);
|
||||||
|
|
|
||||||
|
|
@ -36,10 +36,23 @@ public class Product : TenantEntity
|
||||||
public decimal? MinStock { get; set; } // минимальный остаток (для уведомлений)
|
public decimal? MinStock { get; set; } // минимальный остаток (для уведомлений)
|
||||||
public decimal? MaxStock { get; set; } // максимальный остаток (для автозаказа)
|
public decimal? MaxStock { get; set; } // максимальный остаток (для автозаказа)
|
||||||
|
|
||||||
public decimal? PurchasePrice { get; set; } // закупочная цена по умолчанию
|
/// <summary>«Эталонная» (справочная) цена закупа. Не обязательная.
|
||||||
|
/// Автоматически заполняется UnitPrice'ом первой проведённой приёмки.
|
||||||
|
/// Через 30 дней без новых приёмок Hangfire-job переписывает на текущую Cost.</summary>
|
||||||
|
public decimal? ReferencePrice { get; set; }
|
||||||
|
public DateTime? ReferencePriceUpdatedAt { get; set; }
|
||||||
public Guid? PurchaseCurrencyId { get; set; }
|
public Guid? PurchaseCurrencyId { get; set; }
|
||||||
public Currency? PurchaseCurrency { get; set; }
|
public Currency? PurchaseCurrency { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Себестоимость по скользящему среднему. Пересчитывается на каждой
|
||||||
|
/// проведённой приёмке: (qty_old * cost_old + qty_in * price_in) / (qty_old + qty_in).
|
||||||
|
/// Хранится с 4 знаками для точности; UI показывает 2.</summary>
|
||||||
|
public decimal Cost { get; set; }
|
||||||
|
|
||||||
|
/// <summary>UTC-метка последней проведённой приёмки. Используется
|
||||||
|
/// 30-дневной job для перезаписи ReferencePrice на текущую Cost.</summary>
|
||||||
|
public DateTime? LastSupplyAt { get; set; }
|
||||||
|
|
||||||
public string? ImageUrl { get; set; } // основное изображение (остальные в ProductImage)
|
public string? ImageUrl { get; set; } // основное изображение (остальные в ProductImage)
|
||||||
public bool IsActive { get; set; } = true;
|
public bool IsActive { get; set; } = true;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -12,4 +12,8 @@ public class ProductGroup : TenantEntity
|
||||||
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;
|
public bool IsActive { get; set; } = true;
|
||||||
|
|
||||||
|
/// <summary>Процент наценки на себестоимость для автоматического расчёта
|
||||||
|
/// розничной цены при проведении приёмки. NULL = автонаценка отключена.</summary>
|
||||||
|
public decimal? MarkupPercent { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -53,4 +53,13 @@ public class Organization : Entity
|
||||||
/// иначе шаг 1 и значения округляются до целого даже при попытке прислать
|
/// иначе шаг 1 и значения округляются до целого даже при попытке прислать
|
||||||
/// дробное через 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>Показывать ли в карточке товара поле «Эталонная цена».
|
||||||
|
/// Default: true.</summary>
|
||||||
|
public bool ShowReferencePriceOnProduct { get; set; } = true;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,4 +52,13 @@ public class SupplyLine : TenantEntity
|
||||||
public decimal LineTotal { get; set; }
|
public decimal LineTotal { get; set; }
|
||||||
|
|
||||||
public int SortOrder { get; set; }
|
public int SortOrder { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Если true — пользователь вручную задал розничную цену для
|
||||||
|
/// этой строки (через UI приёмки). При Posting автонаценка по Group.MarkupPercent
|
||||||
|
/// для этой строки пропускается.</summary>
|
||||||
|
public bool RetailPriceManuallyOverridden { get; set; }
|
||||||
|
|
||||||
|
/// <summary>Розничная цена, которую пользователь вписал в колонке «Розничная»
|
||||||
|
/// строки приёмки. Применяется к Product.Prices[default] при Posting.</summary>
|
||||||
|
public decimal? RetailPriceOverride { get; set; }
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -263,7 +263,7 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
|
||||||
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.IsActive = !p.Archived;
|
||||||
product.PurchasePrice = p.BuyPrice is null ? product.PurchasePrice : 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;
|
||||||
}
|
}
|
||||||
|
|
@ -283,7 +283,7 @@ await foreach (var p in _client.StreamProductsAsync(token, ct))
|
||||||
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,
|
IsActive = !p.Archived,
|
||||||
PurchasePrice = 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,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -118,7 +118,8 @@ private static void ConfigureProduct(EntityTypeBuilder<Product> b)
|
||||||
b.Property(x => x.Article).HasMaxLength(500);
|
b.Property(x => x.Article).HasMaxLength(500);
|
||||||
b.Property(x => x.MinStock).HasPrecision(18, 4);
|
b.Property(x => x.MinStock).HasPrecision(18, 4);
|
||||||
b.Property(x => x.MaxStock).HasPrecision(18, 4);
|
b.Property(x => x.MaxStock).HasPrecision(18, 4);
|
||||||
b.Property(x => x.PurchasePrice).HasPrecision(18, 4);
|
b.Property(x => x.ReferencePrice).HasPrecision(18, 4);
|
||||||
|
b.Property(x => x.Cost).HasPrecision(18, 4);
|
||||||
b.Property(x => x.ImageUrl).HasMaxLength(1000);
|
b.Property(x => x.ImageUrl).HasMaxLength(1000);
|
||||||
|
|
||||||
// VatEnabled defaults to true в БД — при миграции existing rows сохраняют true.
|
// VatEnabled defaults to true в БД — при миграции existing rows сохраняют true.
|
||||||
|
|
|
||||||
1915
src/food-market.infrastructure/Persistence/Migrations/20260425110000_Phase3a_PricingModel.Designer.cs
generated
Normal file
1915
src/food-market.infrastructure/Persistence/Migrations/20260425110000_Phase3a_PricingModel.Designer.cs
generated
Normal file
File diff suppressed because it is too large
Load diff
|
|
@ -0,0 +1,70 @@
|
||||||
|
using Microsoft.EntityFrameworkCore.Migrations;
|
||||||
|
|
||||||
|
#nullable disable
|
||||||
|
|
||||||
|
namespace foodmarket.Infrastructure.Persistence.Migrations
|
||||||
|
{
|
||||||
|
/// <summary>Новая модель цен:
|
||||||
|
/// - products.PurchasePrice → ReferencePrice (справочная цена закупа)
|
||||||
|
/// - products.+ ReferencePriceUpdatedAt timestamptz NULL
|
||||||
|
/// - products.+ Cost numeric(18,4) NOT NULL DEFAULT 0 (себестоимость, скользящее среднее)
|
||||||
|
/// - products.+ LastSupplyAt timestamptz NULL
|
||||||
|
/// - product_groups.+ MarkupPercent numeric(5,2) NULL (% наценки на cost для авто-розничной)
|
||||||
|
/// - organizations.+ MultiplePriceTypesEnabled boolean DEFAULT false
|
||||||
|
/// - organizations.+ ShowReferencePriceOnProduct boolean DEFAULT true
|
||||||
|
/// - supply_lines.+ RetailPriceManuallyOverridden boolean DEFAULT false
|
||||||
|
/// - supply_lines.+ RetailPriceOverride numeric(18,2) NULL
|
||||||
|
/// </summary>
|
||||||
|
public partial class Phase3a_PricingModel : Migration
|
||||||
|
{
|
||||||
|
protected override void Up(MigrationBuilder b)
|
||||||
|
{
|
||||||
|
b.RenameColumn(name: "PurchasePrice", schema: "public", table: "products", newName: "ReferencePrice");
|
||||||
|
|
||||||
|
b.AddColumn<System.DateTime>(
|
||||||
|
name: "ReferencePriceUpdatedAt", schema: "public", table: "products",
|
||||||
|
type: "timestamp with time zone", nullable: true);
|
||||||
|
|
||||||
|
b.AddColumn<decimal>(
|
||||||
|
name: "Cost", schema: "public", table: "products",
|
||||||
|
type: "numeric(18,4)", precision: 18, scale: 4, nullable: false, defaultValue: 0m);
|
||||||
|
|
||||||
|
b.AddColumn<System.DateTime>(
|
||||||
|
name: "LastSupplyAt", schema: "public", table: "products",
|
||||||
|
type: "timestamp with time zone", nullable: true);
|
||||||
|
|
||||||
|
b.AddColumn<decimal>(
|
||||||
|
name: "MarkupPercent", schema: "public", table: "product_groups",
|
||||||
|
type: "numeric(5,2)", precision: 5, scale: 2, nullable: true);
|
||||||
|
|
||||||
|
b.AddColumn<bool>(
|
||||||
|
name: "MultiplePriceTypesEnabled", schema: "public", table: "organizations",
|
||||||
|
type: "boolean", nullable: false, defaultValue: false);
|
||||||
|
|
||||||
|
b.AddColumn<bool>(
|
||||||
|
name: "ShowReferencePriceOnProduct", schema: "public", table: "organizations",
|
||||||
|
type: "boolean", nullable: false, defaultValue: true);
|
||||||
|
|
||||||
|
b.AddColumn<bool>(
|
||||||
|
name: "RetailPriceManuallyOverridden", schema: "public", table: "supply_lines",
|
||||||
|
type: "boolean", nullable: false, defaultValue: false);
|
||||||
|
|
||||||
|
b.AddColumn<decimal>(
|
||||||
|
name: "RetailPriceOverride", schema: "public", table: "supply_lines",
|
||||||
|
type: "numeric(18,2)", precision: 18, scale: 2, nullable: true);
|
||||||
|
}
|
||||||
|
|
||||||
|
protected override void Down(MigrationBuilder b)
|
||||||
|
{
|
||||||
|
b.DropColumn(name: "RetailPriceOverride", schema: "public", table: "supply_lines");
|
||||||
|
b.DropColumn(name: "RetailPriceManuallyOverridden", schema: "public", table: "supply_lines");
|
||||||
|
b.DropColumn(name: "ShowReferencePriceOnProduct", schema: "public", table: "organizations");
|
||||||
|
b.DropColumn(name: "MultiplePriceTypesEnabled", schema: "public", table: "organizations");
|
||||||
|
b.DropColumn(name: "MarkupPercent", schema: "public", table: "product_groups");
|
||||||
|
b.DropColumn(name: "LastSupplyAt", schema: "public", table: "products");
|
||||||
|
b.DropColumn(name: "Cost", schema: "public", table: "products");
|
||||||
|
b.DropColumn(name: "ReferencePriceUpdatedAt", schema: "public", table: "products");
|
||||||
|
b.RenameColumn(name: "ReferencePrice", schema: "public", table: "products", newName: "PurchasePrice");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -594,13 +594,23 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
b.Property<Guid>("ProductGroupId")
|
b.Property<Guid>("ProductGroupId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
b.Property<decimal>("Cost")
|
||||||
|
.HasPrecision(18, 4)
|
||||||
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("LastSupplyAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.Property<Guid?>("PurchaseCurrencyId")
|
b.Property<Guid?>("PurchaseCurrencyId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
b.Property<decimal?>("PurchasePrice")
|
b.Property<decimal?>("ReferencePrice")
|
||||||
.HasPrecision(18, 4)
|
.HasPrecision(18, 4)
|
||||||
.HasColumnType("numeric(18,4)");
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<DateTime?>("ReferencePriceUpdatedAt")
|
||||||
|
.HasColumnType("timestamp with time zone");
|
||||||
|
|
||||||
b.Property<Guid>("UnitOfMeasureId")
|
b.Property<Guid>("UnitOfMeasureId")
|
||||||
.HasColumnType("uuid");
|
.HasColumnType("uuid");
|
||||||
|
|
||||||
|
|
@ -690,6 +700,10 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
b.Property<bool>("IsActive")
|
b.Property<bool>("IsActive")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<decimal?>("MarkupPercent")
|
||||||
|
.HasPrecision(5, 2)
|
||||||
|
.HasColumnType("numeric(5,2)");
|
||||||
|
|
||||||
b.Property<string>("Name")
|
b.Property<string>("Name")
|
||||||
.IsRequired()
|
.IsRequired()
|
||||||
.HasMaxLength(200)
|
.HasMaxLength(200)
|
||||||
|
|
@ -1093,9 +1107,15 @@ 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");
|
||||||
|
|
||||||
|
b.Property<bool>("ShowReferencePriceOnProduct")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
b.Property<bool>("ShowMinMaxStock")
|
b.Property<bool>("ShowMinMaxStock")
|
||||||
.HasColumnType("boolean");
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
|
@ -1224,6 +1244,13 @@ protected override void BuildModel(ModelBuilder modelBuilder)
|
||||||
.HasPrecision(18, 4)
|
.HasPrecision(18, 4)
|
||||||
.HasColumnType("numeric(18,4)");
|
.HasColumnType("numeric(18,4)");
|
||||||
|
|
||||||
|
b.Property<bool>("RetailPriceManuallyOverridden")
|
||||||
|
.HasColumnType("boolean");
|
||||||
|
|
||||||
|
b.Property<decimal?>("RetailPriceOverride")
|
||||||
|
.HasPrecision(18, 2)
|
||||||
|
.HasColumnType("numeric(18,2)");
|
||||||
|
|
||||||
b.Property<int>("SortOrder")
|
b.Property<int>("SortOrder")
|
||||||
.HasColumnType("integer");
|
.HasColumnType("integer");
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -74,9 +74,9 @@ export function ProductPicker({ open, onClose, onPick, title = 'Выбор то
|
||||||
<span>· {p.unitName}</span>
|
<span>· {p.unitName}</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{p.purchasePrice !== null && (
|
{p.referencePrice !== null && (
|
||||||
<div className="text-xs text-slate-500 font-mono flex-shrink-0">
|
<div className="text-xs text-slate-500 font-mono flex-shrink-0">
|
||||||
закуп: {p.purchasePrice.toLocaleString('ru')} {p.purchaseCurrencyCode ?? ''}
|
закуп: {p.referencePrice.toLocaleString('ru')} {p.purchaseCurrencyCode ?? ''}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
|
||||||
|
|
@ -38,7 +38,7 @@ 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 }
|
export interface ProductGroup { id: string; name: string; parentId: string | null; path: string; sortOrder: number; isActive: boolean; 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;
|
||||||
|
|
@ -57,7 +57,9 @@ export interface Product {
|
||||||
countryOfOriginId: string | null; countryOfOriginName: string | null;
|
countryOfOriginId: string | null; countryOfOriginName: string | null;
|
||||||
isService: boolean; packaging: Packaging; isMarked: boolean;
|
isService: boolean; packaging: Packaging; isMarked: boolean;
|
||||||
minStock: number | null; maxStock: number | null;
|
minStock: number | null; maxStock: number | null;
|
||||||
purchasePrice: number | null; purchaseCurrencyId: string | null; purchaseCurrencyCode: string | null;
|
referencePrice: number | null; referencePriceUpdatedAt: string | null;
|
||||||
|
purchaseCurrencyId: string | null; purchaseCurrencyCode: string | null;
|
||||||
|
cost: number; lastSupplyAt: string | null;
|
||||||
imageUrl: string | null; isActive: boolean;
|
imageUrl: string | null; isActive: boolean;
|
||||||
prices: ProductPrice[]; barcodes: ProductBarcode[]
|
prices: ProductPrice[]; barcodes: ProductBarcode[]
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,7 @@ interface Form {
|
||||||
isActive: boolean
|
isActive: boolean
|
||||||
minStock: string
|
minStock: string
|
||||||
maxStock: string
|
maxStock: string
|
||||||
purchasePrice: string
|
referencePrice: string
|
||||||
purchaseCurrencyId: string
|
purchaseCurrencyId: string
|
||||||
imageUrl: string
|
imageUrl: string
|
||||||
prices: PriceRow[]
|
prices: PriceRow[]
|
||||||
|
|
@ -46,7 +46,7 @@ const emptyForm: Form = {
|
||||||
productGroupId: '', defaultSupplierId: '', countryOfOriginId: '',
|
productGroupId: '', defaultSupplierId: '', countryOfOriginId: '',
|
||||||
isService: false, packaging: Packaging.Piece, isMarked: false, isActive: true,
|
isService: false, packaging: Packaging.Piece, isMarked: false, isActive: true,
|
||||||
minStock: '', maxStock: '',
|
minStock: '', maxStock: '',
|
||||||
purchasePrice: '', purchaseCurrencyId: '',
|
referencePrice: '', purchaseCurrencyId: '',
|
||||||
imageUrl: '',
|
imageUrl: '',
|
||||||
prices: [],
|
prices: [],
|
||||||
barcodes: [],
|
barcodes: [],
|
||||||
|
|
@ -87,7 +87,7 @@ export function ProductEditPage() {
|
||||||
isActive: p.isActive,
|
isActive: p.isActive,
|
||||||
minStock: p.minStock?.toString() ?? '',
|
minStock: p.minStock?.toString() ?? '',
|
||||||
maxStock: p.maxStock?.toString() ?? '',
|
maxStock: p.maxStock?.toString() ?? '',
|
||||||
purchasePrice: p.purchasePrice?.toString() ?? '',
|
referencePrice: p.referencePrice?.toString() ?? '',
|
||||||
purchaseCurrencyId: p.purchaseCurrencyId ?? '',
|
purchaseCurrencyId: p.purchaseCurrencyId ?? '',
|
||||||
imageUrl: p.imageUrl ?? '',
|
imageUrl: p.imageUrl ?? '',
|
||||||
prices: p.prices.map((x) => ({ id: x.id, priceTypeId: x.priceTypeId, amount: x.amount, currencyId: x.currencyId })),
|
prices: p.prices.map((x) => ({ id: x.id, priceTypeId: x.priceTypeId, amount: x.amount, currencyId: x.currencyId })),
|
||||||
|
|
@ -147,7 +147,7 @@ export function ProductEditPage() {
|
||||||
isActive: form.isActive,
|
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),
|
||||||
purchasePrice: form.purchasePrice === '' ? null : Number(form.purchasePrice),
|
referencePrice: form.referencePrice === '' ? null : Number(form.referencePrice),
|
||||||
purchaseCurrencyId: form.purchaseCurrencyId || null,
|
purchaseCurrencyId: form.purchaseCurrencyId || null,
|
||||||
imageUrl: form.imageUrl || null,
|
imageUrl: form.imageUrl || null,
|
||||||
prices: form.prices.map((p) => ({ priceTypeId: p.priceTypeId, amount: Number(p.amount), currencyId: p.currencyId })),
|
prices: form.prices.map((p) => ({ priceTypeId: p.priceTypeId, amount: Number(p.amount), currencyId: p.currencyId })),
|
||||||
|
|
@ -317,10 +317,10 @@ export function ProductEditPage() {
|
||||||
|
|
||||||
<Section title="Закупка">
|
<Section title="Закупка">
|
||||||
<Grid cols={4}>
|
<Grid cols={4}>
|
||||||
<Field label="Закупочная цена">
|
<Field label="Эталонная цена">
|
||||||
<MoneyInput
|
<MoneyInput
|
||||||
value={form.purchasePrice === '' ? null : Number(form.purchasePrice)}
|
value={form.referencePrice === '' ? null : Number(form.referencePrice)}
|
||||||
onChange={(n) => setForm({ ...form, purchasePrice: n == null ? '' : String(n) })}
|
onChange={(n) => setForm({ ...form, referencePrice: n == null ? '' : String(n) })}
|
||||||
currencyCode={currencies.data?.find((c) => c.id === form.purchaseCurrencyId)?.code ?? org.data?.defaultCurrencyCode ?? undefined}
|
currencyCode={currencies.data?.find((c) => c.id === form.purchaseCurrencyId)?.code ?? org.data?.defaultCurrencyCode ?? undefined}
|
||||||
currencySymbol={currencies.data?.find((c) => c.id === form.purchaseCurrencyId)?.symbol ?? org.data?.defaultCurrencySymbol ?? undefined}
|
currencySymbol={currencies.data?.find((c) => c.id === form.purchaseCurrencyId)?.symbol ?? org.data?.defaultCurrencySymbol ?? undefined}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -21,8 +21,8 @@ interface Filters {
|
||||||
isService: TriFilter
|
isService: TriFilter
|
||||||
packaging: number | null // null = все, 1/2/3 = Piece/Weight/Liquid
|
packaging: number | null // null = все, 1/2/3 = Piece/Weight/Liquid
|
||||||
isMarked: TriFilter
|
isMarked: TriFilter
|
||||||
purchasePriceFrom: number | null
|
referencePriceFrom: number | null
|
||||||
purchasePriceTo: number | null
|
referencePriceTo: number | null
|
||||||
}
|
}
|
||||||
|
|
||||||
const defaultFilters: Filters = {
|
const defaultFilters: Filters = {
|
||||||
|
|
@ -31,8 +31,8 @@ const defaultFilters: Filters = {
|
||||||
isService: 'all',
|
isService: 'all',
|
||||||
packaging: null,
|
packaging: null,
|
||||||
isMarked: 'all',
|
isMarked: 'all',
|
||||||
purchasePriceFrom: null,
|
referencePriceFrom: null,
|
||||||
purchasePriceTo: null,
|
referencePriceTo: null,
|
||||||
}
|
}
|
||||||
|
|
||||||
const toExtra = (f: Filters): Record<string, string | number | boolean | undefined> => {
|
const toExtra = (f: Filters): Record<string, string | number | boolean | undefined> => {
|
||||||
|
|
@ -42,8 +42,8 @@ const toExtra = (f: Filters): Record<string, string | number | boolean | undefin
|
||||||
if (f.isService !== 'all') e.isService = f.isService === 'yes'
|
if (f.isService !== 'all') e.isService = f.isService === 'yes'
|
||||||
if (f.packaging) e.packaging = f.packaging
|
if (f.packaging) e.packaging = f.packaging
|
||||||
if (f.isMarked !== 'all') e.isMarked = f.isMarked === 'yes'
|
if (f.isMarked !== 'all') e.isMarked = f.isMarked === 'yes'
|
||||||
if (f.purchasePriceFrom != null) e.purchasePriceFrom = f.purchasePriceFrom
|
if (f.referencePriceFrom != null) e.referencePriceFrom = f.referencePriceFrom
|
||||||
if (f.purchasePriceTo != null) e.purchasePriceTo = f.purchasePriceTo
|
if (f.referencePriceTo != null) e.referencePriceTo = f.referencePriceTo
|
||||||
return e
|
return e
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -54,8 +54,8 @@ const activeFilterCount = (f: Filters) => {
|
||||||
if (f.isService !== 'all') n++
|
if (f.isService !== 'all') n++
|
||||||
if (f.packaging) n++
|
if (f.packaging) n++
|
||||||
if (f.isMarked !== 'all') n++
|
if (f.isMarked !== 'all') n++
|
||||||
if (f.purchasePriceFrom != null) n++
|
if (f.referencePriceFrom != null) n++
|
||||||
if (f.purchasePriceTo != null) n++
|
if (f.referencePriceTo != null) n++
|
||||||
return n
|
return n
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -127,10 +127,10 @@ export function ProductsPage() {
|
||||||
{ header: 'Штрихкод', width: '160px', cell: (r) => (
|
{ header: 'Штрихкод', width: '160px', cell: (r) => (
|
||||||
<span className="font-mono">{r.barcodes[0]?.code ?? '—'}</span>
|
<span className="font-mono">{r.barcodes[0]?.code ?? '—'}</span>
|
||||||
)},
|
)},
|
||||||
{ header: 'Закупочная цена', width: '160px', className: 'text-right font-mono', sortKey: 'purchasePrice', cell: (r) => {
|
{ header: 'Эталонная цена', width: '160px', className: 'text-right font-mono', sortKey: 'referencePrice', cell: (r) => {
|
||||||
if (r.purchasePrice == null) return '—'
|
if (r.referencePrice == null) return '—'
|
||||||
const fractional = org.data?.allowFractionalPrices ?? false
|
const fractional = org.data?.allowFractionalPrices ?? false
|
||||||
const num = r.purchasePrice.toLocaleString('ru',
|
const num = r.referencePrice.toLocaleString('ru',
|
||||||
fractional
|
fractional
|
||||||
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
|
||||||
: { maximumFractionDigits: 0 })
|
: { maximumFractionDigits: 0 })
|
||||||
|
|
@ -226,8 +226,8 @@ export function ProductsPage() {
|
||||||
<span className="text-slate-500">Закупочная цена</span>
|
<span className="text-slate-500">Закупочная цена</span>
|
||||||
<div className="w-32">
|
<div className="w-32">
|
||||||
<MoneyInput
|
<MoneyInput
|
||||||
value={filters.purchasePriceFrom}
|
value={filters.referencePriceFrom}
|
||||||
onChange={(n) => { setFilters({ ...filters, purchasePriceFrom: n }); setPage(1) }}
|
onChange={(n) => { setFilters({ ...filters, referencePriceFrom: n }); setPage(1) }}
|
||||||
currencyCode={org.data?.defaultCurrencyCode ?? undefined}
|
currencyCode={org.data?.defaultCurrencyCode ?? undefined}
|
||||||
currencySymbol={org.data?.defaultCurrencySymbol ?? undefined}
|
currencySymbol={org.data?.defaultCurrencySymbol ?? undefined}
|
||||||
|
|
||||||
|
|
@ -236,8 +236,8 @@ export function ProductsPage() {
|
||||||
</div>
|
</div>
|
||||||
<div className="w-32">
|
<div className="w-32">
|
||||||
<MoneyInput
|
<MoneyInput
|
||||||
value={filters.purchasePriceTo}
|
value={filters.referencePriceTo}
|
||||||
onChange={(n) => { setFilters({ ...filters, purchasePriceTo: n }); setPage(1) }}
|
onChange={(n) => { setFilters({ ...filters, referencePriceTo: n }); setPage(1) }}
|
||||||
currencyCode={org.data?.defaultCurrencyCode ?? undefined}
|
currencyCode={org.data?.defaultCurrencyCode ?? undefined}
|
||||||
currencySymbol={org.data?.defaultCurrencySymbol ?? undefined}
|
currencySymbol={org.data?.defaultCurrencySymbol ?? undefined}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -175,7 +175,7 @@ export function SupplyEditPage() {
|
||||||
productArticle: p.article,
|
productArticle: p.article,
|
||||||
unitName: p.unitName,
|
unitName: p.unitName,
|
||||||
quantity: 1,
|
quantity: 1,
|
||||||
unitPrice: p.purchasePrice ?? 0,
|
unitPrice: p.referencePrice ?? 0,
|
||||||
}],
|
}],
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue