diff --git a/internal/services/export.go b/internal/services/export.go index 1db4025..365f084 100644 --- a/internal/services/export.go +++ b/internal/services/export.go @@ -53,13 +53,14 @@ type ProjectExportData struct { } type ProjectPricingExportOptions struct { - IncludeLOT bool `json:"include_lot"` - IncludeBOM bool `json:"include_bom"` - 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 multiplier; 0 defaults to 1.3 + IncludeLOT bool `json:"include_lot"` + IncludeBOM bool `json:"include_bom"` + 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 multiplier; 0 defaults to 1.3 + ManualPrice *float64 `json:"manual_price"` // user-defined total price; distributed proportionally across rows } func (o ProjectPricingExportOptions) saleMarkupFactor() float64 { @@ -87,14 +88,15 @@ type ProjectPricingExportConfig struct { } type ProjectPricingExportRow struct { - LotDisplay string - VendorPN string - Description string - Quantity int - BOMTotal *float64 - Estimate *float64 - Stock *float64 - Competitor *float64 + LotDisplay string + VendorPN string + Description string + Quantity int + BOMTotal *float64 + Estimate *float64 + Stock *float64 + Competitor *float64 + ManualPrice *float64 // proportional share of the user-defined total price } // ToCSV writes project export data in the new structured CSV format. @@ -442,6 +444,9 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts if opts.isDDP() { applyDDPMarkup(block.Rows, opts.saleMarkupFactor()) } + if opts.ManualPrice != nil && *opts.ManualPrice > 0 { + distributeManualPrice(block.Rows, *opts.ManualPrice) + } return block, nil } @@ -464,6 +469,9 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts if opts.isDDP() { applyDDPMarkup(block.Rows, opts.saleMarkupFactor()) } + if opts.ManualPrice != nil && *opts.ManualPrice > 0 { + distributeManualPrice(block.Rows, *opts.ManualPrice) + } return block, nil } @@ -716,6 +724,44 @@ func computeMappingTotal(priceMap map[string]pricingLevels, mappings []localdb.V return floatPtr(total) } +// distributeManualPrice sets ManualPrice on each row proportionally based on the +// row's Estimate share. The last row with a price absorbs rounding remainder so +// the sum of ManualPrice values always equals manualPrice exactly. +func distributeManualPrice(rows []ProjectPricingExportRow, manualPrice float64) { + if manualPrice <= 0 || len(rows) == 0 { + return + } + totalEstimate := 0.0 + for _, row := range rows { + if row.Estimate != nil && *row.Estimate > 0 { + totalEstimate += *row.Estimate + } + } + if totalEstimate <= 0 { + return + } + lastIdx := -1 + for i, row := range rows { + if row.Estimate != nil && *row.Estimate > 0 { + lastIdx = i + } + } + assigned := 0.0 + for i, row := range rows { + if row.Estimate == nil || *row.Estimate <= 0 { + continue + } + var share float64 + if i == lastIdx { + share = math.Round((manualPrice-assigned)*100) / 100 + } else { + share = math.Round((*row.Estimate/totalEstimate)*manualPrice*100) / 100 + assigned += share + } + rows[i].ManualPrice = floatPtr(share) + } +} + func computeSingleLotTotal(priceMap map[string]pricingLevels, lotName string, qty int, selector func(pricingLevels) *float64) *float64 { price := selector(priceMap[lotName]) if price == nil || *price <= 0 { @@ -744,7 +790,7 @@ func estimateOnlyTotal(estimatePrice *float64, fallbackUnitPrice float64, quanti } func pricingCSVHeaders(opts ProjectPricingExportOptions) []string { - headers := make([]string, 0, 8) + headers := make([]string, 0, 9) headers = append(headers, "Line Item") if opts.IncludeLOT { headers = append(headers, "LOT") @@ -762,11 +808,14 @@ func pricingCSVHeaders(opts ProjectPricingExportOptions) []string { if opts.IncludeCompetitor { headers = append(headers, "Конкуренты") } + if opts.ManualPrice != nil && *opts.ManualPrice > 0 { + headers = append(headers, "Ручная цена") + } return headers } func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions) []string { - record := make([]string, 0, 8) + record := make([]string, 0, 9) record = append(record, "") if opts.IncludeLOT { record = append(record, emptyDash(row.LotDisplay)) @@ -788,11 +837,14 @@ func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions if opts.IncludeCompetitor { record = append(record, formatMoneyValue(row.Competitor)) } + if opts.ManualPrice != nil && *opts.ManualPrice > 0 { + record = append(record, formatMoneyValue(row.ManualPrice)) + } return record } func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricingExportOptions) []string { - record := make([]string, 0, 8) + record := make([]string, 0, 9) record = append(record, fmt.Sprintf("%d", cfg.Line)) if opts.IncludeLOT { record = append(record, "") @@ -814,6 +866,9 @@ func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricing if opts.IncludeCompetitor { record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Competitor }))) } + if opts.ManualPrice != nil && *opts.ManualPrice > 0 { + record = append(record, formatMoneyValue(opts.ManualPrice)) + } return record } diff --git a/web/templates/index.html b/web/templates/index.html index 8250d53..f22dfd3 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -879,6 +879,12 @@ document.addEventListener('DOMContentLoaded', async function() { } }); + // Save pricing state (ручная цена) on page exit so it survives navigation + window.addEventListener('pagehide', saveConfigOnExit); + document.addEventListener('visibilitychange', () => { + if (document.visibilityState === 'hidden') saveConfigOnExit(); + }); + // Load vendor spec BOM for this configuration if (configUUID) { loadVendorSpec(configUUID); @@ -2440,6 +2446,9 @@ function restoreAutosaveDraftIfAny() { customPriceInput.value = ''; } } + if (payload.notes) { + restorePricingStateFromNotes(payload.notes); + } hasUnsavedChanges = true; } catch (_) { // ignore invalid draft @@ -4388,6 +4397,8 @@ function setPricingCustomPriceFromVendor() { async function exportPricingCSV(table) { if (!configUUID) { showToast('Сохраните конфигурацию перед экспортом', 'error'); return; } const basis = table === 'sale' ? 'ddp' : 'fob'; + const manualInputId = table === 'sale' ? 'pricing-custom-price-sale' : 'pricing-custom-price-buy'; + const manualPrice = parseDecimalInput(document.getElementById(manualInputId)?.value || ''); try { const resp = await fetch(`/api/configs/${configUUID}/export/pricing`, { method: 'POST', @@ -4399,6 +4410,7 @@ async function exportPricingCSV(table) { include_stock: true, include_competitor: true, basis: basis, + manual_price: manualPrice > 0 ? manualPrice : null, }), }); if (!resp.ok) { showToast('Ошибка экспорта', 'error'); return; }