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
|
## Pricing tab layout
|
||||||
|
|
||||||
The Pricing tab (Ценообразование) has two tables: Buy (Цена покупки) and Sale (Цена продажи).
|
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 | Склад | Конкуренты | Ручная цена
|
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:
|
Per-LOT row expansion rules:
|
||||||
- each `lot_mappings` entry in a BOM row becomes its own table row with its own quantity and prices;
|
- 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`;
|
- `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"`
|
IncludeCompetitor bool `json:"include_competitor"`
|
||||||
Basis string `json:"basis"` // "fob" or "ddp"
|
Basis string `json:"basis"` // "fob" or "ddp"
|
||||||
SaleMarkup float64 `json:"sale_markup"` // DDP multiplier; 0 defaults to 1.3
|
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) {
|
func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
||||||
@@ -254,6 +263,15 @@ func (h *ExportHandler) ExportConfigPricingCSV(c *gin.Context) {
|
|||||||
Basis: req.Basis,
|
Basis: req.Basis,
|
||||||
SaleMarkup: req.SaleMarkup,
|
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)
|
data, err := h.exportService.ConfigToPricingExportData(config, opts)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
+101
-20
@@ -59,9 +59,16 @@ type ProjectPricingExportOptions struct {
|
|||||||
IncludeEstimate bool `json:"include_estimate"`
|
IncludeEstimate bool `json:"include_estimate"`
|
||||||
IncludeStock bool `json:"include_stock"`
|
IncludeStock bool `json:"include_stock"`
|
||||||
IncludeCompetitor bool `json:"include_competitor"`
|
IncludeCompetitor bool `json:"include_competitor"`
|
||||||
Basis string `json:"basis"` // "fob" or "ddp"; empty defaults to "fob"
|
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
|
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
|
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.
|
// 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)
|
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 {
|
for _, cfg := range data.Configs {
|
||||||
if err := csvWriter.Write(pricingConfigSummaryRow(cfg, opts)); err != nil {
|
// Minimal mode (single-config Sale export) skips the per-config summary line —
|
||||||
return fmt.Errorf("failed to write config summary row: %w", err)
|
// 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 {
|
if writeRows {
|
||||||
for _, row := range cfg.Rows {
|
for _, row := range cfg.Rows {
|
||||||
@@ -485,12 +496,7 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
|
|||||||
CompetitorWorld: priceMap[lot].CompetitorWorld,
|
CompetitorWorld: priceMap[lot].CompetitorWorld,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
if opts.isDDP() {
|
applySalePricing(block.Rows, cfg, opts)
|
||||||
applyDDPMarkup(block.Rows, opts.effectiveSaleMarkupFactor(cfg))
|
|
||||||
}
|
|
||||||
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
|
|
||||||
distributeManualPrice(block.Rows, *opts.ManualPrice)
|
|
||||||
}
|
|
||||||
return block, nil
|
return block, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -522,16 +528,29 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
if opts.isDDP() {
|
applySalePricing(block.Rows, cfg, opts)
|
||||||
applyDDPMarkup(block.Rows, opts.effectiveSaleMarkupFactor(cfg))
|
|
||||||
}
|
|
||||||
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
|
|
||||||
distributeManualPrice(block.Rows, *opts.ManualPrice)
|
|
||||||
}
|
|
||||||
|
|
||||||
return block, nil
|
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.
|
// sortConfigItemsByCategoryMap returns a copy of items sorted by category display order.
|
||||||
// categories maps lot_name → category code; catOrder maps category code → 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 {
|
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
|
// stockCompetitorMarkupFactor is the fixed DDP multiplier applied to Stock and
|
||||||
// Competitor columns, independent of the user-configurable estimate uplift.
|
// 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
|
const stockCompetitorMarkupFactor = 1.3
|
||||||
|
|
||||||
func applyDDPMarkup(rows []ProjectPricingExportRow, estimateFactor float64) {
|
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 {
|
func scaleFloatPtr(v *float64, factor float64) *float64 {
|
||||||
if v == nil {
|
if v == nil {
|
||||||
return nil
|
return nil
|
||||||
@@ -861,6 +932,9 @@ func estimateOnlyTotal(estimatePrice *float64, fallbackUnitPrice float64, quanti
|
|||||||
}
|
}
|
||||||
|
|
||||||
func pricingCSVHeaders(opts ProjectPricingExportOptions) []string {
|
func pricingCSVHeaders(opts ProjectPricingExportOptions) []string {
|
||||||
|
if opts.MinimalSaleColumns {
|
||||||
|
return []string{"LOT", "Описание", "Кол-во", "Цена"}
|
||||||
|
}
|
||||||
headers := make([]string, 0, 9)
|
headers := make([]string, 0, 9)
|
||||||
headers = append(headers, "Line Item")
|
headers = append(headers, "Line Item")
|
||||||
if opts.IncludeLOT {
|
if opts.IncludeLOT {
|
||||||
@@ -887,6 +961,14 @@ func pricingCSVHeaders(opts ProjectPricingExportOptions) []string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
func pricingCSVRow(row ProjectPricingExportRow, 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 := make([]string, 0, 9)
|
||||||
record = append(record, "")
|
record = append(record, "")
|
||||||
if opts.IncludeLOT {
|
if opts.IncludeLOT {
|
||||||
@@ -960,7 +1042,6 @@ func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricing
|
|||||||
return record
|
return record
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
func formatMoneyValue(value *float64) string {
|
func formatMoneyValue(value *float64) string {
|
||||||
if value == nil {
|
if value == nil {
|
||||||
return "—"
|
return "—"
|
||||||
|
|||||||
+177
-79
@@ -267,7 +267,6 @@
|
|||||||
<h3 class="text-base font-semibold text-gray-800">Цена продажи</h3>
|
<h3 class="text-base font-semibold text-gray-800">Цена продажи</h3>
|
||||||
<span class="text-xs text-gray-400">Цены указаны за 1 шт.</span>
|
<span class="text-xs text-gray-400">Цены указаны за 1 шт.</span>
|
||||||
</div>
|
</div>
|
||||||
<p class="text-xs text-gray-500 mb-3">Склад и Конкуренты умножаются на 1,3</p>
|
|
||||||
<div class="overflow-x-auto">
|
<div class="overflow-x-auto">
|
||||||
<table class="w-full text-sm border-collapse">
|
<table class="w-full text-sm border-collapse">
|
||||||
<thead class="bg-gray-50 text-gray-700">
|
<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">Описание</th>
|
||||||
<th class="px-2 py-2 text-left border-b">LOT</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">Кол-во</th>
|
||||||
<th class="px-2 py-2 text-right border-b">Estimate</th>
|
<th class="px-2 py-2 text-right border-b w-20">Накидка, %</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">Ручная цена</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="pricing-body-sale">
|
<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>
|
</tbody>
|
||||||
<tfoot id="pricing-foot-sale" class="hidden bg-gray-50 font-semibold">
|
<tfoot id="pricing-foot-sale" class="hidden bg-gray-50 font-semibold">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="{{if eq .IndicatorMode "accessible"}}5{{else}}4{{end}}" class="px-2 py-2 text-right">Итого:</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" id="pricing-total-sale-estimate">—</td>
|
<td class="px-2 py-2 text-right font-bold" id="pricing-total-sale-price">—</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>
|
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
@@ -612,11 +606,9 @@ let componentPricesCache = {}; // { lot_name: price } - caches prices loaded via
|
|||||||
let componentPricesCacheLoading = new Map(); // { category: Promise } - tracks ongoing price loads
|
let componentPricesCacheLoading = new Map(); // { category: Promise } - tracks ongoing price loads
|
||||||
|
|
||||||
// ─── Sale (DDP) pricing markup — single source of truth for this file ───
|
// ─── Sale (DDP) pricing markup — single source of truth for this file ───
|
||||||
// Mirrors internal/services/export.go (saleMarkupFactor / stockCompetitorMarkupFactor).
|
// Mirrors internal/services/export.go effectiveSaleMarkupFactor/defaultSaleMarkup.
|
||||||
// Keep both sides in sync: Estimate scales by the user's uplift, Stock/Competitor by a fixed factor.
|
|
||||||
const PricingMarkup = {
|
const PricingMarkup = {
|
||||||
DEFAULT_SALE_UPLIFT: 1.3,
|
DEFAULT_SALE_UPLIFT: 1.3,
|
||||||
STOCK_COMPETITOR_FIXED: 1.3,
|
|
||||||
// Reads the "Аплифт к estimate" input; falls back to DEFAULT_SALE_UPLIFT when empty/invalid.
|
// Reads the "Аплифт к estimate" input; falls back to DEFAULT_SALE_UPLIFT when empty/invalid.
|
||||||
getSaleUplift() {
|
getSaleUplift() {
|
||||||
const v = parseDecimalInput(document.getElementById('pricing-uplift-sale')?.value || '');
|
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
|
// Autocomplete state
|
||||||
let autocompleteInput = null;
|
let autocompleteInput = null;
|
||||||
let autocompleteCategory = null;
|
let autocompleteCategory = null;
|
||||||
@@ -2580,10 +2588,18 @@ function buildPricingState() {
|
|||||||
const saleUplift = parseDecimalInput(document.getElementById('pricing-uplift-sale')?.value || '');
|
const saleUplift = parseDecimalInput(document.getElementById('pricing-uplift-sale')?.value || '');
|
||||||
const saleCustom = parseDecimalInput(document.getElementById('pricing-custom-price-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 {
|
return {
|
||||||
buy_custom_price: buyCustom > 0 ? buyCustom : null,
|
buy_custom_price: buyCustom > 0 ? buyCustom : null,
|
||||||
sale_uplift: saleUplift > 0 ? saleUplift : null,
|
sale_uplift: saleUplift > 0 ? saleUplift : null,
|
||||||
sale_custom_price: saleCustom > 0 ? saleCustom : 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)
|
? 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() {
|
function getAutosaveStorageKey() {
|
||||||
@@ -4517,9 +4544,6 @@ async function renderPricingTab() {
|
|||||||
} catch(e) { /* silent */ }
|
} 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
|
// Helper: returns unit prices from pricelist for a single LOT
|
||||||
const _getUnitPrices = (pl) => ({
|
const _getUnitPrices = (pl) => ({
|
||||||
@@ -4531,17 +4555,9 @@ async function renderPricingTab() {
|
|||||||
compWorld: !!(pl && pl.competitor_from_world),
|
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
|
// Colour-blind-safe mode: leading signal-meter column, no row tint, and the
|
||||||
// amber world-fallback cell tint is replaced by a text marker.
|
// amber world-fallback cell tint is replaced by a text marker.
|
||||||
const ACCESSIBLE_MODE = window.QF_INDICATOR_MODE === 'accessible';
|
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
|
const meterCell = (q, bt) => ACCESSIBLE_MODE
|
||||||
? `<td class="pricing-quality-meter px-2 py-1.5 align-middle ${bt || ''}">${qualityMeterHtml(q)}</td>`
|
? `<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 ─────────────────────────────────────────────────
|
// ─── 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 = '';
|
tbodySale.innerHTML = '';
|
||||||
if (!rowData.length) {
|
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');
|
tfootSale.classList.add('hidden');
|
||||||
} else {
|
} 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 => {
|
rowData.forEach(r => {
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
tr.classList.add('pricing-row-sale');
|
tr.classList.add('pricing-row-sale');
|
||||||
@@ -4746,21 +4762,16 @@ async function renderPricingTab() {
|
|||||||
} else if (r.isEstOnly) {
|
} else if (r.isEstOnly) {
|
||||||
tr.classList.add('bg-blue-50');
|
tr.classList.add('bg-blue-50');
|
||||||
}
|
}
|
||||||
const saleEstUnit = r.estUnit > 0 ? r.estUnit * saleUplift : 0;
|
const rowKey = _saleRowKey(r.vendorPN, r.lotText);
|
||||||
const saleWhUnit = r.warehouseUnit != null ? r.warehouseUnit * SALE_FIXED_MULT : null;
|
const markupPct = saleRowMarkups[rowKey] || 0;
|
||||||
const saleCompUnit = r.competitorUnit != null ? r.competitorUnit * SALE_FIXED_MULT : null;
|
tr.dataset.rowKey = rowKey;
|
||||||
const saleEstTotal = saleEstUnit * r.qty;
|
tr.dataset.rawEst = r.est;
|
||||||
const saleWhTotal = saleWhUnit != null ? saleWhUnit * r.qty : null;
|
|
||||||
const saleCompTotal = saleCompUnit != null ? saleCompUnit * r.qty : null;
|
|
||||||
tr.dataset.estSale = saleEstTotal;
|
|
||||||
tr.dataset.qty = r.qty;
|
tr.dataset.qty = r.qty;
|
||||||
tr.dataset.groupStart = r.groupStart ? 'true' : 'false';
|
tr.dataset.groupStart = r.groupStart ? 'true' : 'false';
|
||||||
tr.dataset.vendorPn = r.vendorPN || '';
|
tr.dataset.vendorPn = r.vendorPN || '';
|
||||||
tr.dataset.desc = r.desc;
|
tr.dataset.desc = r.desc;
|
||||||
tr.dataset.lot = r.lotText;
|
tr.dataset.lot = r.lotText;
|
||||||
if (saleEstTotal > 0) { totEst += saleEstTotal; hasEst = true; if (r.estWorld) worldEst += saleEstTotal; }
|
tr.dataset.estWorld = r.estWorld ? 'true' : 'false';
|
||||||
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; }
|
|
||||||
const borderTop = r.groupStart ? 'border-t border-gray-200' : '';
|
const borderTop = r.groupStart ? 'border-t border-gray-200' : '';
|
||||||
const pnDescHtml = r.groupStart ? (() => {
|
const pnDescHtml = r.groupStart ? (() => {
|
||||||
const rs = r.groupSize > 1 ? ` rowspan="${r.groupSize}"` : '';
|
const rs = r.groupSize > 1 ? ` rowspan="${r.groupSize}"` : '';
|
||||||
@@ -4772,23 +4783,100 @@ async function renderPricingTab() {
|
|||||||
${pnDescHtml}
|
${pnDescHtml}
|
||||||
<td class="px-2 py-1.5 text-xs ${borderTop}">${r.lotCell}</td>
|
<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}">${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 ${borderTop}">
|
||||||
<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>
|
<input type="text" inputmode="decimal" class="pricing-row-markup w-16 px-1 py-0.5 border rounded text-right text-xs"
|
||||||
<td class="px-2 py-1.5 text-right text-xs ${borderTop} ${worldCls(r.compWorld)}">${saleCompUnit != null ? formatCurrency(saleCompUnit) : '—'}${worldMark(r.compWorld)}</td>
|
value="${formatMarkupInput(markupPct)}"
|
||||||
<td class="px-2 py-1.5 text-right text-xs pricing-vendor-price-sale ${borderTop} text-gray-400">—</td>
|
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);
|
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');
|
tfootSale.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
// Restore custom prices after re-render
|
// Restore custom prices after re-render
|
||||||
applyCustomPrice('buy');
|
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 ─────────────────────────────────────────────────────────
|
// ─── Pricing helpers ─────────────────────────────────────────────────────────
|
||||||
@@ -4849,25 +4937,27 @@ function formatUpliftInput(value) {
|
|||||||
return value.toFixed(4).replace('.', ',');
|
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) {
|
function _getPricingEstimateTotal(table) {
|
||||||
const attr = table === 'sale' ? 'estSale' : 'est';
|
|
||||||
const cls = table === 'sale' ? 'pricing-row-sale' : 'pricing-row-buy';
|
|
||||||
let total = 0;
|
let total = 0;
|
||||||
document.querySelectorAll(`#pricing-body-${table} tr.${cls}`).forEach(tr => {
|
document.querySelectorAll(`#pricing-body-${table} tr.pricing-row-${table}`).forEach(tr => {
|
||||||
total += parseFloat(tr.dataset[attr]) || 0;
|
total += parseFloat(tr.dataset.est) || 0;
|
||||||
});
|
});
|
||||||
return total;
|
return total;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply custom (own) price proportionally to Ручная цена column.
|
// Apply custom (own) price proportionally to the Buy table's Ручная цена column.
|
||||||
// table: 'buy' | 'sale'
|
// The Sale table's own manual-price handling lives in recomputeSalePrices().
|
||||||
function applyCustomPrice(table) {
|
function applyCustomPrice(table) {
|
||||||
const inputId = `pricing-custom-price-${table}`;
|
const inputId = `pricing-custom-price-${table}`;
|
||||||
const totalElId = `pricing-total-${table}-vendor`;
|
const totalElId = `pricing-total-${table}-vendor`;
|
||||||
const rowClass = `pricing-row-${table}`;
|
const rowClass = `pricing-row-${table}`;
|
||||||
const cellClass = `.pricing-vendor-price-${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 customPrice = parseFloat(document.getElementById(inputId)?.value) || 0;
|
||||||
const estimateTotal = _getPricingEstimateTotal(table);
|
const estimateTotal = _getPricingEstimateTotal(table);
|
||||||
@@ -4886,7 +4976,7 @@ function applyCustomPrice(table) {
|
|||||||
if (customPrice > 0 && estimateTotal > 0) {
|
if (customPrice > 0 && estimateTotal > 0) {
|
||||||
let assigned = 0;
|
let assigned = 0;
|
||||||
rows.forEach((tr, i) => {
|
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 qty = Math.max(1, parseFloat(tr.dataset.qty) || 1);
|
||||||
const cell = vendorCells[i];
|
const cell = vendorCells[i];
|
||||||
if (!cell) return;
|
if (!cell) return;
|
||||||
@@ -4911,22 +5001,17 @@ function applyCustomPrice(table) {
|
|||||||
const cell = vendorCells[i];
|
const cell = vendorCells[i];
|
||||||
if (!cell) return;
|
if (!cell) return;
|
||||||
cell.className = cell.className.replace(/\btext-(?:gray|green|red|blue)-\d+\b/g, '').trim();
|
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));
|
cell.textContent = formatCurrency(parseFloat(tr.dataset.vendorOrigUnit));
|
||||||
} else {
|
} else {
|
||||||
cell.textContent = '—';
|
cell.textContent = '—';
|
||||||
cell.classList.add('text-gray-400');
|
cell.classList.add('text-gray-400');
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
// Recompute total from originals (buy) or clear (sale)
|
// Recompute total from originals
|
||||||
if (origAttr) {
|
let origTotal = 0; let hasOrig = false;
|
||||||
let origTotal = 0; let hasOrig = false;
|
rows.forEach(tr => { if (tr.dataset.vendorOrig !== '') { origTotal += parseFloat(tr.dataset.vendorOrig) || 0; hasOrig = true; } });
|
||||||
rows.forEach(tr => { if (tr.dataset[origAttr] !== '') { origTotal += parseFloat(tr.dataset[origAttr]) || 0; hasOrig = true; } });
|
totalVendorEl.textContent = hasOrig ? formatCurrency(origTotal) : '—';
|
||||||
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();
|
totalVendorEl.className = totalVendorEl.className.replace(/\btext-(?:green|red)-\d+\b/g, '').trim();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -4937,7 +5022,7 @@ function onBuyCustomPriceInput() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onSaleCustomPriceInput() {
|
function onSaleCustomPriceInput() {
|
||||||
applyCustomPrice('sale');
|
recomputeSalePrices();
|
||||||
triggerAutoSave();
|
triggerAutoSave();
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4988,20 +5073,33 @@ async function exportPricingCSV(table) {
|
|||||||
const manualInputId = table === 'sale' ? 'pricing-custom-price-sale' : 'pricing-custom-price-buy';
|
const manualInputId = table === 'sale' ? 'pricing-custom-price-sale' : 'pricing-custom-price-buy';
|
||||||
const manualPrice = parseDecimalInput(document.getElementById(manualInputId)?.value || '');
|
const manualPrice = parseDecimalInput(document.getElementById(manualInputId)?.value || '');
|
||||||
const saleUplift = table === 'sale' ? PricingMarkup.getSaleUplift() : 0;
|
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 {
|
try {
|
||||||
const resp = await fetch(`/api/configs/${configUUID}/export/pricing`, {
|
const resp = await fetch(`/api/configs/${configUUID}/export/pricing`, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({
|
body: JSON.stringify(requestBody),
|
||||||
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,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
if (!resp.ok) { showToast('Ошибка экспорта', 'error'); return; }
|
if (!resp.ok) { showToast('Ошибка экспорта', 'error'); return; }
|
||||||
const blob = await resp.blob();
|
const blob = await resp.blob();
|
||||||
|
|||||||
Reference in New Issue
Block a user