feat: redesign project pricing export — FOB/DDP basis, variant filename, article column

- Add FOB/DDP basis to export options; DDP multiplies all prices ×1.3
- Rename export file from "pricing" to "{FOB|DDP} {variant}" (e.g. "FOB v1")
- Fix server article missing from CSV summary row (PN вендора column)
- Skip per-row breakdown when neither LOT nor BOM is selected
- Remove empty separator rows between configurations
- Redesign export modal: split into Артикул / Цены / Базис поставки sections

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-04-03 17:55:26 +03:00
parent a360992a01
commit 7f6be786a8
3 changed files with 121 additions and 47 deletions

View File

@@ -53,6 +53,8 @@ type ProjectExportOptionsRequest struct {
IncludeEstimate bool `json:"include_estimate"` IncludeEstimate bool `json:"include_estimate"`
IncludeStock bool `json:"include_stock"` IncludeStock bool `json:"include_stock"`
IncludeCompetitor bool `json:"include_competitor"` IncludeCompetitor bool `json:"include_competitor"`
Basis string `json:"basis"` // "fob" or "ddp"
SaleMarkup float64 `json:"sale_markup"` // DDP multiplier; 0 defaults to 1.3
} }
func (h *ExportHandler) ExportCSV(c *gin.Context) { func (h *ExportHandler) ExportCSV(c *gin.Context) {
@@ -252,6 +254,8 @@ func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) {
IncludeEstimate: req.IncludeEstimate, IncludeEstimate: req.IncludeEstimate,
IncludeStock: req.IncludeStock, IncludeStock: req.IncludeStock,
IncludeCompetitor: req.IncludeCompetitor, IncludeCompetitor: req.IncludeCompetitor,
Basis: req.Basis,
SaleMarkup: req.SaleMarkup,
} }
data, err := h.exportService.ProjectToPricingExportData(result.Configs, opts) data, err := h.exportService.ProjectToPricingExportData(result.Configs, opts)
@@ -260,7 +264,15 @@ func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) {
return return
} }
filename := fmt.Sprintf("%s (%s) pricing.csv", time.Now().Format("2006-01-02"), project.Code) basisLabel := "FOB"
if strings.EqualFold(strings.TrimSpace(req.Basis), "ddp") {
basisLabel = "DDP"
}
variantLabel := strings.TrimSpace(project.Variant)
if variantLabel == "" {
variantLabel = "main"
}
filename := fmt.Sprintf("%s (%s) %s %s.csv", time.Now().Format("2006-01-02"), project.Code, basisLabel, variantLabel)
c.Header("Content-Type", "text/csv; charset=utf-8") c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename)) c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))

View File

@@ -61,6 +61,19 @@ type ProjectPricingExportOptions struct {
IncludeEstimate bool `json:"include_estimate"` IncludeEstimate bool `json:"include_estimate"`
IncludeStock bool `json:"include_stock"` IncludeStock bool `json:"include_stock"`
IncludeCompetitor bool `json:"include_competitor"` IncludeCompetitor bool `json:"include_competitor"`
Basis string `json:"basis"` // "fob" or "ddp"; empty defaults to "fob"
SaleMarkup float64 `json:"sale_markup"` // DDP multiplier; 0 defaults to 1.3
}
func (o ProjectPricingExportOptions) saleMarkupFactor() float64 {
if o.SaleMarkup > 0 {
return o.SaleMarkup
}
return 1.3
}
func (o ProjectPricingExportOptions) isDDP() bool {
return strings.EqualFold(strings.TrimSpace(o.Basis), "ddp")
} }
type ProjectPricingExportData struct { type ProjectPricingExportData struct {
@@ -251,19 +264,17 @@ func (s *ExportService) ToPricingCSV(w io.Writer, data *ProjectPricingExportData
return fmt.Errorf("failed to write pricing header: %w", err) return fmt.Errorf("failed to write pricing header: %w", err)
} }
for idx, cfg := range data.Configs { writeRows := opts.IncludeLOT || opts.IncludeBOM
for _, cfg := range data.Configs {
if err := csvWriter.Write(pricingConfigSummaryRow(cfg, opts)); err != nil { if err := csvWriter.Write(pricingConfigSummaryRow(cfg, opts)); err != nil {
return fmt.Errorf("failed to write config summary row: %w", err) return fmt.Errorf("failed to write config summary row: %w", err)
} }
if writeRows {
for _, row := range cfg.Rows { for _, row := range cfg.Rows {
if err := csvWriter.Write(pricingCSVRow(row, opts)); err != nil { if err := csvWriter.Write(pricingCSVRow(row, opts)); err != nil {
return fmt.Errorf("failed to write pricing row: %w", err) return fmt.Errorf("failed to write pricing row: %w", err)
} }
} }
if idx < len(data.Configs)-1 {
if err := csvWriter.Write([]string{}); err != nil {
return fmt.Errorf("failed to write separator row: %w", err)
}
} }
} }
@@ -424,6 +435,9 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity), Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity),
}) })
} }
if opts.isDDP() {
applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
}
return block, nil return block, nil
} }
@@ -443,9 +457,29 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
}) })
} }
if opts.isDDP() {
applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
}
return block, nil return block, nil
} }
func applyDDPMarkup(rows []ProjectPricingExportRow, factor float64) {
for i := range rows {
rows[i].Estimate = scaleFloatPtr(rows[i].Estimate, factor)
rows[i].Stock = scaleFloatPtr(rows[i].Stock, factor)
rows[i].Competitor = scaleFloatPtr(rows[i].Competitor, factor)
}
}
func scaleFloatPtr(v *float64, factor float64) *float64 {
if v == nil {
return nil
}
result := *v * factor
return &result
}
// resolveCategories returns lot_name → category map. // resolveCategories returns lot_name → category map.
// Primary source: pricelist items (lot_category). Fallback: local_components table. // Primary source: pricelist items (lot_category). Fallback: local_components table.
func (s *ExportService) resolveCategories(pricelistID *uint, lotNames []string) map[string]string { func (s *ExportService) resolveCategories(pricelistID *uint, lotNames []string) map[string]string {
@@ -735,7 +769,7 @@ func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricing
record = append(record, "") record = append(record, "")
} }
record = append(record, record = append(record,
"", emptyDash(cfg.Article),
emptyDash(cfg.Name), emptyDash(cfg.Name),
fmt.Sprintf("%d", exportPositiveInt(cfg.ServerCount, 1)), fmt.Sprintf("%d", exportPositiveInt(cfg.ServerCount, 1)),
) )

View File

@@ -113,23 +113,31 @@
<div id="project-export-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50"> <div id="project-export-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6"> <div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<h2 class="text-xl font-semibold mb-4">Экспорт CSV</h2> <h2 class="text-xl font-semibold mb-5">Экспорт CSV</h2>
<div class="space-y-4"> <div class="space-y-5">
<div class="text-sm text-gray-600">
Экспортирует проект в формате вкладки ценообразования. Если включён `BOM`, строки строятся по BOM; иначе по текущему Estimate. <!-- Section 1: Артикул -->
</div> <div>
<div class="space-y-3"> <p class="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-2">Артикул</p>
<div class="space-y-2">
<label class="flex items-center gap-3 text-sm text-gray-700"> <label class="flex items-center gap-3 text-sm text-gray-700">
<input id="export-col-lot" type="checkbox" class="rounded border-gray-300" checked> <input id="export-col-lot" type="checkbox" class="rounded border-gray-300" checked>
<span>LOT</span> <span>LOT</span>
</label> </label>
<label class="flex items-center gap-3 text-sm text-gray-700"> <label class="flex items-center gap-3 text-sm text-gray-700">
<input id="export-col-bom" type="checkbox" class="rounded border-gray-300"> <input id="export-col-bom" type="checkbox" class="rounded border-gray-300">
<span>BOM</span> <span>BOM <span class="text-gray-400 font-normal">(строки по BOM, иначе по Estimate)</span></span>
</label> </label>
</div>
</div>
<!-- Section 2: Цены -->
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-2">Цены</p>
<div class="space-y-2">
<label class="flex items-center gap-3 text-sm text-gray-700"> <label class="flex items-center gap-3 text-sm text-gray-700">
<input id="export-col-estimate" type="checkbox" class="rounded border-gray-300" checked> <input id="export-col-estimate" type="checkbox" class="rounded border-gray-300" checked>
<span>Estimate</span> <span>Est</span>
</label> </label>
<label class="flex items-center gap-3 text-sm text-gray-700"> <label class="flex items-center gap-3 text-sm text-gray-700">
<input id="export-col-stock" type="checkbox" class="rounded border-gray-300"> <input id="export-col-stock" type="checkbox" class="rounded border-gray-300">
@@ -140,6 +148,25 @@
<span>Конкуренты</span> <span>Конкуренты</span>
</label> </label>
</div> </div>
</div>
<!-- Section 3: Базис поставки -->
<div>
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-2">Базис поставки</p>
<div class="flex gap-6">
<label class="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
<input type="radio" name="export-basis" value="fob" class="border-gray-300" checked>
<span class="font-medium">FOB</span>
<span class="text-gray-400">— Цена покупки</span>
</label>
<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>
</label>
</div>
</div>
<div id="project-export-status" class="hidden text-sm rounded border px-3 py-2"></div> <div id="project-export-status" class="hidden text-sm rounded border px-3 py-2"></div>
</div> </div>
<div class="flex justify-end space-x-3 mt-6"> <div class="flex justify-end space-x-3 mt-6">
@@ -1475,7 +1502,8 @@ async function exportProject() {
include_bom: !!document.getElementById('export-col-bom')?.checked, include_bom: !!document.getElementById('export-col-bom')?.checked,
include_estimate: !!document.getElementById('export-col-estimate')?.checked, include_estimate: !!document.getElementById('export-col-estimate')?.checked,
include_stock: !!document.getElementById('export-col-stock')?.checked, include_stock: !!document.getElementById('export-col-stock')?.checked,
include_competitor: !!document.getElementById('export-col-competitor')?.checked include_competitor: !!document.getElementById('export-col-competitor')?.checked,
basis: document.querySelector('input[name="export-basis"]:checked')?.value || 'fob'
}; };
if (submitBtn) submitBtn.disabled = true; if (submitBtn) submitBtn.disabled = true;