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:
Mikhail Chusavitin
2026-09-15 16:39:54 +03:00
co-authored by Claude Sonnet 5
parent fb412a4227
commit 7263dd4572
5 changed files with 385 additions and 100 deletions
+31 -1
View File
@@ -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.
+18
View File
@@ -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
View File
@@ -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
View File
@@ -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();