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

Подготовка к новой модели цен МойСклад-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:
nns 2026-04-25 20:59:09 +05:00
parent 453d04b7d1
commit 23d6f2bd5a
19 changed files with 2125 additions and 51 deletions

View file

@ -46,7 +46,7 @@ public class ProductGroupsController : ControllerBase
};
var items = await q
.Skip(req.Skip).Take(req.Take)
.Select(g => new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.IsActive))
.Select(g => new ProductGroupDto(g.Id, g.Name, g.ParentId, g.Path, g.SortOrder, g.IsActive, g.MarkupPercent))
.ToListAsync(ct);
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)
{
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")]
@ -66,11 +66,12 @@ public async Task<ActionResult<ProductGroupDto>> Create([FromBody] ProductGroupI
{
Name = input.Name, ParentId = input.ParentId, Path = path,
SortOrder = input.SortOrder, IsActive = input.IsActive,
MarkupPercent = input.MarkupPercent,
};
_db.ProductGroups.Add(e);
await _db.SaveChangesAsync(ct);
return CreatedAtAction(nameof(Get), new { id = e.Id },
new ProductGroupDto(e.Id, e.Name, e.ParentId, e.Path, e.SortOrder, e.IsActive));
new ProductGroupDto(e.Id, e.Name, e.ParentId, e.Path, e.SortOrder, e.IsActive, e.MarkupPercent));
}
[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.SortOrder = input.SortOrder;
e.IsActive = input.IsActive;
e.MarkupPercent = input.MarkupPercent;
await _db.SaveChangesAsync(ct);
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 (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.PurchasePrice >= purchasePriceFrom);
if (purchasePriceTo is not null) q = q.Where(p => p.PurchasePrice <= purchasePriceTo);
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 (!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),
("packaging", false) => q.OrderBy(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", true) => q.OrderByDescending(p => p.PurchasePrice).ThenBy(p => p.Name),
("purchasePrice", false) => q.OrderBy(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", true) => q.OrderByDescending(p => p.Vat).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 e = new Product();
Apply(e, input);
e.PurchasePrice = RoundIfNeeded(e.PurchasePrice, allowFractional);
e.ReferencePrice = RoundIfNeeded(e.ReferencePrice, allowFractional);
// Если UI скрывает поле Vat (showVatEnabledOnProduct=false) и прислал null — дефолт из страны.
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);
Apply(e, input);
e.PurchasePrice = RoundIfNeeded(e.PurchasePrice, allowFractional);
e.ReferencePrice = RoundIfNeeded(e.ReferencePrice, allowFractional);
// Merge barcodes по Code: одинаковые — обновляем поля, лишние удаляем,
// новые добавляем. Это позволяет избежать массового DELETE+INSERT, на
@ -328,7 +328,9 @@ public async Task<ActionResult<IReadOnlyList<BarcodeDuplicate>>> BarcodeDuplicat
p.CountryOfOriginId, p.CountryOfOrigin != null ? p.CountryOfOrigin.Name : null,
p.IsService, p.Packaging, p.IsMarked,
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.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());
@ -349,7 +351,14 @@ private static void Apply(Product e, ProductInput i)
e.IsMarked = i.IsMarked;
e.MinStock = i.MinStock;
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.ImageUrl = i.ImageUrl;
e.IsActive = i.IsActive;

View file

@ -34,7 +34,9 @@ public record OrgSettingsDto(
bool ShowServiceOnProduct,
bool ShowMarkedOnProduct,
bool ShowMinMaxStock,
bool AllowFractionalPrices);
bool AllowFractionalPrices,
bool MultiplePriceTypesEnabled,
bool ShowReferencePriceOnProduct);
// DefaultCurrencyId не принимается — он read-only, выводится из страны (Country.DefaultCurrencyId).
public record OrgSettingsInput(
@ -45,7 +47,9 @@ public record OrgSettingsInput(
bool ShowServiceOnProduct,
bool ShowMarkedOnProduct,
bool ShowMinMaxStock,
bool AllowFractionalPrices);
bool AllowFractionalPrices,
bool MultiplePriceTypesEnabled,
bool ShowReferencePriceOnProduct);
[HttpGet("settings")]
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.ShowMinMaxStock = input.ShowMinMaxStock;
o.AllowFractionalPrices = input.AllowFractionalPrices;
o.MultiplePriceTypesEnabled = input.MultiplePriceTypesEnabled;
o.ShowReferencePriceOnProduct = input.ShowReferencePriceOnProduct;
await _db.SaveChangesAsync(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.ShowMarkedOnProduct,
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,
Packaging = d.IsWeighed ? Packaging.Weight : Packaging.Piece,
IsActive = true,
PurchasePrice = Math.Round(d.RetailPrice * 0.72m, 2),
ReferencePrice = Math.Round(d.RetailPrice * 0.72m, 2),
PurchaseCurrencyId = kzt.Id,
Prices =
[

View file

@ -26,7 +26,8 @@ public record RetailPointDto(
string? Address, string? Phone, string? FiscalSerial, string? FiscalRegNumber, bool IsActive);
public record ProductGroupDto(
Guid Id, string Name, Guid? ParentId, string Path, int SortOrder, bool IsActive);
Guid Id, string Name, Guid? ParentId, string Path, int SortOrder, bool IsActive,
decimal? MarkupPercent);
public record CounterpartyDto(
Guid Id, string Name, string? LegalName, CounterpartyType Type,
@ -47,7 +48,9 @@ public record ProductDto(
Guid? CountryOfOriginId, string? CountryOfOriginName,
bool IsService, Packaging Packaging, bool IsMarked,
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,
IReadOnlyList<ProductPriceDto> Prices,
IReadOnlyList<ProductBarcodeDto> Barcodes);
@ -67,7 +70,9 @@ public record RetailPointInput(
string Name, string? Code, Guid StoreId,
string? Address = null, string? Phone = null,
string? FiscalSerial = null, string? FiscalRegNumber = null, bool IsActive = true);
public record ProductGroupInput(string Name, Guid? ParentId, int SortOrder = 0, bool IsActive = true);
public record ProductGroupInput(
string Name, Guid? ParentId, int SortOrder = 0, bool IsActive = true,
[Range(0, 1000)] decimal? MarkupPercent = null);
public record CounterpartyInput(
string Name, string? LegalName, CounterpartyType Type,
string? Bin, string? Iin, string? TaxNumber, Guid? CountryId,
@ -81,7 +86,7 @@ public record ProductInput(
Guid ProductGroupId, Guid? DefaultSupplierId, Guid? CountryOfOriginId,
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? PurchasePrice = null, Guid? PurchaseCurrencyId = null,
[Range(0, 1e10)] decimal? ReferencePrice = null, Guid? PurchaseCurrencyId = null,
string? ImageUrl = null, bool IsActive = true,
IReadOnlyList<ProductPriceInput>? Prices = null,
IReadOnlyList<ProductBarcodeInput>? Barcodes = null);

View file

@ -36,10 +36,23 @@ public class Product : TenantEntity
public decimal? MinStock { 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 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 bool IsActive { get; set; } = true;

View file

@ -12,4 +12,8 @@ public class ProductGroup : TenantEntity
public string Path { get; set; } = ""; // денормализованный путь "Электроника/Телефоны/Смартфоны" для фильтрации
public int SortOrder { get; set; }
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 и значения округляются до целого даже при попытке прислать
/// дробное через API.</summary>
public bool AllowFractionalPrices { get; set; }
/// <summary>Если true — в карточке товара рендерится список цен по всем
/// PriceType, есть страница «Настройки → Типы цен». Если false (default)
/// — одно поле «Розничная цена», работающее с дефолтным PriceType.</summary>
public bool MultiplePriceTypesEnabled { get; set; }
/// <summary>Показывать ли в карточке товара поле «Эталонная цена».
/// Default: true.</summary>
public bool ShowReferencePriceOnProduct { get; set; } = true;
}

View file

@ -52,4 +52,13 @@ public class SupplyLine : TenantEntity
public decimal LineTotal { 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.IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED";
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++;
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,
IsMarked = !string.IsNullOrEmpty(p.TrackingType) && p.TrackingType != "NOT_TRACKED",
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,
};

View file

@ -118,7 +118,8 @@ private static void ConfigureProduct(EntityTypeBuilder<Product> b)
b.Property(x => x.Article).HasMaxLength(500);
b.Property(x => x.MinStock).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);
// 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")
.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")
.HasColumnType("uuid");
b.Property<decimal?>("PurchasePrice")
b.Property<decimal?>("ReferencePrice")
.HasPrecision(18, 4)
.HasColumnType("numeric(18,4)");
b.Property<DateTime?>("ReferencePriceUpdatedAt")
.HasColumnType("timestamp with time zone");
b.Property<Guid>("UnitOfMeasureId")
.HasColumnType("uuid");
@ -690,6 +700,10 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<bool>("IsActive")
.HasColumnType("boolean");
b.Property<decimal?>("MarkupPercent")
.HasPrecision(5, 2)
.HasColumnType("numeric(5,2)");
b.Property<string>("Name")
.IsRequired()
.HasMaxLength(200)
@ -1093,9 +1107,15 @@ protected override void BuildModel(ModelBuilder modelBuilder)
b.Property<bool>("MultiCurrencyEnabled")
.HasColumnType("boolean");
b.Property<bool>("MultiplePriceTypesEnabled")
.HasColumnType("boolean");
b.Property<bool>("ShowMarkedOnProduct")
.HasColumnType("boolean");
b.Property<bool>("ShowReferencePriceOnProduct")
.HasColumnType("boolean");
b.Property<bool>("ShowMinMaxStock")
.HasColumnType("boolean");
@ -1224,6 +1244,13 @@ protected override void BuildModel(ModelBuilder modelBuilder)
.HasPrecision(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")
.HasColumnType("integer");

View file

@ -74,9 +74,9 @@ export function ProductPicker({ open, onClose, onPick, title = 'Выбор то
<span>· {p.unitName}</span>
</div>
</div>
{p.purchasePrice !== null && (
{p.referencePrice !== null && (
<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>
)}
</button>

View file

@ -38,7 +38,7 @@ export interface RetailPoint {
id: string; name: string; code: string | null; storeId: string; storeName: string | null;
address: string | null; phone: string | null; fiscalSerial: string | null; fiscalRegNumber: string | null; isActive: boolean
}
export interface ProductGroup { id: string; name: string; parentId: string | null; path: string; sortOrder: number; isActive: boolean }
export interface ProductGroup { id: string; name: string; parentId: string | null; path: string; sortOrder: number; isActive: boolean; markupPercent: number | null }
export interface Counterparty {
id: string; name: string; legalName: string | null; type: CounterpartyType;
bin: string | null; iin: string | null; taxNumber: string | null; countryId: string | null; countryName: string | null;
@ -57,7 +57,9 @@ export interface Product {
countryOfOriginId: string | null; countryOfOriginName: string | null;
isService: boolean; packaging: Packaging; isMarked: boolean;
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;
prices: ProductPrice[]; barcodes: ProductBarcode[]
}

View file

@ -32,7 +32,7 @@ interface Form {
isActive: boolean
minStock: string
maxStock: string
purchasePrice: string
referencePrice: string
purchaseCurrencyId: string
imageUrl: string
prices: PriceRow[]
@ -46,7 +46,7 @@ const emptyForm: Form = {
productGroupId: '', defaultSupplierId: '', countryOfOriginId: '',
isService: false, packaging: Packaging.Piece, isMarked: false, isActive: true,
minStock: '', maxStock: '',
purchasePrice: '', purchaseCurrencyId: '',
referencePrice: '', purchaseCurrencyId: '',
imageUrl: '',
prices: [],
barcodes: [],
@ -87,7 +87,7 @@ export function ProductEditPage() {
isActive: p.isActive,
minStock: p.minStock?.toString() ?? '',
maxStock: p.maxStock?.toString() ?? '',
purchasePrice: p.purchasePrice?.toString() ?? '',
referencePrice: p.referencePrice?.toString() ?? '',
purchaseCurrencyId: p.purchaseCurrencyId ?? '',
imageUrl: p.imageUrl ?? '',
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,
minStock: form.minStock === '' ? null : Number(form.minStock),
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,
imageUrl: form.imageUrl || null,
prices: form.prices.map((p) => ({ priceTypeId: p.priceTypeId, amount: Number(p.amount), currencyId: p.currencyId })),
@ -317,10 +317,10 @@ export function ProductEditPage() {
<Section title="Закупка">
<Grid cols={4}>
<Field label="Закупочная цена">
<Field label="Эталонная цена">
<MoneyInput
value={form.purchasePrice === '' ? null : Number(form.purchasePrice)}
onChange={(n) => setForm({ ...form, purchasePrice: n == null ? '' : String(n) })}
value={form.referencePrice === '' ? null : Number(form.referencePrice)}
onChange={(n) => setForm({ ...form, referencePrice: n == null ? '' : String(n) })}
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}

View file

@ -21,8 +21,8 @@ interface Filters {
isService: TriFilter
packaging: number | null // null = все, 1/2/3 = Piece/Weight/Liquid
isMarked: TriFilter
purchasePriceFrom: number | null
purchasePriceTo: number | null
referencePriceFrom: number | null
referencePriceTo: number | null
}
const defaultFilters: Filters = {
@ -31,8 +31,8 @@ const defaultFilters: Filters = {
isService: 'all',
packaging: null,
isMarked: 'all',
purchasePriceFrom: null,
purchasePriceTo: null,
referencePriceFrom: null,
referencePriceTo: null,
}
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.packaging) e.packaging = f.packaging
if (f.isMarked !== 'all') e.isMarked = f.isMarked === 'yes'
if (f.purchasePriceFrom != null) e.purchasePriceFrom = f.purchasePriceFrom
if (f.purchasePriceTo != null) e.purchasePriceTo = f.purchasePriceTo
if (f.referencePriceFrom != null) e.referencePriceFrom = f.referencePriceFrom
if (f.referencePriceTo != null) e.referencePriceTo = f.referencePriceTo
return e
}
@ -54,8 +54,8 @@ const activeFilterCount = (f: Filters) => {
if (f.isService !== 'all') n++
if (f.packaging) n++
if (f.isMarked !== 'all') n++
if (f.purchasePriceFrom != null) n++
if (f.purchasePriceTo != null) n++
if (f.referencePriceFrom != null) n++
if (f.referencePriceTo != null) n++
return n
}
@ -127,10 +127,10 @@ export function ProductsPage() {
{ header: 'Штрихкод', width: '160px', cell: (r) => (
<span className="font-mono">{r.barcodes[0]?.code ?? '—'}</span>
)},
{ header: 'Закупочная цена', width: '160px', className: 'text-right font-mono', sortKey: 'purchasePrice', cell: (r) => {
if (r.purchasePrice == null) return '—'
{ header: 'Эталонная цена', width: '160px', className: 'text-right font-mono', sortKey: 'referencePrice', cell: (r) => {
if (r.referencePrice == null) return '—'
const fractional = org.data?.allowFractionalPrices ?? false
const num = r.purchasePrice.toLocaleString('ru',
const num = r.referencePrice.toLocaleString('ru',
fractional
? { minimumFractionDigits: 2, maximumFractionDigits: 2 }
: { maximumFractionDigits: 0 })
@ -226,8 +226,8 @@ export function ProductsPage() {
<span className="text-slate-500">Закупочная цена</span>
<div className="w-32">
<MoneyInput
value={filters.purchasePriceFrom}
onChange={(n) => { setFilters({ ...filters, purchasePriceFrom: n }); setPage(1) }}
value={filters.referencePriceFrom}
onChange={(n) => { setFilters({ ...filters, referencePriceFrom: n }); setPage(1) }}
currencyCode={org.data?.defaultCurrencyCode ?? undefined}
currencySymbol={org.data?.defaultCurrencySymbol ?? undefined}
@ -236,8 +236,8 @@ export function ProductsPage() {
</div>
<div className="w-32">
<MoneyInput
value={filters.purchasePriceTo}
onChange={(n) => { setFilters({ ...filters, purchasePriceTo: n }); setPage(1) }}
value={filters.referencePriceTo}
onChange={(n) => { setFilters({ ...filters, referencePriceTo: n }); setPage(1) }}
currencyCode={org.data?.defaultCurrencyCode ?? undefined}
currencySymbol={org.data?.defaultCurrencySymbol ?? undefined}

View file

@ -175,7 +175,7 @@ export function SupplyEditPage() {
productArticle: p.article,
unitName: p.unitName,
quantity: 1,
unitPrice: p.purchasePrice ?? 0,
unitPrice: p.referencePrice ?? 0,
}],
})
}