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:
@@ -48,11 +48,13 @@ type ExportRequest struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ProjectExportOptionsRequest struct {
|
type ProjectExportOptionsRequest struct {
|
||||||
IncludeLOT bool `json:"include_lot"`
|
IncludeLOT bool `json:"include_lot"`
|
||||||
IncludeBOM bool `json:"include_bom"`
|
IncludeBOM bool `json:"include_bom"`
|
||||||
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))
|
||||||
|
|
||||||
|
|||||||
@@ -56,11 +56,24 @@ type ProjectExportData struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type ProjectPricingExportOptions struct {
|
type ProjectPricingExportOptions struct {
|
||||||
IncludeLOT bool `json:"include_lot"`
|
IncludeLOT bool `json:"include_lot"`
|
||||||
IncludeBOM bool `json:"include_bom"`
|
IncludeBOM bool `json:"include_bom"`
|
||||||
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,18 +264,16 @@ 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)
|
||||||
}
|
}
|
||||||
for _, row := range cfg.Rows {
|
if writeRows {
|
||||||
if err := csvWriter.Write(pricingCSVRow(row, opts)); err != nil {
|
for _, row := range cfg.Rows {
|
||||||
return fmt.Errorf("failed to write pricing row: %w", err)
|
if err := csvWriter.Write(pricingCSVRow(row, opts)); err != nil {
|
||||||
}
|
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)),
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -113,33 +113,60 @@
|
|||||||
|
|
||||||
<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>
|
||||||
|
<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">
|
||||||
|
<input id="export-col-lot" type="checkbox" class="rounded border-gray-300" checked>
|
||||||
|
<span>LOT</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-3 text-sm text-gray-700">
|
||||||
|
<input id="export-col-bom" type="checkbox" class="rounded border-gray-300">
|
||||||
|
<span>BOM <span class="text-gray-400 font-normal">(строки по BOM, иначе по Estimate)</span></span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="space-y-3">
|
|
||||||
<label class="flex items-center gap-3 text-sm text-gray-700">
|
<!-- Section 2: Цены -->
|
||||||
<input id="export-col-lot" type="checkbox" class="rounded border-gray-300" checked>
|
<div>
|
||||||
<span>LOT</span>
|
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-2">Цены</p>
|
||||||
</label>
|
<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-bom" type="checkbox" class="rounded border-gray-300">
|
<input id="export-col-estimate" type="checkbox" class="rounded border-gray-300" checked>
|
||||||
<span>BOM</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-estimate" type="checkbox" class="rounded border-gray-300" checked>
|
<input id="export-col-stock" type="checkbox" class="rounded border-gray-300">
|
||||||
<span>Estimate</span>
|
<span>Stock</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-competitor" type="checkbox" class="rounded border-gray-300">
|
||||||
<span>Stock</span>
|
<span>Конкуренты</span>
|
||||||
</label>
|
</label>
|
||||||
<label class="flex items-center gap-3 text-sm text-gray-700">
|
</div>
|
||||||
<input id="export-col-competitor" type="checkbox" class="rounded border-gray-300">
|
|
||||||
<span>Конкуренты</span>
|
|
||||||
</label>
|
|
||||||
</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;
|
||||||
|
|||||||
Reference in New Issue
Block a user