feat: сохранение и экспорт ручной цены (buy/sale) из вкладки Ценообразование

Сохранение:
- restoreAutosaveDraftIfAny теперь восстанавливает pricing_ui из notes драфта
- saveConfigOnExit привязан к pagehide и visibilitychange — цены сохраняются
  на сервер при уходе со страницы без явного нажатия «Сохранить»

Экспорт CSV:
- exportPricingCSV передаёт manual_price (buy для FOB, sale для DDP)
- ProjectPricingExportOptions.ManualPrice *float64 — новое поле
- distributeManualPrice распределяет ручную цену пропорционально estimate
  с коррекцией остатка на последней строке
- Колонка «Ручная цена» в CSV (заголовок, строки, итог конфига)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Mikhail Chusavitin
2026-06-17 09:59:27 +03:00
parent c6385f6cf1
commit 76d93c6be8
2 changed files with 85 additions and 18 deletions
+73 -18
View File
@@ -53,13 +53,14 @@ 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" Basis string `json:"basis"` // "fob" or "ddp"; empty defaults to "fob"
SaleMarkup float64 `json:"sale_markup"` // DDP multiplier; 0 defaults to 1.3 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 { func (o ProjectPricingExportOptions) saleMarkupFactor() float64 {
@@ -87,14 +88,15 @@ type ProjectPricingExportConfig struct {
} }
type ProjectPricingExportRow struct { type ProjectPricingExportRow struct {
LotDisplay string LotDisplay string
VendorPN string VendorPN string
Description string Description string
Quantity int Quantity int
BOMTotal *float64 BOMTotal *float64
Estimate *float64 Estimate *float64
Stock *float64 Stock *float64
Competitor *float64 Competitor *float64
ManualPrice *float64 // proportional share of the user-defined total price
} }
// ToCSV writes project export data in the new structured CSV format. // 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() { if opts.isDDP() {
applyDDPMarkup(block.Rows, opts.saleMarkupFactor()) applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
} }
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
distributeManualPrice(block.Rows, *opts.ManualPrice)
}
return block, nil return block, nil
} }
@@ -464,6 +469,9 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
if opts.isDDP() { if opts.isDDP() {
applyDDPMarkup(block.Rows, opts.saleMarkupFactor()) applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
} }
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
distributeManualPrice(block.Rows, *opts.ManualPrice)
}
return block, nil return block, nil
} }
@@ -716,6 +724,44 @@ func computeMappingTotal(priceMap map[string]pricingLevels, mappings []localdb.V
return floatPtr(total) 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 { func computeSingleLotTotal(priceMap map[string]pricingLevels, lotName string, qty int, selector func(pricingLevels) *float64) *float64 {
price := selector(priceMap[lotName]) price := selector(priceMap[lotName])
if price == nil || *price <= 0 { if price == nil || *price <= 0 {
@@ -744,7 +790,7 @@ func estimateOnlyTotal(estimatePrice *float64, fallbackUnitPrice float64, quanti
} }
func pricingCSVHeaders(opts ProjectPricingExportOptions) []string { func pricingCSVHeaders(opts ProjectPricingExportOptions) []string {
headers := make([]string, 0, 8) headers := make([]string, 0, 9)
headers = append(headers, "Line Item") headers = append(headers, "Line Item")
if opts.IncludeLOT { if opts.IncludeLOT {
headers = append(headers, "LOT") headers = append(headers, "LOT")
@@ -762,11 +808,14 @@ func pricingCSVHeaders(opts ProjectPricingExportOptions) []string {
if opts.IncludeCompetitor { if opts.IncludeCompetitor {
headers = append(headers, "Конкуренты") headers = append(headers, "Конкуренты")
} }
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
headers = append(headers, "Ручная цена")
}
return headers return headers
} }
func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions) []string { func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions) []string {
record := make([]string, 0, 8) record := make([]string, 0, 9)
record = append(record, "") record = append(record, "")
if opts.IncludeLOT { if opts.IncludeLOT {
record = append(record, emptyDash(row.LotDisplay)) record = append(record, emptyDash(row.LotDisplay))
@@ -788,11 +837,14 @@ func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions
if opts.IncludeCompetitor { if opts.IncludeCompetitor {
record = append(record, formatMoneyValue(row.Competitor)) record = append(record, formatMoneyValue(row.Competitor))
} }
if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
record = append(record, formatMoneyValue(row.ManualPrice))
}
return record return record
} }
func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricingExportOptions) []string { 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)) record = append(record, fmt.Sprintf("%d", cfg.Line))
if opts.IncludeLOT { if opts.IncludeLOT {
record = append(record, "") record = append(record, "")
@@ -814,6 +866,9 @@ func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricing
if opts.IncludeCompetitor { if opts.IncludeCompetitor {
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Competitor }))) 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 return record
} }
+12
View File
@@ -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 // Load vendor spec BOM for this configuration
if (configUUID) { if (configUUID) {
loadVendorSpec(configUUID); loadVendorSpec(configUUID);
@@ -2440,6 +2446,9 @@ function restoreAutosaveDraftIfAny() {
customPriceInput.value = ''; customPriceInput.value = '';
} }
} }
if (payload.notes) {
restorePricingStateFromNotes(payload.notes);
}
hasUnsavedChanges = true; hasUnsavedChanges = true;
} catch (_) { } catch (_) {
// ignore invalid draft // ignore invalid draft
@@ -4388,6 +4397,8 @@ function setPricingCustomPriceFromVendor() {
async function exportPricingCSV(table) { async function exportPricingCSV(table) {
if (!configUUID) { showToast('Сохраните конфигурацию перед экспортом', 'error'); return; } if (!configUUID) { showToast('Сохраните конфигурацию перед экспортом', 'error'); return; }
const basis = table === 'sale' ? 'ddp' : 'fob'; 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 { try {
const resp = await fetch(`/api/configs/${configUUID}/export/pricing`, { const resp = await fetch(`/api/configs/${configUUID}/export/pricing`, {
method: 'POST', method: 'POST',
@@ -4399,6 +4410,7 @@ async function exportPricingCSV(table) {
include_stock: true, include_stock: true,
include_competitor: true, include_competitor: true,
basis: basis, basis: basis,
manual_price: manualPrice > 0 ? manualPrice : null,
}), }),
}); });
if (!resp.ok) { showToast('Ошибка экспорта', 'error'); return; } if (!resp.ok) { showToast('Ошибка экспорта', 'error'); return; }