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
+101
-20
@@ -59,9 +59,16 @@ type ProjectPricingExportOptions struct {
|
||||
IncludeEstimate bool `json:"include_estimate"`
|
||||
IncludeStock bool `json:"include_stock"`
|
||||
IncludeCompetitor bool `json:"include_competitor"`
|
||||
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
|
||||
ManualPrice *float64 `json:"manual_price"` // user-defined total price; distributed proportionally across rows
|
||||
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
|
||||
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.
|
||||
@@ -294,10 +301,14 @@ func (s *ExportService) ToPricingCSV(w io.Writer, data *ProjectPricingExportData
|
||||
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 {
|
||||
if err := csvWriter.Write(pricingConfigSummaryRow(cfg, opts)); err != nil {
|
||||
return fmt.Errorf("failed to write config summary row: %w", err)
|
||||
// Minimal mode (single-config Sale export) skips the per-config summary line —
|
||||
// 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 {
|
||||
for _, row := range cfg.Rows {
|
||||
@@ -485,12 +496,7 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
|
||||
CompetitorWorld: priceMap[lot].CompetitorWorld,
|
||||
})
|
||||
}
|
||||
if opts.isDDP() {
|
||||
applyDDPMarkup(block.Rows, opts.effectiveSaleMarkupFactor(cfg))
|
||||
}
|
||||
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
|
||||
distributeManualPrice(block.Rows, *opts.ManualPrice)
|
||||
}
|
||||
applySalePricing(block.Rows, cfg, opts)
|
||||
return block, nil
|
||||
}
|
||||
|
||||
@@ -522,16 +528,29 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
|
||||
})
|
||||
}
|
||||
|
||||
if opts.isDDP() {
|
||||
applyDDPMarkup(block.Rows, opts.effectiveSaleMarkupFactor(cfg))
|
||||
}
|
||||
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
|
||||
distributeManualPrice(block.Rows, *opts.ManualPrice)
|
||||
}
|
||||
applySalePricing(block.Rows, cfg, opts)
|
||||
|
||||
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.
|
||||
// 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 {
|
||||
@@ -550,7 +569,8 @@ func sortConfigItemsByCategoryMap(items models.ConfigItems, catOrder map[string]
|
||||
|
||||
// stockCompetitorMarkupFactor is the fixed DDP multiplier applied to Stock and
|
||||
// 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
|
||||
|
||||
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 {
|
||||
if v == nil {
|
||||
return nil
|
||||
@@ -861,6 +932,9 @@ func estimateOnlyTotal(estimatePrice *float64, fallbackUnitPrice float64, quanti
|
||||
}
|
||||
|
||||
func pricingCSVHeaders(opts ProjectPricingExportOptions) []string {
|
||||
if opts.MinimalSaleColumns {
|
||||
return []string{"LOT", "Описание", "Кол-во", "Цена"}
|
||||
}
|
||||
headers := make([]string, 0, 9)
|
||||
headers = append(headers, "Line Item")
|
||||
if opts.IncludeLOT {
|
||||
@@ -887,6 +961,14 @@ func pricingCSVHeaders(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 = append(record, "")
|
||||
if opts.IncludeLOT {
|
||||
@@ -960,7 +1042,6 @@ func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricing
|
||||
return record
|
||||
}
|
||||
|
||||
|
||||
func formatMoneyValue(value *float64) string {
|
||||
if value == nil {
|
||||
return "—"
|
||||
|
||||
Reference in New Issue
Block a user