diff --git a/bible-local/02-architecture.md b/bible-local/02-architecture.md index e0dbfad..d28c642 100644 --- a/bible-local/02-architecture.md +++ b/bible-local/02-architecture.md @@ -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 `"::"` (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`; diff --git a/bible-local/decisions/2026-09-15-sale-row-markup-and-minimal-csv.md b/bible-local/decisions/2026-09-15-sale-row-markup-and-minimal-csv.md new file mode 100644 index 0000000..95e037a --- /dev/null +++ b/bible-local/decisions/2026-09-15-sale-row-markup-and-minimal-csv.md @@ -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 `"::"` (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. diff --git a/internal/handlers/export.go b/internal/handlers/export.go index b87dc06..b7b00e1 100644 --- a/internal/handlers/export.go +++ b/internal/handlers/export.go @@ -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"` // "::" -> 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 { diff --git a/internal/services/export.go b/internal/services/export.go index 8ccf3d1..6a120b0 100644 --- a/internal/services/export.go +++ b/internal/services/export.go @@ -59,9 +59,16 @@ type ProjectPricingExportOptions struct { IncludeEstimate bool `json:"include_estimate"` IncludeStock bool `json:"include_stock"` IncludeCompetitor bool `json:"include_competitor"` - 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 + 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"` // "::" -> markup percent } // defaultSaleMarkup mirrors PricingMarkup.DEFAULT_SALE_UPLIFT in web/templates/index.html. @@ -294,10 +301,14 @@ 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 { - if err := csvWriter.Write(pricingConfigSummaryRow(cfg, opts)); err != nil { - return fmt.Errorf("failed to write config summary row: %w", err) + // 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 { @@ -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 "—" diff --git a/web/templates/index.html b/web/templates/index.html index 0a3adab..8c9c7de 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -267,7 +267,6 @@

Цена продажи

Цены указаны за 1 шт. -

Склад и Конкуренты умножаются на 1,3

@@ -277,22 +276,17 @@ - - - - + + - + - - - - - + +
Описание LOT Кол-воEstimateСкладКонкурентыРучная ценаНакидка, %Цена
Загрузите BOM во вкладке «BOM»
Загрузите BOM во вкладке «BOM»
@@ -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) + ? 'W' + : ''; + // 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) - ? 'W' - : ''; const meterCell = (q, bt) => ACCESSIBLE_MODE ? `${qualityMeterHtml(q)}` : ''; @@ -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 = `Нет данных для отображения`; + tbodySale.innerHTML = `Нет данных для отображения`; 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} ${r.lotCell} ${r.qty} - ${saleEstUnit > 0 ? formatCurrency(saleEstUnit) : '—'}${worldMark(r.estWorld)} - ${saleWhUnit != null ? formatCurrency(saleWhUnit) : '—'}${worldMark(r.whWorld)} - ${saleCompUnit != null ? formatCurrency(saleCompUnit) : '—'}${worldMark(r.compWorld)} - — + + + + —${worldMark(r.estWorld)} `; 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) { - let origTotal = 0; let hasOrig = false; - rows.forEach(tr => { if (tr.dataset[origAttr] !== '') { origTotal += parseFloat(tr.dataset[origAttr]) || 0; hasOrig = true; } }); - totalVendorEl.textContent = hasOrig ? formatCurrency(origTotal) : '—'; - } else { - // sale: reset to — already handled above - totalVendorEl.textContent = '—'; - } + // Recompute total from originals + let origTotal = 0; let hasOrig = false; + rows.forEach(tr => { if (tr.dataset.vendorOrig !== '') { origTotal += parseFloat(tr.dataset.vendorOrig) || 0; hasOrig = true; } }); + totalVendorEl.textContent = hasOrig ? formatCurrency(origTotal) : '—'; 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; + // 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: 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({ - include_lot: true, - include_bom: true, - include_estimate: true, - include_stock: !!showStockPrices, - include_competitor: true, - basis: basis, - sale_markup: saleUplift > 0 ? saleUplift : null, - manual_price: manualPrice > 0 ? manualPrice : null, - }), + body: JSON.stringify(requestBody), }); if (!resp.ok) { showToast('Ошибка экспорта', 'error'); return; } const blob = await resp.blob();