fix: DDP-экспорт с проекта игнорировал свой аплифт каждой конфигурации

ProjectPricingExportOptions.SaleMarkup всегда был 0 при экспорте с
проекта, поэтому применялся единый дефолт 1,3 вместо сохранённого в
Notes конфигурации значения. buildPricingExportBlock теперь берёт
аплифт из настроек каждой конфигурации — экспорт с проекта идёт по
тому же конвейеру, что и экспорт с конкретной конфигурации.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-08-12 17:29:25 +03:00
co-authored by Claude Sonnet 5
parent 619967c954
commit c83e4ec9f9
3 changed files with 84 additions and 4 deletions
@@ -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.
+32 -3
View File
@@ -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)
+1 -1
View File
@@ -178,7 +178,7 @@
<label class="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
<input type="radio" name="export-basis" value="ddp" class="border-gray-300">
<span class="font-medium">DDP</span>
<span class="text-gray-400">— Цена продажи ×1,3</span>
<span class="text-gray-400">— Цена продажи, аплифт свой для каждой конфигурации</span>
</label>
</div>
</div>