feat: накидка по строке в таблице «Цена продажи» + минимальный CSV
Sale-таблица показывает LOT/Описание/Кол-во/Накидка,%/Цена вместо Estimate/ Склад/Конкуренты/Ручная цена; итоговая цена строки = база (raw estimate или доля от общей «Ручная цена») × (Аплифт к estimate + своя Накидка%). Экспорт CSV этой таблицы теперь выводит только LOT;Описание;Кол-во;Цена. Buy-таблица и массовый экспорт по проекту не затронуты — новые поля запроса опциональны и включаются только кнопкой «Экспорт CSV» у Sale-таблицы. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
fb412a4227
commit
7263dd4572
@@ -62,13 +62,43 @@ Rules:
|
||||
## Pricing tab layout
|
||||
|
||||
The Pricing tab (Ценообразование) has two tables: Buy (Цена покупки) and Sale (Цена продажи).
|
||||
Their column sets differ (see below); the shared per-LOT row expansion/grouping rules apply to both.
|
||||
|
||||
Column order (both tables):
|
||||
Buy table column order:
|
||||
|
||||
```
|
||||
PN вендора | Описание | LOT | Кол-во | Estimate | Склад | Конкуренты | Ручная цена
|
||||
```
|
||||
|
||||
Sale table column order:
|
||||
|
||||
```
|
||||
PN вендора | Описание | LOT | Кол-во | Накидка, % | Цена
|
||||
```
|
||||
|
||||
Sale per-row pricing (`_saleRowPrice`/`recomputeSalePrices` in `index.html`,
|
||||
`applySaleRowPricing` in `internal/services/export.go`):
|
||||
- each row's base is its raw Estimate unit price × qty, unless the table-wide "Ручная
|
||||
цена" input is set — then the base is that row's proportional share of the manual
|
||||
total (same distribution as before this existed, last row absorbs the rounding
|
||||
remainder), overwriting the row's own base rather than filling a separate column;
|
||||
- `Цена = base × (Аплифт к estimate + row's own Накидка% / 100)` — Накидка is a
|
||||
per-row percentage, additive to the table-wide uplift, entered directly in the
|
||||
row's own input; unset rows default to 0% (no change to older configs' totals);
|
||||
Склад/Конкуренты are not shown or exported for the Sale table;
|
||||
- per-row Накидка is keyed by `"<vendorPN>::<lot>"` (uppercased, "NONE" when there's
|
||||
no vendor PN) — plain `lot_name` isn't a safe key since the same LOT can appear
|
||||
under more than one vendor-PN group (see row expansion rules below) — and persisted
|
||||
as an additive key, `Notes.pricing_ui.sale_row_markups`, alongside the existing
|
||||
`sale_uplift`/`sale_custom_price` (see
|
||||
[decisions/2026-09-15-sale-row-markup-and-minimal-csv.md](decisions/2026-09-15-sale-row-markup-and-minimal-csv.md));
|
||||
- the Sale table's own "Экспорт CSV" button additionally sends
|
||||
`minimal_sale_columns: true` + `sale_row_markups`, which collapses
|
||||
`POST /api/configs/:uuid/export/pricing` output to exactly `LOT;Описание;Кол-во;Цена`
|
||||
(`ProjectPricingExportOptions.MinimalSaleColumns` in `internal/services/export.go`).
|
||||
This is scoped to that one request: Buy export and the project-level bulk export
|
||||
never set it and are unaffected.
|
||||
|
||||
Per-LOT row expansion rules:
|
||||
- each `lot_mappings` entry in a BOM row becomes its own table row with its own quantity and prices;
|
||||
- `baseLot` (resolved LOT without an explicit mapping) is treated as the first sub-row with `quantity_per_pn` from `_getRowLotQtyPerPN`;
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
# Decision: per-row "Накидка" on the Sale table + minimal Sale CSV columns
|
||||
|
||||
**Date:** 2026-09-15
|
||||
**Status:** active
|
||||
|
||||
## Context
|
||||
|
||||
The Sale (Цена продажи) table only had one table-wide "Аплифт к estimate" multiplier
|
||||
and one table-wide "Ручная цена" override. There was no way to push margin up on
|
||||
individual rows (e.g. scarce GPUs) without changing the uplift for every row, and the
|
||||
table showed Estimate/Склад/Конкуренты/Ручная-цена columns that a sales rep handing a
|
||||
quote to a customer never needed — the actual deliverable is just LOT, Описание, Кол-во,
|
||||
and a final price.
|
||||
|
||||
## Decision
|
||||
|
||||
- The Sale table gains a per-row "Накидка, %" input. Each row's final "Цена" is
|
||||
`base × (Аплифт к estimate + Накидка% / 100)`, where `base` is the row's raw Estimate
|
||||
unit price × qty, or — when the table-wide "Ручная цена" is set — that row's
|
||||
proportional share of the manual total (same last-row-absorbs-remainder distribution
|
||||
the table-wide manual price already used, now overwriting the row's base instead of
|
||||
filling a separate column).
|
||||
- The Sale table's visible/exported columns collapse to
|
||||
`PN вендора | Описание | LOT | Кол-во | Накидка, % | Цена` on screen, and exactly
|
||||
`LOT;Описание;Кол-во;Цена` in its own CSV export. Estimate, Склад, Конкуренты, and
|
||||
the old Ручная-цена column are no longer shown or exported for this table. The Buy
|
||||
table (Цена покупки) is untouched.
|
||||
- Per-row markup is keyed by `"<vendorPN>::<lot>"` (uppercased, `NONE` when there is no
|
||||
vendor PN) — not by `lot_name` alone, since the same LOT can appear under more than
|
||||
one vendor-PN BOM group (see "Per-LOT row expansion rules" in `02-architecture.md`).
|
||||
- Persistence is additive: `Configuration.Notes.pricing_ui` gains a new
|
||||
`sale_row_markups` map alongside the existing `sale_uplift`/`sale_custom_price`, the
|
||||
same JSON-blob-in-`Notes` mechanism established by
|
||||
[2026-08-12-project-export-per-config-sale-uplift.md](2026-08-12-project-export-per-config-sale-uplift.md).
|
||||
Zero/unset rows are omitted from the map entirely.
|
||||
- CSV export: `POST /api/configs/:uuid/export/pricing` gained `minimal_sale_columns`
|
||||
(bool) and `sale_row_markups` (map) request fields, read into
|
||||
`ProjectPricingExportOptions.MinimalSaleColumns`/`SaleRowMarkups`. These are wired
|
||||
**only** in `ExportConfigPricingCSV`, gated on `req.MinimalSaleColumns` being true —
|
||||
the Sale table's own "Экспорт CSV" button is the only caller that sets it. Buy export
|
||||
(basis=fob) and the project-level bulk export (`ExportProjectPricingCSV`) never send
|
||||
it and keep their full existing column sets/output unchanged.
|
||||
- `ProjectPricingExportOptions.ManualPrice`, previously parsed from the request but
|
||||
never actually read into the options struct (a pre-existing dead field), is now wired
|
||||
— but only inside the same `MinimalSaleColumns` gate, so this bug fix does not change
|
||||
Buy-table CSV output for anyone already relying on the old (broken) behavior.
|
||||
|
||||
## Consequences
|
||||
|
||||
- Old clients / already-saved configurations are unaffected: `sale_row_markups` absent
|
||||
from `Notes` means every row's markup defaults to 0%, reproducing the exact totals
|
||||
the table produced before this change (uplift-only). Old clients that don't know the
|
||||
new `Notes` key simply ignore it, exactly like `sale_uplift` is ignored by even older
|
||||
code.
|
||||
- If a future caller needs the full-column DDP CSV format with per-row markup applied,
|
||||
it must do so explicitly — `MinimalSaleColumns` intentionally couples "reduced
|
||||
columns" and "per-row markup applied" as one request-level switch rather than two
|
||||
independent options, since the only current caller needs both together.
|
||||
@@ -55,6 +55,15 @@ type ProjectExportOptionsRequest struct {
|
||||
IncludeCompetitor bool `json:"include_competitor"`
|
||||
Basis string `json:"basis"` // "fob" or "ddp"
|
||||
SaleMarkup float64 `json:"sale_markup"` // DDP multiplier; 0 defaults to 1.3
|
||||
|
||||
ManualPrice *float64 `json:"manual_price"` // user-defined total price; distributed proportionally across rows
|
||||
|
||||
// MinimalSaleColumns/SaleRowMarkups are sent only by the single-config Sale-table
|
||||
// "Экспорт CSV" button (index.html exportPricingCSV('sale')) — never by the Buy
|
||||
// export or the project-level bulk export, which always leave these zero-valued so
|
||||
// their output stays unchanged. See services.ProjectPricingExportOptions.
|
||||
MinimalSaleColumns bool `json:"minimal_sale_columns"`
|
||||
SaleRowMarkups map[string]float64 `json:"sale_row_markups"` // "<vendorPN>::<lot>" -> markup percent
|
||||
}
|
||||
|
||||
func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
||||
@@ -254,6 +263,15 @@ func (h *ExportHandler) ExportConfigPricingCSV(c *gin.Context) {
|
||||
Basis: req.Basis,
|
||||
SaleMarkup: req.SaleMarkup,
|
||||
}
|
||||
// MinimalSaleColumns/SaleRowMarkups/ManualPrice are only ever sent by the Sale
|
||||
// table's own "Экспорт CSV" button (index.html exportPricingCSV('sale')) — gating
|
||||
// on the request's own flag keeps every other caller (Buy export, any other basis=
|
||||
// ddp caller) byte-identical to before this field existed.
|
||||
if req.MinimalSaleColumns {
|
||||
opts.MinimalSaleColumns = true
|
||||
opts.SaleRowMarkups = req.SaleRowMarkups
|
||||
opts.ManualPrice = req.ManualPrice
|
||||
}
|
||||
|
||||
data, err := h.exportService.ConfigToPricingExportData(config, opts)
|
||||
if err != nil {
|
||||
|
||||
+96
-15
@@ -62,6 +62,13 @@ type ProjectPricingExportOptions struct {
|
||||
Basis string `json:"basis"` // "fob" or "ddp"; empty defaults to "fob"
|
||||
SaleMarkup float64 `json:"sale_markup"` // DDP uplift applied to Estimate only; 0 defaults to 1.3
|
||||
ManualPrice *float64 `json:"manual_price"` // user-defined total price; distributed proportionally across rows
|
||||
|
||||
// MinimalSaleColumns/SaleRowMarkups back the Sale table's own per-row "Накидка"
|
||||
// feature (index.html renderPricingTab/_saleRowPrice). Only ever set by
|
||||
// ExportConfigPricingCSV when the request explicitly asks for it — the Buy export
|
||||
// and project-level bulk export never set these, so their output is unaffected.
|
||||
MinimalSaleColumns bool `json:"minimal_sale_columns"`
|
||||
SaleRowMarkups map[string]float64 `json:"sale_row_markups"` // "<vendorPN>::<lot>" -> markup percent
|
||||
}
|
||||
|
||||
// defaultSaleMarkup mirrors PricingMarkup.DEFAULT_SALE_UPLIFT in web/templates/index.html.
|
||||
@@ -294,11 +301,15 @@ func (s *ExportService) ToPricingCSV(w io.Writer, data *ProjectPricingExportData
|
||||
return fmt.Errorf("failed to write pricing header: %w", err)
|
||||
}
|
||||
|
||||
writeRows := opts.IncludeLOT || opts.IncludeBOM
|
||||
writeRows := opts.IncludeLOT || opts.IncludeBOM || opts.MinimalSaleColumns
|
||||
for _, cfg := range data.Configs {
|
||||
// Minimal mode (single-config Sale export) skips the per-config summary line —
|
||||
// it's just LOT/Описание/Кол-во/Цена item rows, see pricingCSVHeaders.
|
||||
if !opts.MinimalSaleColumns {
|
||||
if err := csvWriter.Write(pricingConfigSummaryRow(cfg, opts)); err != nil {
|
||||
return fmt.Errorf("failed to write config summary row: %w", err)
|
||||
}
|
||||
}
|
||||
if writeRows {
|
||||
for _, row := range cfg.Rows {
|
||||
if err := csvWriter.Write(pricingCSVRow(row, opts)); err != nil {
|
||||
@@ -485,12 +496,7 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
|
||||
CompetitorWorld: priceMap[lot].CompetitorWorld,
|
||||
})
|
||||
}
|
||||
if opts.isDDP() {
|
||||
applyDDPMarkup(block.Rows, opts.effectiveSaleMarkupFactor(cfg))
|
||||
}
|
||||
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
|
||||
distributeManualPrice(block.Rows, *opts.ManualPrice)
|
||||
}
|
||||
applySalePricing(block.Rows, cfg, opts)
|
||||
return block, nil
|
||||
}
|
||||
|
||||
@@ -522,16 +528,29 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
|
||||
})
|
||||
}
|
||||
|
||||
if opts.isDDP() {
|
||||
applyDDPMarkup(block.Rows, opts.effectiveSaleMarkupFactor(cfg))
|
||||
}
|
||||
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
|
||||
distributeManualPrice(block.Rows, *opts.ManualPrice)
|
||||
}
|
||||
applySalePricing(block.Rows, cfg, opts)
|
||||
|
||||
return block, nil
|
||||
}
|
||||
|
||||
// applySalePricing applies the DDP estimate uplift/manual-price/markup pipeline to a
|
||||
// pricing export block. MinimalSaleColumns (only ever set by the single-config Sale
|
||||
// export button) switches to applySaleRowPricing, which additionally folds in each
|
||||
// row's own "Накидка" percent; every other caller (Buy export, project bulk export)
|
||||
// keeps the original applyDDPMarkup + distributeManualPrice behavior unchanged.
|
||||
func applySalePricing(rows []ProjectPricingExportRow, cfg *models.Configuration, opts ProjectPricingExportOptions) {
|
||||
if opts.MinimalSaleColumns {
|
||||
applySaleRowPricing(rows, opts.effectiveSaleMarkupFactor(cfg), opts.ManualPrice, opts.SaleRowMarkups)
|
||||
return
|
||||
}
|
||||
if opts.isDDP() {
|
||||
applyDDPMarkup(rows, opts.effectiveSaleMarkupFactor(cfg))
|
||||
}
|
||||
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
|
||||
distributeManualPrice(rows, *opts.ManualPrice)
|
||||
}
|
||||
}
|
||||
|
||||
// sortConfigItemsByCategoryMap returns a copy of items sorted by category display order.
|
||||
// categories maps lot_name → category code; catOrder maps category code → display order.
|
||||
func sortConfigItemsByCategoryMap(items models.ConfigItems, catOrder map[string]int, categories map[string]string) models.ConfigItems {
|
||||
@@ -550,7 +569,8 @@ func sortConfigItemsByCategoryMap(items models.ConfigItems, catOrder map[string]
|
||||
|
||||
// stockCompetitorMarkupFactor is the fixed DDP multiplier applied to Stock and
|
||||
// Competitor columns, independent of the user-configurable estimate uplift.
|
||||
// Mirrors PricingMarkup.STOCK_COMPETITOR_FIXED in web/templates/index.html.
|
||||
// Only used by the non-minimal (full-column) DDP export path — the Sale table itself
|
||||
// no longer displays/exports Stock/Competitor, see applySaleRowPricing.
|
||||
const stockCompetitorMarkupFactor = 1.3
|
||||
|
||||
func applyDDPMarkup(rows []ProjectPricingExportRow, estimateFactor float64) {
|
||||
@@ -561,6 +581,57 @@ func applyDDPMarkup(rows []ProjectPricingExportRow, estimateFactor float64) {
|
||||
}
|
||||
}
|
||||
|
||||
// saleRowKey mirrors _saleRowKey in web/templates/index.html: a LOT can appear more
|
||||
// than once across different vendor-PN BOM groups, so lot_name alone isn't a safe key.
|
||||
// "—" is the row-builder's own placeholder for "no vendor PN" (see the VendorPN: "—"
|
||||
// literals in buildPricingExportBlock), matching the frontend's empty-string case.
|
||||
func saleRowKey(vendorPN, lot string) string {
|
||||
v := strings.ToUpper(strings.TrimSpace(vendorPN))
|
||||
if v == "" || v == "—" {
|
||||
v = "NONE"
|
||||
}
|
||||
return v + "::" + strings.ToUpper(strings.TrimSpace(lot))
|
||||
}
|
||||
|
||||
// applySaleRowPricing implements the per-row "Накидка" pricing pipeline for the
|
||||
// minimal Sale CSV export: each row's base (its raw Estimate, or a proportional share
|
||||
// of manualPrice when set — same last-row-absorbs-remainder distribution as
|
||||
// distributeManualPrice, but overwriting the base instead of a side field) is
|
||||
// multiplied by (saleUplift + that row's own markup percent / 100). Mirrors
|
||||
// _saleRowPrice/recomputeSalePrices in web/templates/index.html exactly.
|
||||
func applySaleRowPricing(rows []ProjectPricingExportRow, saleUplift float64, manualPrice *float64, rowMarkups map[string]float64) {
|
||||
rawTotal := 0.0
|
||||
lastPricedIdx := -1
|
||||
for i, row := range rows {
|
||||
if row.Estimate != nil && *row.Estimate > 0 {
|
||||
rawTotal += *row.Estimate
|
||||
lastPricedIdx = i
|
||||
}
|
||||
}
|
||||
useManual := manualPrice != nil && *manualPrice > 0 && rawTotal > 0
|
||||
|
||||
assigned := 0.0
|
||||
for i := range rows {
|
||||
if rows[i].Estimate == nil || *rows[i].Estimate <= 0 {
|
||||
continue
|
||||
}
|
||||
base := *rows[i].Estimate
|
||||
if useManual {
|
||||
if i == lastPricedIdx {
|
||||
base = math.Round((*manualPrice-assigned)*100) / 100
|
||||
} else {
|
||||
share := (*rows[i].Estimate / rawTotal) * (*manualPrice)
|
||||
base = math.Round(share*100) / 100
|
||||
assigned += base
|
||||
}
|
||||
}
|
||||
key := saleRowKey(rows[i].VendorPN, rows[i].LotDisplay)
|
||||
factor := saleUplift + (rowMarkups[key] / 100)
|
||||
price := math.Round(base*factor*100) / 100
|
||||
rows[i].Estimate = floatPtr(price)
|
||||
}
|
||||
}
|
||||
|
||||
func scaleFloatPtr(v *float64, factor float64) *float64 {
|
||||
if v == nil {
|
||||
return nil
|
||||
@@ -861,6 +932,9 @@ func estimateOnlyTotal(estimatePrice *float64, fallbackUnitPrice float64, quanti
|
||||
}
|
||||
|
||||
func pricingCSVHeaders(opts ProjectPricingExportOptions) []string {
|
||||
if opts.MinimalSaleColumns {
|
||||
return []string{"LOT", "Описание", "Кол-во", "Цена"}
|
||||
}
|
||||
headers := make([]string, 0, 9)
|
||||
headers = append(headers, "Line Item")
|
||||
if opts.IncludeLOT {
|
||||
@@ -887,6 +961,14 @@ func pricingCSVHeaders(opts ProjectPricingExportOptions) []string {
|
||||
}
|
||||
|
||||
func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions) []string {
|
||||
if opts.MinimalSaleColumns {
|
||||
return []string{
|
||||
emptyDash(row.LotDisplay),
|
||||
emptyDash(row.Description),
|
||||
fmt.Sprintf("%d", exportPositiveInt(row.Quantity, 1)),
|
||||
formatMoneyValue(row.Estimate),
|
||||
}
|
||||
}
|
||||
record := make([]string, 0, 9)
|
||||
record = append(record, "")
|
||||
if opts.IncludeLOT {
|
||||
@@ -960,7 +1042,6 @@ func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricing
|
||||
return record
|
||||
}
|
||||
|
||||
|
||||
func formatMoneyValue(value *float64) string {
|
||||
if value == nil {
|
||||
return "—"
|
||||
|
||||
+172
-74
@@ -267,7 +267,6 @@
|
||||
<h3 class="text-base font-semibold text-gray-800">Цена продажи</h3>
|
||||
<span class="text-xs text-gray-400">Цены указаны за 1 шт.</span>
|
||||
</div>
|
||||
<p class="text-xs text-gray-500 mb-3">Склад и Конкуренты умножаются на 1,3</p>
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm border-collapse">
|
||||
<thead class="bg-gray-50 text-gray-700">
|
||||
@@ -277,22 +276,17 @@
|
||||
<th class="px-2 py-2 text-left border-b">Описание</th>
|
||||
<th class="px-2 py-2 text-left border-b">LOT</th>
|
||||
<th class="px-2 py-2 text-right border-b">Кол-во</th>
|
||||
<th class="px-2 py-2 text-right border-b">Estimate</th>
|
||||
<th class="px-2 py-2 text-right border-b stock-price-col">Склад</th>
|
||||
<th class="px-2 py-2 text-right border-b">Конкуренты</th>
|
||||
<th class="px-2 py-2 text-right border-b">Ручная цена</th>
|
||||
<th class="px-2 py-2 text-right border-b w-20">Накидка, %</th>
|
||||
<th class="px-2 py-2 text-right border-b">Цена</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pricing-body-sale">
|
||||
<tr><td colspan="{{if eq .IndicatorMode "accessible"}}9{{else}}8{{end}}" class="px-2 py-8 text-center text-gray-400">Загрузите BOM во вкладке «BOM»</td></tr>
|
||||
<tr><td colspan="{{if eq .IndicatorMode "accessible"}}7{{else}}6{{end}}" class="px-2 py-8 text-center text-gray-400">Загрузите BOM во вкладке «BOM»</td></tr>
|
||||
</tbody>
|
||||
<tfoot id="pricing-foot-sale" class="hidden bg-gray-50 font-semibold">
|
||||
<tr>
|
||||
<td colspan="{{if eq .IndicatorMode "accessible"}}5{{else}}4{{end}}" class="px-2 py-2 text-right">Итого:</td>
|
||||
<td class="px-2 py-2 text-right" id="pricing-total-sale-estimate">—</td>
|
||||
<td class="px-2 py-2 text-right stock-price-col" id="pricing-total-sale-warehouse">—</td>
|
||||
<td class="px-2 py-2 text-right" id="pricing-total-sale-competitor">—</td>
|
||||
<td class="px-2 py-2 text-right font-bold" id="pricing-total-sale-vendor">—</td>
|
||||
<td colspan="{{if eq .IndicatorMode "accessible"}}6{{else}}5{{end}}" class="px-2 py-2 text-right">Итого:</td>
|
||||
<td class="px-2 py-2 text-right font-bold" id="pricing-total-sale-price">—</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
@@ -612,11 +606,9 @@ let componentPricesCache = {}; // { lot_name: price } - caches prices loaded via
|
||||
let componentPricesCacheLoading = new Map(); // { category: Promise } - tracks ongoing price loads
|
||||
|
||||
// ─── Sale (DDP) pricing markup — single source of truth for this file ───
|
||||
// Mirrors internal/services/export.go (saleMarkupFactor / stockCompetitorMarkupFactor).
|
||||
// Keep both sides in sync: Estimate scales by the user's uplift, Stock/Competitor by a fixed factor.
|
||||
// Mirrors internal/services/export.go effectiveSaleMarkupFactor/defaultSaleMarkup.
|
||||
const PricingMarkup = {
|
||||
DEFAULT_SALE_UPLIFT: 1.3,
|
||||
STOCK_COMPETITOR_FIXED: 1.3,
|
||||
// Reads the "Аплифт к estimate" input; falls back to DEFAULT_SALE_UPLIFT when empty/invalid.
|
||||
getSaleUplift() {
|
||||
const v = parseDecimalInput(document.getElementById('pricing-uplift-sale')?.value || '');
|
||||
@@ -624,6 +616,22 @@ const PricingMarkup = {
|
||||
},
|
||||
};
|
||||
|
||||
// Per-row "Накидка" (%) for the Sale table, keyed by _saleRowKey(vendorPN, lot).
|
||||
// Persisted in Configuration.Notes.pricing_ui.sale_row_markups (see buildPricingState/
|
||||
// restorePricingStateFromNotes); restored before the table exists, so renderPricingTab()
|
||||
// reads it live rather than relying on per-render input state.
|
||||
let saleRowMarkups = {};
|
||||
|
||||
// Amber marker for world-fallback cells — must stay outside gray/green/red/blue
|
||||
// families since applyCustomPrice() strips those on vendor-price cells via regex.
|
||||
// Shared by renderPricingTab() (initial paint) and recomputeSalePrices() (re-paint on
|
||||
// input change), so both module-level functions rather than a closure in either one.
|
||||
const WORLD_CLS = 'bg-amber-50 text-amber-700';
|
||||
const worldCls = (w) => (window.QF_INDICATOR_MODE !== 'accessible' && w) ? WORLD_CLS : '';
|
||||
const worldMark = (w) => (window.QF_INDICATOR_MODE === 'accessible' && w)
|
||||
? '<sup class="text-gray-500 font-semibold" title="Цена-заглушка из прайслиста WORLD">W</sup>'
|
||||
: '';
|
||||
|
||||
// Autocomplete state
|
||||
let autocompleteInput = null;
|
||||
let autocompleteCategory = null;
|
||||
@@ -2580,10 +2588,18 @@ function buildPricingState() {
|
||||
const saleUplift = parseDecimalInput(document.getElementById('pricing-uplift-sale')?.value || '');
|
||||
const saleCustom = parseDecimalInput(document.getElementById('pricing-custom-price-sale')?.value || '');
|
||||
|
||||
// Only non-zero entries are kept, so an untouched config serializes exactly like
|
||||
// before this feature (additive key — old clients simply ignore it).
|
||||
const rowMarkups = {};
|
||||
Object.entries(saleRowMarkups).forEach(([key, pct]) => {
|
||||
if (pct > 0) rowMarkups[key] = pct;
|
||||
});
|
||||
|
||||
return {
|
||||
buy_custom_price: buyCustom > 0 ? buyCustom : null,
|
||||
sale_uplift: saleUplift > 0 ? saleUplift : null,
|
||||
sale_custom_price: saleCustom > 0 ? saleCustom : null,
|
||||
sale_row_markups: Object.keys(rowMarkups).length ? rowMarkups : null,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -2625,6 +2641,17 @@ function restorePricingStateFromNotes(notesRaw) {
|
||||
? pricing.sale_custom_price.toFixed(2)
|
||||
: '';
|
||||
}
|
||||
|
||||
// Sale-table row is not rendered yet at this point — renderPricingTab() reads
|
||||
// saleRowMarkups live when it builds each row's Накидка input.
|
||||
saleRowMarkups = {};
|
||||
if (pricing.sale_row_markups && typeof pricing.sale_row_markups === 'object') {
|
||||
Object.entries(pricing.sale_row_markups).forEach(([key, pct]) => {
|
||||
if (typeof pct === 'number' && Number.isFinite(pct) && pct > 0) {
|
||||
saleRowMarkups[key] = pct;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function getAutosaveStorageKey() {
|
||||
@@ -4517,9 +4544,6 @@ async function renderPricingTab() {
|
||||
} catch(e) { /* silent */ }
|
||||
}
|
||||
|
||||
// Sale uplift applied to estimate; Stock/Competitor use the fixed factor. See PricingMarkup.
|
||||
const saleUplift = PricingMarkup.getSaleUplift();
|
||||
const SALE_FIXED_MULT = PricingMarkup.STOCK_COMPETITOR_FIXED;
|
||||
|
||||
// Helper: returns unit prices from pricelist for a single LOT
|
||||
const _getUnitPrices = (pl) => ({
|
||||
@@ -4531,17 +4555,9 @@ async function renderPricingTab() {
|
||||
compWorld: !!(pl && pl.competitor_from_world),
|
||||
});
|
||||
|
||||
// Amber marker for world-fallback cells — must stay outside gray/green/red/blue
|
||||
// families since applyCustomPrice() strips those on vendor-price cells via regex.
|
||||
const WORLD_CLS = 'bg-amber-50 text-amber-700';
|
||||
|
||||
// Colour-blind-safe mode: leading signal-meter column, no row tint, and the
|
||||
// amber world-fallback cell tint is replaced by a text marker.
|
||||
const ACCESSIBLE_MODE = window.QF_INDICATOR_MODE === 'accessible';
|
||||
const worldCls = (w) => (!ACCESSIBLE_MODE && w) ? WORLD_CLS : '';
|
||||
const worldMark = (w) => (ACCESSIBLE_MODE && w)
|
||||
? '<sup class="text-gray-500 font-semibold" title="Цена-заглушка из прайслиста WORLD">W</sup>'
|
||||
: '';
|
||||
const meterCell = (q, bt) => ACCESSIBLE_MODE
|
||||
? `<td class="pricing-quality-meter px-2 py-1.5 align-middle ${bt || ''}">${qualityMeterHtml(q)}</td>`
|
||||
: '';
|
||||
@@ -4729,15 +4745,15 @@ async function renderPricingTab() {
|
||||
}
|
||||
|
||||
// ─── Populate Sale table ─────────────────────────────────────────────────
|
||||
// Sale rows show one final "Цена" per row: rawEstimate (or a proportional share
|
||||
// of the global "Ручная цена" when set) multiplied by (Аплифт к estimate + row's
|
||||
// own Накидка%). See recomputeSalePrices() / _saleRowPrice() for the shared math,
|
||||
// re-run on every input change and after every render.
|
||||
tbodySale.innerHTML = '';
|
||||
if (!rowData.length) {
|
||||
tbodySale.innerHTML = `<tr><td colspan="${ACCESSIBLE_MODE ? 9 : 8}" class="px-2 py-8 text-center text-gray-400">Нет данных для отображения</td></tr>`;
|
||||
tbodySale.innerHTML = `<tr><td colspan="${ACCESSIBLE_MODE ? 7 : 6}" class="px-2 py-8 text-center text-gray-400">Нет данных для отображения</td></tr>`;
|
||||
tfootSale.classList.add('hidden');
|
||||
} else {
|
||||
let totEst = 0, totWh = 0, totComp = 0;
|
||||
let hasEst = false, hasWh = false, hasComp = false;
|
||||
let cntWh = 0, cntComp = 0;
|
||||
let worldEst = 0, worldWh = 0, worldComp = 0;
|
||||
rowData.forEach(r => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.classList.add('pricing-row-sale');
|
||||
@@ -4746,21 +4762,16 @@ async function renderPricingTab() {
|
||||
} else if (r.isEstOnly) {
|
||||
tr.classList.add('bg-blue-50');
|
||||
}
|
||||
const saleEstUnit = r.estUnit > 0 ? r.estUnit * saleUplift : 0;
|
||||
const saleWhUnit = r.warehouseUnit != null ? r.warehouseUnit * SALE_FIXED_MULT : null;
|
||||
const saleCompUnit = r.competitorUnit != null ? r.competitorUnit * SALE_FIXED_MULT : null;
|
||||
const saleEstTotal = saleEstUnit * r.qty;
|
||||
const saleWhTotal = saleWhUnit != null ? saleWhUnit * r.qty : null;
|
||||
const saleCompTotal = saleCompUnit != null ? saleCompUnit * r.qty : null;
|
||||
tr.dataset.estSale = saleEstTotal;
|
||||
const rowKey = _saleRowKey(r.vendorPN, r.lotText);
|
||||
const markupPct = saleRowMarkups[rowKey] || 0;
|
||||
tr.dataset.rowKey = rowKey;
|
||||
tr.dataset.rawEst = r.est;
|
||||
tr.dataset.qty = r.qty;
|
||||
tr.dataset.groupStart = r.groupStart ? 'true' : 'false';
|
||||
tr.dataset.vendorPn = r.vendorPN || '';
|
||||
tr.dataset.desc = r.desc;
|
||||
tr.dataset.lot = r.lotText;
|
||||
if (saleEstTotal > 0) { totEst += saleEstTotal; hasEst = true; if (r.estWorld) worldEst += saleEstTotal; }
|
||||
if (saleWhTotal != null) { totWh += saleWhTotal; hasWh = true; cntWh++; if (r.whWorld) worldWh += saleWhTotal; }
|
||||
if (saleCompTotal != null) { totComp += saleCompTotal; hasComp = true; cntComp++; if (r.compWorld) worldComp += saleCompTotal; }
|
||||
tr.dataset.estWorld = r.estWorld ? 'true' : 'false';
|
||||
const borderTop = r.groupStart ? 'border-t border-gray-200' : '';
|
||||
const pnDescHtml = r.groupStart ? (() => {
|
||||
const rs = r.groupSize > 1 ? ` rowspan="${r.groupSize}"` : '';
|
||||
@@ -4772,23 +4783,100 @@ async function renderPricingTab() {
|
||||
${pnDescHtml}
|
||||
<td class="px-2 py-1.5 text-xs ${borderTop}">${r.lotCell}</td>
|
||||
<td class="px-2 py-1.5 text-right text-xs ${borderTop}">${r.qty}</td>
|
||||
<td class="px-2 py-1.5 text-right text-xs ${borderTop} ${worldCls(r.estWorld)}">${saleEstUnit > 0 ? formatCurrency(saleEstUnit) : '—'}${worldMark(r.estWorld)}</td>
|
||||
<td class="px-2 py-1.5 text-right text-xs stock-price-col ${borderTop} ${worldCls(r.whWorld)}">${saleWhUnit != null ? formatCurrency(saleWhUnit) : '—'}${worldMark(r.whWorld)}</td>
|
||||
<td class="px-2 py-1.5 text-right text-xs ${borderTop} ${worldCls(r.compWorld)}">${saleCompUnit != null ? formatCurrency(saleCompUnit) : '—'}${worldMark(r.compWorld)}</td>
|
||||
<td class="px-2 py-1.5 text-right text-xs pricing-vendor-price-sale ${borderTop} text-gray-400">—</td>
|
||||
<td class="px-2 py-1.5 text-right ${borderTop}">
|
||||
<input type="text" inputmode="decimal" class="pricing-row-markup w-16 px-1 py-0.5 border rounded text-right text-xs"
|
||||
value="${formatMarkupInput(markupPct)}"
|
||||
placeholder="0" oninput="onSaleRowMarkupInput(this)">
|
||||
</td>
|
||||
<td class="px-2 py-1.5 text-right text-xs pricing-sale-price ${borderTop} ${worldCls(r.estWorld)}">—${worldMark(r.estWorld)}</td>
|
||||
`;
|
||||
tbodySale.appendChild(tr);
|
||||
});
|
||||
document.getElementById('pricing-total-sale-vendor').textContent = '—';
|
||||
_setPricingTotal('pricing-total-sale-estimate', hasEst, totEst, worldEst, rowData.length, rowData.length);
|
||||
_setPricingTotal('pricing-total-sale-warehouse', hasWh, totWh, worldWh, cntWh, rowData.length);
|
||||
_setPricingTotal('pricing-total-sale-competitor', hasComp, totComp, worldComp, cntComp, rowData.length);
|
||||
tfootSale.classList.remove('hidden');
|
||||
}
|
||||
|
||||
// Restore custom prices after re-render
|
||||
applyCustomPrice('buy');
|
||||
applyCustomPrice('sale');
|
||||
recomputeSalePrices();
|
||||
}
|
||||
|
||||
// Composite key for a Sale-table row's per-row "Накидка": the same LOT can appear
|
||||
// under more than one vendor PN group (see bible-local/02-architecture.md "Per-LOT
|
||||
// row expansion rules"), so lot_name alone is not a safe map key.
|
||||
function _saleRowKey(vendorPN, lot) {
|
||||
const U = s => (s || '').toUpperCase();
|
||||
return `${U(vendorPN) || 'NONE'}::${U(lot)}`;
|
||||
}
|
||||
|
||||
// Single source of truth for a Sale row's final price: its base (raw estimate, or a
|
||||
// proportional share of the global "Ручная цена" when set) times (global uplift + this
|
||||
// row's own Накидка%).
|
||||
function _saleRowPrice(base, markupPct, saleUplift) {
|
||||
const factor = saleUplift + ((markupPct || 0) / 100);
|
||||
return base * factor;
|
||||
}
|
||||
|
||||
// Re-derives every Sale row's displayed price from its raw estimate, the global
|
||||
// uplift/manual-price inputs, and its own Накидка input. Called after render and on
|
||||
// every uplift/manual-price/row-markup change — the only place this math happens.
|
||||
function recomputeSalePrices() {
|
||||
const saleUplift = PricingMarkup.getSaleUplift();
|
||||
const customPrice = parseDecimalInput(document.getElementById('pricing-custom-price-sale')?.value || '');
|
||||
const rows = Array.from(document.querySelectorAll('#pricing-body-sale tr.pricing-row-sale'));
|
||||
|
||||
let estimateTotalRaw = 0;
|
||||
let lastPricedIdx = -1;
|
||||
rows.forEach((tr, i) => {
|
||||
const rawEst = parseFloat(tr.dataset.rawEst) || 0;
|
||||
if (rawEst > 0) { estimateTotalRaw += rawEst; lastPricedIdx = i; }
|
||||
});
|
||||
|
||||
// Same last-row-absorbs-remainder distribution as applyCustomPrice()/
|
||||
// distributeManualPrice() so the rows' bases sum to customPrice exactly.
|
||||
let assigned = 0;
|
||||
let total = 0, worldTotal = 0, hasAny = false, pricedCount = 0;
|
||||
rows.forEach((tr, i) => {
|
||||
const rawEst = parseFloat(tr.dataset.rawEst) || 0;
|
||||
const cell = tr.querySelector('.pricing-sale-price');
|
||||
if (!cell) return;
|
||||
if (rawEst <= 0) {
|
||||
cell.innerHTML = '—';
|
||||
return;
|
||||
}
|
||||
let base;
|
||||
if (customPrice > 0 && estimateTotalRaw > 0) {
|
||||
if (i === lastPricedIdx) {
|
||||
base = Math.round((customPrice - assigned) * 100) / 100;
|
||||
} else {
|
||||
base = Math.round((rawEst / estimateTotalRaw) * customPrice * 100) / 100;
|
||||
assigned += base;
|
||||
}
|
||||
} else {
|
||||
base = rawEst;
|
||||
}
|
||||
const markupPct = saleRowMarkups[tr.dataset.rowKey] || 0;
|
||||
const isWorld = tr.dataset.estWorld === 'true';
|
||||
const price = _saleRowPrice(base, markupPct, saleUplift);
|
||||
cell.innerHTML = formatCurrency(price) + worldMark(isWorld);
|
||||
total += price;
|
||||
hasAny = true;
|
||||
pricedCount++;
|
||||
if (isWorld) worldTotal += price;
|
||||
});
|
||||
|
||||
_setPricingTotal('pricing-total-sale-price', hasAny, total, worldTotal, pricedCount, rows.length);
|
||||
}
|
||||
|
||||
function onSaleRowMarkupInput(inputEl) {
|
||||
const key = inputEl.dataset.rowKey;
|
||||
const value = parseDecimalInput(inputEl.value || '');
|
||||
if (value > 0) {
|
||||
saleRowMarkups[key] = value;
|
||||
} else {
|
||||
delete saleRowMarkups[key];
|
||||
}
|
||||
recomputeSalePrices();
|
||||
triggerAutoSave();
|
||||
}
|
||||
|
||||
// ─── Pricing helpers ─────────────────────────────────────────────────────────
|
||||
@@ -4849,25 +4937,27 @@ function formatUpliftInput(value) {
|
||||
return value.toFixed(4).replace('.', ',');
|
||||
}
|
||||
|
||||
// One or two decimals, comma separator, no trailing zeros — for the per-row Накидка input.
|
||||
function formatMarkupInput(value) {
|
||||
if (!Number.isFinite(value) || value <= 0) return '';
|
||||
return String(Math.round(value * 100) / 100).replace('.', ',');
|
||||
}
|
||||
|
||||
function _getPricingEstimateTotal(table) {
|
||||
const attr = table === 'sale' ? 'estSale' : 'est';
|
||||
const cls = table === 'sale' ? 'pricing-row-sale' : 'pricing-row-buy';
|
||||
let total = 0;
|
||||
document.querySelectorAll(`#pricing-body-${table} tr.${cls}`).forEach(tr => {
|
||||
total += parseFloat(tr.dataset[attr]) || 0;
|
||||
document.querySelectorAll(`#pricing-body-${table} tr.pricing-row-${table}`).forEach(tr => {
|
||||
total += parseFloat(tr.dataset.est) || 0;
|
||||
});
|
||||
return total;
|
||||
}
|
||||
|
||||
// Apply custom (own) price proportionally to Ручная цена column.
|
||||
// table: 'buy' | 'sale'
|
||||
// Apply custom (own) price proportionally to the Buy table's Ручная цена column.
|
||||
// The Sale table's own manual-price handling lives in recomputeSalePrices().
|
||||
function applyCustomPrice(table) {
|
||||
const inputId = `pricing-custom-price-${table}`;
|
||||
const totalElId = `pricing-total-${table}-vendor`;
|
||||
const rowClass = `pricing-row-${table}`;
|
||||
const cellClass = `.pricing-vendor-price-${table}`;
|
||||
const estAttr = table === 'sale' ? 'estSale' : 'est';
|
||||
const origAttr = table === 'buy' ? 'vendorOrig' : null;
|
||||
|
||||
const customPrice = parseFloat(document.getElementById(inputId)?.value) || 0;
|
||||
const estimateTotal = _getPricingEstimateTotal(table);
|
||||
@@ -4886,7 +4976,7 @@ function applyCustomPrice(table) {
|
||||
if (customPrice > 0 && estimateTotal > 0) {
|
||||
let assigned = 0;
|
||||
rows.forEach((tr, i) => {
|
||||
const rowEst = parseFloat(tr.dataset[estAttr]) || 0;
|
||||
const rowEst = parseFloat(tr.dataset.est) || 0;
|
||||
const qty = Math.max(1, parseFloat(tr.dataset.qty) || 1);
|
||||
const cell = vendorCells[i];
|
||||
if (!cell) return;
|
||||
@@ -4911,22 +5001,17 @@ function applyCustomPrice(table) {
|
||||
const cell = vendorCells[i];
|
||||
if (!cell) return;
|
||||
cell.className = cell.className.replace(/\btext-(?:gray|green|red|blue)-\d+\b/g, '').trim();
|
||||
if (origAttr && tr.dataset.vendorOrigUnit !== '') {
|
||||
if (tr.dataset.vendorOrigUnit !== '') {
|
||||
cell.textContent = formatCurrency(parseFloat(tr.dataset.vendorOrigUnit));
|
||||
} else {
|
||||
cell.textContent = '—';
|
||||
cell.classList.add('text-gray-400');
|
||||
}
|
||||
});
|
||||
// Recompute total from originals (buy) or clear (sale)
|
||||
if (origAttr) {
|
||||
// Recompute total from originals
|
||||
let origTotal = 0; let hasOrig = false;
|
||||
rows.forEach(tr => { if (tr.dataset[origAttr] !== '') { origTotal += parseFloat(tr.dataset[origAttr]) || 0; hasOrig = true; } });
|
||||
rows.forEach(tr => { if (tr.dataset.vendorOrig !== '') { origTotal += parseFloat(tr.dataset.vendorOrig) || 0; hasOrig = true; } });
|
||||
totalVendorEl.textContent = hasOrig ? formatCurrency(origTotal) : '—';
|
||||
} else {
|
||||
// sale: reset to — already handled above
|
||||
totalVendorEl.textContent = '—';
|
||||
}
|
||||
totalVendorEl.className = totalVendorEl.className.replace(/\btext-(?:green|red)-\d+\b/g, '').trim();
|
||||
}
|
||||
}
|
||||
@@ -4937,7 +5022,7 @@ function onBuyCustomPriceInput() {
|
||||
}
|
||||
|
||||
function onSaleCustomPriceInput() {
|
||||
applyCustomPrice('sale');
|
||||
recomputeSalePrices();
|
||||
triggerAutoSave();
|
||||
}
|
||||
|
||||
@@ -4988,20 +5073,33 @@ async function exportPricingCSV(table) {
|
||||
const manualInputId = table === 'sale' ? 'pricing-custom-price-sale' : 'pricing-custom-price-buy';
|
||||
const manualPrice = parseDecimalInput(document.getElementById(manualInputId)?.value || '');
|
||||
const saleUplift = table === 'sale' ? PricingMarkup.getSaleUplift() : 0;
|
||||
try {
|
||||
const resp = await fetch(`/api/configs/${configUUID}/export/pricing`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({
|
||||
// Sale export drops Estimate/Stock/Competitor and collapses to LOT/Описание/
|
||||
// Кол-во/Цена — see internal/services/export.go MinimalSaleColumns. Buy export is
|
||||
// untouched.
|
||||
const requestBody = table === 'sale'
|
||||
? {
|
||||
include_lot: true,
|
||||
basis: basis,
|
||||
sale_markup: saleUplift > 0 ? saleUplift : null,
|
||||
manual_price: manualPrice > 0 ? manualPrice : null,
|
||||
minimal_sale_columns: true,
|
||||
sale_row_markups: saleRowMarkups,
|
||||
}
|
||||
: {
|
||||
include_lot: true,
|
||||
include_bom: true,
|
||||
include_estimate: true,
|
||||
include_stock: !!showStockPrices,
|
||||
include_competitor: true,
|
||||
basis: basis,
|
||||
sale_markup: saleUplift > 0 ? saleUplift : null,
|
||||
sale_markup: null,
|
||||
manual_price: manualPrice > 0 ? manualPrice : null,
|
||||
}),
|
||||
};
|
||||
try {
|
||||
const resp = await fetch(`/api/configs/${configUUID}/export/pricing`, {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify(requestBody),
|
||||
});
|
||||
if (!resp.ok) { showToast('Ошибка экспорта', 'error'); return; }
|
||||
const blob = await resp.blob();
|
||||
|
||||
Reference in New Issue
Block a user