diff --git a/bible-local/decisions/2026-08-12-project-export-per-config-sale-uplift.md b/bible-local/decisions/2026-08-12-project-export-per-config-sale-uplift.md new file mode 100644 index 0000000..99fa2fd --- /dev/null +++ b/bible-local/decisions/2026-08-12-project-export-per-config-sale-uplift.md @@ -0,0 +1,51 @@ +# Decision: project-level DDP export uses each configuration's own sale uplift + +**Date:** 2026-08-12 +**Status:** active + +## Context + +Two code paths produce the same "Ценообразование" (pricing) CSV: exporting a single +configuration from `index.html` (`exportPricingCSV`), and exporting an entire project +from `project_detail.html` (`exportProject` → `POST /api/projects/:uuid/export` → +`ExportProjectPricingCSV`). Both end up in `ExportService.buildPricingExportBlock`. + +Each configuration's "Аплифт к estimate" (`#pricing-uplift-sale` in the pricing tab) is +saved per configuration, piggybacked into `Configuration.Notes` as +`{"pricing_ui":{"sale_uplift":...}}` (see `serializeConfigNotes`/ +`restorePricingStateFromNotes` in `index.html`). The single-config export sends this value +explicitly as `sale_markup` in the request, so it always applies the right uplift. + +The project-level export modal never had an uplift input and never sent `sale_markup`, so +`ProjectPricingExportOptions.SaleMarkup` was always `0`. `buildPricingExportBlock` used to +fall back to a single hardcoded `defaultSaleMarkup` (1.3) for every configuration in the +project, silently ignoring each configuration's own saved uplift — a bulk DDP export did +not match what exporting those same configurations individually would produce. + +## Decision + +`buildPricingExportBlock` resolves the DDP estimate factor per configuration via +`ProjectPricingExportOptions.effectiveSaleMarkupFactor(cfg)`: + +- an explicit `opts.SaleMarkup` (only ever sent by the single-config pricing tab, which + knows the live-edited value that may not be saved yet) always wins; +- otherwise it reads that configuration's own saved uplift from `Notes` + (`configSavedSaleUplift`); +- otherwise it falls back to `defaultSaleMarkup` (1.3), same as before, for configurations + that never had an uplift saved. + +Both `applyDDPMarkup` call sites in `buildPricingExportBlock` (the BOM-driven branch and +the plain-items fallback) use this per-configuration factor. Stock/Competitor keep using +the fixed `stockCompetitorMarkupFactor` (1.3) — only the Estimate uplift is configurable. + +## Consequences + +- Project-level DDP export now goes through the same "pipeline" as single-config export: + each row's Estimate is scaled by that configuration's own saved uplift, not a + project-wide constant. +- If a future caller needs to force one uplift across an entire project export regardless + of individual configs' saved settings, it must do so explicitly via `sale_markup` in the + request — do not reintroduce a silent global default that overrides saved per-config + values. +- `configSavedSaleUplift` only reads `Notes`; it does not fail loudly on malformed/foreign + JSON in that field — it just returns 0 and lets the 1.3 fallback apply. diff --git a/internal/services/export.go b/internal/services/export.go index bd89922..8ccf3d1 100644 --- a/internal/services/export.go +++ b/internal/services/export.go @@ -3,6 +3,7 @@ package services import ( "bytes" "encoding/csv" + "encoding/json" "fmt" "io" "math" @@ -68,13 +69,41 @@ type ProjectPricingExportOptions struct { // other API callers that omit sale_markup. const defaultSaleMarkup = 1.3 -func (o ProjectPricingExportOptions) saleMarkupFactor() float64 { +// effectiveSaleMarkupFactor resolves the DDP estimate uplift for one configuration. +// An explicit opts.SaleMarkup (sent by the single-config pricing tab, which knows the +// live-edited value) always wins. Otherwise each configuration's own saved uplift +// (persisted in Notes by the "Ценообразование" tab, see index.html buildPricingState/ +// restorePricingStateFromNotes) is used, so bulk project export matches what exporting +// that same configuration individually would produce — one pipeline, not a single +// project-wide constant. +func (o ProjectPricingExportOptions) effectiveSaleMarkupFactor(cfg *models.Configuration) float64 { if o.SaleMarkup > 0 { return o.SaleMarkup } + if v := configSavedSaleUplift(cfg); v > 0 { + return v + } return defaultSaleMarkup } +// configSavedSaleUplift reads the per-configuration DDP estimate uplift persisted by the +// pricing tab. Notes stores {"pricing_ui":{"sale_uplift":...}} as JSON — see +// serializeConfigNotes/restorePricingStateFromNotes in index.html. +func configSavedSaleUplift(cfg *models.Configuration) float64 { + if cfg == nil || strings.TrimSpace(cfg.Notes) == "" { + return 0 + } + var parsed struct { + PricingUI struct { + SaleUplift float64 `json:"sale_uplift"` + } `json:"pricing_ui"` + } + if err := json.Unmarshal([]byte(cfg.Notes), &parsed); err != nil { + return 0 + } + return parsed.PricingUI.SaleUplift +} + func (o ProjectPricingExportOptions) isDDP() bool { return strings.EqualFold(strings.TrimSpace(o.Basis), "ddp") } @@ -457,7 +486,7 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts }) } if opts.isDDP() { - applyDDPMarkup(block.Rows, opts.saleMarkupFactor()) + applyDDPMarkup(block.Rows, opts.effectiveSaleMarkupFactor(cfg)) } if opts.ManualPrice != nil && *opts.ManualPrice > 0 { distributeManualPrice(block.Rows, *opts.ManualPrice) @@ -494,7 +523,7 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts } if opts.isDDP() { - applyDDPMarkup(block.Rows, opts.saleMarkupFactor()) + applyDDPMarkup(block.Rows, opts.effectiveSaleMarkupFactor(cfg)) } if opts.ManualPrice != nil && *opts.ManualPrice > 0 { distributeManualPrice(block.Rows, *opts.ManualPrice) diff --git a/web/templates/project_detail.html b/web/templates/project_detail.html index 8b74125..50b71fc 100644 --- a/web/templates/project_detail.html +++ b/web/templates/project_detail.html @@ -178,7 +178,7 @@