feat(domain): pricing model rename and new fields (Phase3a)

Подготовка к новой модели цен сторонняя система-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/контроллеры/OtherSystem-импорт/UI поля переименованы в referencePrice
(включая фильтры списка товаров). UI-логика следующего коммита будет
показывать Cost и кнопку «привести розничную к себестоимости»; пока
referencePrice работает как раньше.

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
nns 2026-04-25 20:59:09 +05:00
parent b8fd5ec2bd
commit 6acf6b7c03
19 changed files with 2125 additions and 51 deletions

View file

@ -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();
} }

View file

@ -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;

View file

@ -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);
} }

View file

@ -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 =
[ [

View file

@ -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);

View file

@ -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;

View file

@ -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; }
} }

View file

@ -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;
} }

View file

@ -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; }
} }

View file

@ -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,
}; };

View file

@ -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.

View file

@ -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");
}
}
}

View file

@ -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");

View file

@ -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>

View file

@ -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[]
} }

View file

@ -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}

View file

@ -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}

View file

@ -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,
}], }],
}) })
} }