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 (
|
||||
"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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user