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:
@@ -60,6 +60,7 @@ type ProjectPricingExportOptions struct {
|
|||||||
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 {
|
||||||
@@ -95,6 +96,7 @@ type ProjectPricingExportRow struct {
|
|||||||
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
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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; }
|
||||||
|
|||||||
Reference in New Issue
Block a user