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:
Mikhail Chusavitin
2026-09-15 16:39:54 +03:00
co-authored by Claude Sonnet 5
parent fb412a4227
commit 7263dd4572
5 changed files with 385 additions and 100 deletions
+18
View File
@@ -55,6 +55,15 @@ type ProjectExportOptionsRequest struct {
IncludeCompetitor bool `json:"include_competitor"`
Basis string `json:"basis"` // "fob" or "ddp"
SaleMarkup float64 `json:"sale_markup"` // DDP multiplier; 0 defaults to 1.3
ManualPrice *float64 `json:"manual_price"` // user-defined total price; distributed proportionally across rows
// MinimalSaleColumns/SaleRowMarkups are sent only by the single-config Sale-table
// "Экспорт CSV" button (index.html exportPricingCSV('sale')) — never by the Buy
// export or the project-level bulk export, which always leave these zero-valued so
// their output stays unchanged. See services.ProjectPricingExportOptions.
MinimalSaleColumns bool `json:"minimal_sale_columns"`
SaleRowMarkups map[string]float64 `json:"sale_row_markups"` // "<vendorPN>::<lot>" -> markup percent
}
func (h *ExportHandler) ExportCSV(c *gin.Context) {
@@ -254,6 +263,15 @@ func (h *ExportHandler) ExportConfigPricingCSV(c *gin.Context) {
Basis: req.Basis,
SaleMarkup: req.SaleMarkup,
}
// MinimalSaleColumns/SaleRowMarkups/ManualPrice are only ever sent by the Sale
// table's own "Экспорт CSV" button (index.html exportPricingCSV('sale')) — gating
// on the request's own flag keeps every other caller (Buy export, any other basis=
// ddp caller) byte-identical to before this field existed.
if req.MinimalSaleColumns {
opts.MinimalSaleColumns = true
opts.SaleRowMarkups = req.SaleRowMarkups
opts.ManualPrice = req.ManualPrice
}
data, err := h.exportService.ConfigToPricingExportData(config, opts)
if err != nil {
+101 -20
View File
@@ -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 "—"