fix: DDP-экспорт с проекта игнорировал свой аплифт каждой конфигурации
ProjectPricingExportOptions.SaleMarkup всегда был 0 при экспорте с проекта, поэтому применялся единый дефолт 1,3 вместо сохранённого в Notes конфигурации значения. buildPricingExportBlock теперь берёт аплифт из настроек каждой конфигурации — экспорт с проекта идёт по тому же конвейеру, что и экспорт с конкретной конфигурации. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
619967c954
commit
c83e4ec9f9
@@ -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.
|
||||||
@@ -3,6 +3,7 @@ package services
|
|||||||
import (
|
import (
|
||||||
"bytes"
|
"bytes"
|
||||||
"encoding/csv"
|
"encoding/csv"
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"io"
|
"io"
|
||||||
"math"
|
"math"
|
||||||
@@ -68,13 +69,41 @@ type ProjectPricingExportOptions struct {
|
|||||||
// other API callers that omit sale_markup.
|
// other API callers that omit sale_markup.
|
||||||
const defaultSaleMarkup = 1.3
|
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 {
|
if o.SaleMarkup > 0 {
|
||||||
return o.SaleMarkup
|
return o.SaleMarkup
|
||||||
}
|
}
|
||||||
|
if v := configSavedSaleUplift(cfg); v > 0 {
|
||||||
|
return v
|
||||||
|
}
|
||||||
return defaultSaleMarkup
|
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 {
|
func (o ProjectPricingExportOptions) isDDP() bool {
|
||||||
return strings.EqualFold(strings.TrimSpace(o.Basis), "ddp")
|
return strings.EqualFold(strings.TrimSpace(o.Basis), "ddp")
|
||||||
}
|
}
|
||||||
@@ -457,7 +486,7 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if opts.isDDP() {
|
if opts.isDDP() {
|
||||||
applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
|
applyDDPMarkup(block.Rows, opts.effectiveSaleMarkupFactor(cfg))
|
||||||
}
|
}
|
||||||
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
|
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
|
||||||
distributeManualPrice(block.Rows, *opts.ManualPrice)
|
distributeManualPrice(block.Rows, *opts.ManualPrice)
|
||||||
@@ -494,7 +523,7 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
|
|||||||
}
|
}
|
||||||
|
|
||||||
if opts.isDDP() {
|
if opts.isDDP() {
|
||||||
applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
|
applyDDPMarkup(block.Rows, opts.effectiveSaleMarkupFactor(cfg))
|
||||||
}
|
}
|
||||||
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
|
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
|
||||||
distributeManualPrice(block.Rows, *opts.ManualPrice)
|
distributeManualPrice(block.Rows, *opts.ManualPrice)
|
||||||
|
|||||||
@@ -178,7 +178,7 @@
|
|||||||
<label class="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
|
<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">
|
<input type="radio" name="export-basis" value="ddp" class="border-gray-300">
|
||||||
<span class="font-medium">DDP</span>
|
<span class="font-medium">DDP</span>
|
||||||
<span class="text-gray-400">— Цена продажи ×1,3</span>
|
<span class="text-gray-400">— Цена продажи, аплифт свой для каждой конфигурации</span>
|
||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user