- 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>
904 lines
25 KiB
Go
904 lines
25 KiB
Go
package services
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/csv"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"git.mchus.pro/mchus/quoteforge/internal/config"
|
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
|
)
|
|
|
|
type ExportService struct {
|
|
config config.ExportConfig
|
|
categoryRepo *repository.CategoryRepository
|
|
localDB *localdb.LocalDB
|
|
}
|
|
|
|
func NewExportService(cfg config.ExportConfig, categoryRepo *repository.CategoryRepository, local *localdb.LocalDB) *ExportService {
|
|
return &ExportService{
|
|
config: cfg,
|
|
categoryRepo: categoryRepo,
|
|
localDB: local,
|
|
}
|
|
}
|
|
|
|
// ExportItem represents a single component in an export block.
|
|
type ExportItem struct {
|
|
LotName string
|
|
Description string
|
|
Category string
|
|
Quantity int
|
|
UnitPrice float64
|
|
TotalPrice float64
|
|
}
|
|
|
|
// ConfigExportBlock represents one configuration (server) in the export.
|
|
type ConfigExportBlock struct {
|
|
Article string
|
|
Line int
|
|
ServerCount int
|
|
UnitPrice float64 // sum of component prices for one server
|
|
Items []ExportItem
|
|
}
|
|
|
|
// ProjectExportData holds all configuration blocks for a project-level export.
|
|
type ProjectExportData struct {
|
|
Configs []ConfigExportBlock
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
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 {
|
|
Configs []ProjectPricingExportConfig
|
|
CreatedAt time.Time
|
|
}
|
|
|
|
type ProjectPricingExportConfig struct {
|
|
Name string
|
|
Article string
|
|
Line int
|
|
ServerCount int
|
|
Rows []ProjectPricingExportRow
|
|
}
|
|
|
|
type ProjectPricingExportRow struct {
|
|
LotDisplay string
|
|
VendorPN string
|
|
Description string
|
|
Quantity int
|
|
BOMTotal *float64
|
|
Estimate *float64
|
|
Stock *float64
|
|
Competitor *float64
|
|
}
|
|
|
|
// ToCSV writes project export data in the new structured CSV format.
|
|
//
|
|
// Format:
|
|
//
|
|
// Line;Type;p/n;Description;Qty (1 pcs.);Qty (total);Price (1 pcs.);Price (total)
|
|
// 10;;DL380-ARTICLE;;;10;10470;104 700
|
|
// ;;MB_INTEL_...;;1;;2074,5;
|
|
// ...
|
|
// (empty row)
|
|
// 20;;DL380-ARTICLE-2;;;2;10470;20 940
|
|
// ...
|
|
func (s *ExportService) ToCSV(w io.Writer, data *ProjectExportData) error {
|
|
// Write UTF-8 BOM for Excel compatibility
|
|
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
|
|
return fmt.Errorf("failed to write BOM: %w", err)
|
|
}
|
|
|
|
csvWriter := csv.NewWriter(w)
|
|
csvWriter.Comma = ';'
|
|
defer csvWriter.Flush()
|
|
|
|
// Header
|
|
headers := []string{"Line", "Type", "p/n", "Description", "Qty (1 pcs.)", "Qty (total)", "Price (1 pcs.)", "Price (total)"}
|
|
if err := csvWriter.Write(headers); err != nil {
|
|
return fmt.Errorf("failed to write header: %w", err)
|
|
}
|
|
|
|
// Get category hierarchy for sorting
|
|
categoryOrder := make(map[string]int)
|
|
if s.categoryRepo != nil {
|
|
categories, err := s.categoryRepo.GetAll()
|
|
if err == nil {
|
|
for _, cat := range categories {
|
|
categoryOrder[cat.Code] = cat.DisplayOrder
|
|
}
|
|
}
|
|
}
|
|
|
|
for i, block := range data.Configs {
|
|
lineNo := block.Line
|
|
if lineNo <= 0 {
|
|
lineNo = (i + 1) * 10
|
|
}
|
|
|
|
serverCount := block.ServerCount
|
|
if serverCount < 1 {
|
|
serverCount = 1
|
|
}
|
|
|
|
totalPrice := block.UnitPrice * float64(serverCount)
|
|
|
|
// Server summary row
|
|
serverRow := []string{
|
|
fmt.Sprintf("%d", lineNo), // Line
|
|
"", // Type
|
|
block.Article, // p/n
|
|
"", // Description
|
|
"", // Qty (1 pcs.)
|
|
fmt.Sprintf("%d", serverCount), // Qty (total)
|
|
formatPriceInt(block.UnitPrice), // Price (1 pcs.)
|
|
formatPriceWithSpace(totalPrice), // Price (total)
|
|
}
|
|
if err := csvWriter.Write(serverRow); err != nil {
|
|
return fmt.Errorf("failed to write server row: %w", err)
|
|
}
|
|
|
|
// Sort items by category display order
|
|
sortedItems := make([]ExportItem, len(block.Items))
|
|
copy(sortedItems, block.Items)
|
|
sortItemsByCategory(sortedItems, categoryOrder)
|
|
|
|
// Component rows
|
|
for _, item := range sortedItems {
|
|
componentRow := []string{
|
|
"", // Line
|
|
item.Category, // Type
|
|
item.LotName, // p/n
|
|
"", // Description
|
|
fmt.Sprintf("%d", item.Quantity), // Qty (1 pcs.)
|
|
"", // Qty (total)
|
|
formatPriceComma(item.UnitPrice), // Price (1 pcs.)
|
|
"", // Price (total)
|
|
}
|
|
if err := csvWriter.Write(componentRow); err != nil {
|
|
return fmt.Errorf("failed to write component row: %w", err)
|
|
}
|
|
}
|
|
|
|
// Empty separator row between blocks (skip after last)
|
|
if i < len(data.Configs)-1 {
|
|
if err := csvWriter.Write([]string{"", "", "", "", "", "", "", ""}); err != nil {
|
|
return fmt.Errorf("failed to write separator row: %w", err)
|
|
}
|
|
}
|
|
}
|
|
|
|
csvWriter.Flush()
|
|
if err := csvWriter.Error(); err != nil {
|
|
return fmt.Errorf("csv writer error: %w", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// ToCSVBytes is a backward-compatible wrapper that returns CSV data as bytes.
|
|
func (s *ExportService) ToCSVBytes(data *ProjectExportData) ([]byte, error) {
|
|
var buf bytes.Buffer
|
|
if err := s.ToCSV(&buf, data); err != nil {
|
|
return nil, err
|
|
}
|
|
return buf.Bytes(), nil
|
|
}
|
|
|
|
func (s *ExportService) ProjectToPricingExportData(configs []models.Configuration, opts ProjectPricingExportOptions) (*ProjectPricingExportData, error) {
|
|
sortedConfigs := make([]models.Configuration, len(configs))
|
|
copy(sortedConfigs, configs)
|
|
sort.Slice(sortedConfigs, func(i, j int) bool {
|
|
leftLine := sortedConfigs[i].Line
|
|
rightLine := sortedConfigs[j].Line
|
|
|
|
if leftLine <= 0 {
|
|
leftLine = int(^uint(0) >> 1)
|
|
}
|
|
if rightLine <= 0 {
|
|
rightLine = int(^uint(0) >> 1)
|
|
}
|
|
if leftLine != rightLine {
|
|
return leftLine < rightLine
|
|
}
|
|
if !sortedConfigs[i].CreatedAt.Equal(sortedConfigs[j].CreatedAt) {
|
|
return sortedConfigs[i].CreatedAt.After(sortedConfigs[j].CreatedAt)
|
|
}
|
|
return sortedConfigs[i].UUID > sortedConfigs[j].UUID
|
|
})
|
|
|
|
blocks := make([]ProjectPricingExportConfig, 0, len(sortedConfigs))
|
|
for i := range sortedConfigs {
|
|
block, err := s.buildPricingExportBlock(&sortedConfigs[i], opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
blocks = append(blocks, block)
|
|
}
|
|
|
|
return &ProjectPricingExportData{
|
|
Configs: blocks,
|
|
CreatedAt: time.Now(),
|
|
}, nil
|
|
}
|
|
|
|
func (s *ExportService) ToPricingCSV(w io.Writer, data *ProjectPricingExportData, opts ProjectPricingExportOptions) error {
|
|
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
|
|
return fmt.Errorf("failed to write BOM: %w", err)
|
|
}
|
|
|
|
csvWriter := csv.NewWriter(w)
|
|
csvWriter.Comma = ';'
|
|
defer csvWriter.Flush()
|
|
|
|
headers := pricingCSVHeaders(opts)
|
|
if err := csvWriter.Write(headers); err != nil {
|
|
return fmt.Errorf("failed to write pricing header: %w", err)
|
|
}
|
|
|
|
writeRows := opts.IncludeLOT || opts.IncludeBOM
|
|
for _, cfg := range data.Configs {
|
|
if err := csvWriter.Write(pricingConfigSummaryRow(cfg, opts)); err != nil {
|
|
return fmt.Errorf("failed to write config summary row: %w", err)
|
|
}
|
|
if writeRows {
|
|
for _, row := range cfg.Rows {
|
|
if err := csvWriter.Write(pricingCSVRow(row, opts)); err != nil {
|
|
return fmt.Errorf("failed to write pricing row: %w", err)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
csvWriter.Flush()
|
|
if err := csvWriter.Error(); err != nil {
|
|
return fmt.Errorf("csv writer error: %w", err)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// ConfigToExportData converts a single configuration into ProjectExportData.
|
|
func (s *ExportService) ConfigToExportData(cfg *models.Configuration) *ProjectExportData {
|
|
block := s.buildExportBlock(cfg)
|
|
return &ProjectExportData{
|
|
Configs: []ConfigExportBlock{block},
|
|
CreatedAt: cfg.CreatedAt,
|
|
}
|
|
}
|
|
|
|
// ProjectToExportData converts multiple configurations into ProjectExportData.
|
|
func (s *ExportService) ProjectToExportData(configs []models.Configuration) *ProjectExportData {
|
|
sortedConfigs := make([]models.Configuration, len(configs))
|
|
copy(sortedConfigs, configs)
|
|
sort.Slice(sortedConfigs, func(i, j int) bool {
|
|
leftLine := sortedConfigs[i].Line
|
|
rightLine := sortedConfigs[j].Line
|
|
|
|
if leftLine <= 0 {
|
|
leftLine = int(^uint(0) >> 1)
|
|
}
|
|
if rightLine <= 0 {
|
|
rightLine = int(^uint(0) >> 1)
|
|
}
|
|
if leftLine != rightLine {
|
|
return leftLine < rightLine
|
|
}
|
|
if !sortedConfigs[i].CreatedAt.Equal(sortedConfigs[j].CreatedAt) {
|
|
return sortedConfigs[i].CreatedAt.After(sortedConfigs[j].CreatedAt)
|
|
}
|
|
return sortedConfigs[i].UUID > sortedConfigs[j].UUID
|
|
})
|
|
|
|
blocks := make([]ConfigExportBlock, 0, len(configs))
|
|
for i := range sortedConfigs {
|
|
blocks = append(blocks, s.buildExportBlock(&sortedConfigs[i]))
|
|
}
|
|
return &ProjectExportData{
|
|
Configs: blocks,
|
|
CreatedAt: time.Now(),
|
|
}
|
|
}
|
|
|
|
func (s *ExportService) buildExportBlock(cfg *models.Configuration) ConfigExportBlock {
|
|
// Batch-fetch categories from local data (pricelist items → local_components fallback)
|
|
lotNames := make([]string, len(cfg.Items))
|
|
for i, item := range cfg.Items {
|
|
lotNames[i] = item.LotName
|
|
}
|
|
categories := s.resolveCategories(cfg.PricelistID, lotNames)
|
|
|
|
items := make([]ExportItem, len(cfg.Items))
|
|
var unitTotal float64
|
|
|
|
for i, item := range cfg.Items {
|
|
itemTotal := item.UnitPrice * float64(item.Quantity)
|
|
items[i] = ExportItem{
|
|
LotName: item.LotName,
|
|
Category: categories[item.LotName],
|
|
Quantity: item.Quantity,
|
|
UnitPrice: item.UnitPrice,
|
|
TotalPrice: itemTotal,
|
|
}
|
|
unitTotal += itemTotal
|
|
}
|
|
|
|
serverCount := cfg.ServerCount
|
|
if serverCount < 1 {
|
|
serverCount = 1
|
|
}
|
|
|
|
return ConfigExportBlock{
|
|
Article: cfg.Article,
|
|
Line: cfg.Line,
|
|
ServerCount: serverCount,
|
|
UnitPrice: unitTotal,
|
|
Items: items,
|
|
}
|
|
}
|
|
|
|
func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts ProjectPricingExportOptions) (ProjectPricingExportConfig, error) {
|
|
block := ProjectPricingExportConfig{
|
|
Name: cfg.Name,
|
|
Article: cfg.Article,
|
|
Line: cfg.Line,
|
|
ServerCount: exportPositiveInt(cfg.ServerCount, 1),
|
|
Rows: make([]ProjectPricingExportRow, 0),
|
|
}
|
|
if s.localDB == nil {
|
|
for _, item := range cfg.Items {
|
|
block.Rows = append(block.Rows, ProjectPricingExportRow{
|
|
LotDisplay: item.LotName,
|
|
VendorPN: "—",
|
|
Quantity: item.Quantity,
|
|
Estimate: floatPtr(item.UnitPrice * float64(item.Quantity)),
|
|
})
|
|
}
|
|
return block, nil
|
|
}
|
|
|
|
localCfg, err := s.localDB.GetConfigurationByUUID(cfg.UUID)
|
|
if err != nil {
|
|
localCfg = nil
|
|
}
|
|
|
|
priceMap := s.resolvePricingTotals(cfg, localCfg, opts)
|
|
componentDescriptions := s.resolveLotDescriptions(cfg, localCfg)
|
|
if opts.IncludeBOM && localCfg != nil && len(localCfg.VendorSpec) > 0 {
|
|
coveredLots := make(map[string]struct{})
|
|
for _, row := range localCfg.VendorSpec {
|
|
rowMappings := normalizeLotMappings(row.LotMappings)
|
|
for _, mapping := range rowMappings {
|
|
coveredLots[mapping.LotName] = struct{}{}
|
|
}
|
|
|
|
description := strings.TrimSpace(row.Description)
|
|
if description == "" && len(rowMappings) > 0 {
|
|
description = componentDescriptions[rowMappings[0].LotName]
|
|
}
|
|
|
|
pricingRow := ProjectPricingExportRow{
|
|
LotDisplay: formatLotDisplay(rowMappings),
|
|
VendorPN: row.VendorPartnumber,
|
|
Description: description,
|
|
Quantity: exportPositiveInt(row.Quantity, 1),
|
|
BOMTotal: vendorRowTotal(row),
|
|
Estimate: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Estimate }),
|
|
Stock: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Stock }),
|
|
Competitor: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Competitor }),
|
|
}
|
|
block.Rows = append(block.Rows, pricingRow)
|
|
}
|
|
|
|
for _, item := range cfg.Items {
|
|
if item.LotName == "" {
|
|
continue
|
|
}
|
|
if _, ok := coveredLots[item.LotName]; ok {
|
|
continue
|
|
}
|
|
estimate := estimateOnlyTotal(priceMap[item.LotName].Estimate, item.UnitPrice, item.Quantity)
|
|
block.Rows = append(block.Rows, ProjectPricingExportRow{
|
|
LotDisplay: item.LotName,
|
|
VendorPN: "—",
|
|
Description: componentDescriptions[item.LotName],
|
|
Quantity: exportPositiveInt(item.Quantity, 1),
|
|
Estimate: estimate,
|
|
Stock: totalForUnitPrice(priceMap[item.LotName].Stock, item.Quantity),
|
|
Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity),
|
|
})
|
|
}
|
|
if opts.isDDP() {
|
|
applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
|
|
}
|
|
return block, nil
|
|
}
|
|
|
|
for _, item := range cfg.Items {
|
|
if item.LotName == "" {
|
|
continue
|
|
}
|
|
estimate := estimateOnlyTotal(priceMap[item.LotName].Estimate, item.UnitPrice, item.Quantity)
|
|
block.Rows = append(block.Rows, ProjectPricingExportRow{
|
|
LotDisplay: item.LotName,
|
|
VendorPN: "—",
|
|
Description: componentDescriptions[item.LotName],
|
|
Quantity: exportPositiveInt(item.Quantity, 1),
|
|
Estimate: estimate,
|
|
Stock: totalForUnitPrice(priceMap[item.LotName].Stock, item.Quantity),
|
|
Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity),
|
|
})
|
|
}
|
|
|
|
if opts.isDDP() {
|
|
applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
|
|
}
|
|
|
|
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.
|
|
// Primary source: pricelist items (lot_category). Fallback: local_components table.
|
|
func (s *ExportService) resolveCategories(pricelistID *uint, lotNames []string) map[string]string {
|
|
if len(lotNames) == 0 || s.localDB == nil {
|
|
return map[string]string{}
|
|
}
|
|
|
|
categories := make(map[string]string, len(lotNames))
|
|
|
|
// Primary: pricelist items
|
|
if pricelistID != nil && *pricelistID > 0 {
|
|
if cats, err := s.localDB.GetLocalLotCategoriesByServerPricelistID(*pricelistID, lotNames); err == nil {
|
|
for lot, cat := range cats {
|
|
if strings.TrimSpace(cat) != "" {
|
|
categories[lot] = cat
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Fallback: local_components for any still missing
|
|
var missing []string
|
|
for _, lot := range lotNames {
|
|
if categories[lot] == "" {
|
|
missing = append(missing, lot)
|
|
}
|
|
}
|
|
if len(missing) > 0 {
|
|
if fallback, err := s.localDB.GetLocalComponentCategoriesByLotNames(missing); err == nil {
|
|
for lot, cat := range fallback {
|
|
if strings.TrimSpace(cat) != "" {
|
|
categories[lot] = cat
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return categories
|
|
}
|
|
|
|
// sortItemsByCategory sorts items by category display order (items without category go to the end).
|
|
func sortItemsByCategory(items []ExportItem, categoryOrder map[string]int) {
|
|
for i := 0; i < len(items)-1; i++ {
|
|
for j := i + 1; j < len(items); j++ {
|
|
orderI, hasI := categoryOrder[items[i].Category]
|
|
orderJ, hasJ := categoryOrder[items[j].Category]
|
|
|
|
if !hasI && hasJ {
|
|
items[i], items[j] = items[j], items[i]
|
|
} else if hasI && hasJ && orderI > orderJ {
|
|
items[i], items[j] = items[j], items[i]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
type pricingLevels struct {
|
|
Estimate *float64
|
|
Stock *float64
|
|
Competitor *float64
|
|
}
|
|
|
|
func (s *ExportService) resolvePricingTotals(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, opts ProjectPricingExportOptions) map[string]pricingLevels {
|
|
result := map[string]pricingLevels{}
|
|
lots := collectPricingLots(cfg, localCfg, opts.IncludeBOM)
|
|
if len(lots) == 0 || s.localDB == nil {
|
|
return result
|
|
}
|
|
|
|
estimateID := cfg.PricelistID
|
|
if estimateID == nil || *estimateID == 0 {
|
|
if latest, err := s.localDB.GetLatestLocalPricelistBySource("estimate"); err == nil && latest != nil {
|
|
estimateID = &latest.ServerID
|
|
}
|
|
}
|
|
|
|
var warehouseID *uint
|
|
var competitorID *uint
|
|
if localCfg != nil {
|
|
warehouseID = localCfg.WarehousePricelistID
|
|
competitorID = localCfg.CompetitorPricelistID
|
|
}
|
|
if warehouseID == nil || *warehouseID == 0 {
|
|
if latest, err := s.localDB.GetLatestLocalPricelistBySource("warehouse"); err == nil && latest != nil {
|
|
warehouseID = &latest.ServerID
|
|
}
|
|
}
|
|
if competitorID == nil || *competitorID == 0 {
|
|
if latest, err := s.localDB.GetLatestLocalPricelistBySource("competitor"); err == nil && latest != nil {
|
|
competitorID = &latest.ServerID
|
|
}
|
|
}
|
|
|
|
for _, lot := range lots {
|
|
level := pricingLevels{}
|
|
level.Estimate = s.lookupPricePointer(estimateID, lot)
|
|
level.Stock = s.lookupPricePointer(warehouseID, lot)
|
|
level.Competitor = s.lookupPricePointer(competitorID, lot)
|
|
result[lot] = level
|
|
}
|
|
return result
|
|
}
|
|
|
|
func (s *ExportService) lookupPricePointer(serverPricelistID *uint, lotName string) *float64 {
|
|
if s.localDB == nil || serverPricelistID == nil || *serverPricelistID == 0 || strings.TrimSpace(lotName) == "" {
|
|
return nil
|
|
}
|
|
localPL, err := s.localDB.GetLocalPricelistByServerID(*serverPricelistID)
|
|
if err != nil {
|
|
return nil
|
|
}
|
|
price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName)
|
|
if err != nil || price <= 0 {
|
|
return nil
|
|
}
|
|
return floatPtr(price)
|
|
}
|
|
|
|
func (s *ExportService) resolveLotDescriptions(cfg *models.Configuration, localCfg *localdb.LocalConfiguration) map[string]string {
|
|
lots := collectPricingLots(cfg, localCfg, true)
|
|
result := make(map[string]string, len(lots))
|
|
if s.localDB == nil {
|
|
return result
|
|
}
|
|
for _, lot := range lots {
|
|
component, err := s.localDB.GetLocalComponent(lot)
|
|
if err != nil {
|
|
continue
|
|
}
|
|
result[lot] = component.LotDescription
|
|
}
|
|
return result
|
|
}
|
|
|
|
func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, includeBOM bool) []string {
|
|
seen := map[string]struct{}{}
|
|
out := make([]string, 0)
|
|
if includeBOM && localCfg != nil {
|
|
for _, row := range localCfg.VendorSpec {
|
|
for _, mapping := range normalizeLotMappings(row.LotMappings) {
|
|
if _, ok := seen[mapping.LotName]; ok {
|
|
continue
|
|
}
|
|
seen[mapping.LotName] = struct{}{}
|
|
out = append(out, mapping.LotName)
|
|
}
|
|
}
|
|
}
|
|
for _, item := range cfg.Items {
|
|
lot := strings.TrimSpace(item.LotName)
|
|
if lot == "" {
|
|
continue
|
|
}
|
|
if _, ok := seen[lot]; ok {
|
|
continue
|
|
}
|
|
seen[lot] = struct{}{}
|
|
out = append(out, lot)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func normalizeLotMappings(mappings []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
|
|
if len(mappings) == 0 {
|
|
return nil
|
|
}
|
|
out := make([]localdb.VendorSpecLotMapping, 0, len(mappings))
|
|
for _, mapping := range mappings {
|
|
lot := strings.TrimSpace(mapping.LotName)
|
|
if lot == "" {
|
|
continue
|
|
}
|
|
qty := mapping.QuantityPerPN
|
|
if qty < 1 {
|
|
qty = 1
|
|
}
|
|
out = append(out, localdb.VendorSpecLotMapping{
|
|
LotName: lot,
|
|
QuantityPerPN: qty,
|
|
})
|
|
}
|
|
return out
|
|
}
|
|
|
|
func vendorRowTotal(row localdb.VendorSpecItem) *float64 {
|
|
if row.TotalPrice != nil {
|
|
return floatPtr(*row.TotalPrice)
|
|
}
|
|
if row.UnitPrice == nil {
|
|
return nil
|
|
}
|
|
return floatPtr(*row.UnitPrice * float64(exportPositiveInt(row.Quantity, 1)))
|
|
}
|
|
|
|
func computeMappingTotal(priceMap map[string]pricingLevels, mappings []localdb.VendorSpecLotMapping, pnQty int, selector func(pricingLevels) *float64) *float64 {
|
|
if len(mappings) == 0 {
|
|
return nil
|
|
}
|
|
total := 0.0
|
|
hasValue := false
|
|
qty := exportPositiveInt(pnQty, 1)
|
|
for _, mapping := range mappings {
|
|
price := selector(priceMap[mapping.LotName])
|
|
if price == nil || *price <= 0 {
|
|
continue
|
|
}
|
|
total += *price * float64(qty*mapping.QuantityPerPN)
|
|
hasValue = true
|
|
}
|
|
if !hasValue {
|
|
return nil
|
|
}
|
|
return floatPtr(total)
|
|
}
|
|
|
|
func totalForUnitPrice(unitPrice *float64, quantity int) *float64 {
|
|
if unitPrice == nil || *unitPrice <= 0 {
|
|
return nil
|
|
}
|
|
total := *unitPrice * float64(exportPositiveInt(quantity, 1))
|
|
return &total
|
|
}
|
|
|
|
func estimateOnlyTotal(estimatePrice *float64, fallbackUnitPrice float64, quantity int) *float64 {
|
|
if estimatePrice != nil && *estimatePrice > 0 {
|
|
return totalForUnitPrice(estimatePrice, quantity)
|
|
}
|
|
if fallbackUnitPrice <= 0 {
|
|
return nil
|
|
}
|
|
total := fallbackUnitPrice * float64(maxInt(quantity, 1))
|
|
return &total
|
|
}
|
|
|
|
func pricingCSVHeaders(opts ProjectPricingExportOptions) []string {
|
|
headers := make([]string, 0, 8)
|
|
headers = append(headers, "Line Item")
|
|
if opts.IncludeLOT {
|
|
headers = append(headers, "LOT")
|
|
}
|
|
headers = append(headers, "PN вендора", "Описание", "Кол-во")
|
|
if opts.IncludeBOM {
|
|
headers = append(headers, "BOM")
|
|
}
|
|
if opts.IncludeEstimate {
|
|
headers = append(headers, "Estimate")
|
|
}
|
|
if opts.IncludeStock {
|
|
headers = append(headers, "Stock")
|
|
}
|
|
if opts.IncludeCompetitor {
|
|
headers = append(headers, "Конкуренты")
|
|
}
|
|
return headers
|
|
}
|
|
|
|
func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions) []string {
|
|
record := make([]string, 0, 8)
|
|
record = append(record, "")
|
|
if opts.IncludeLOT {
|
|
record = append(record, emptyDash(row.LotDisplay))
|
|
}
|
|
record = append(record,
|
|
emptyDash(row.VendorPN),
|
|
emptyDash(row.Description),
|
|
fmt.Sprintf("%d", exportPositiveInt(row.Quantity, 1)),
|
|
)
|
|
if opts.IncludeBOM {
|
|
record = append(record, formatMoneyValue(row.BOMTotal))
|
|
}
|
|
if opts.IncludeEstimate {
|
|
record = append(record, formatMoneyValue(row.Estimate))
|
|
}
|
|
if opts.IncludeStock {
|
|
record = append(record, formatMoneyValue(row.Stock))
|
|
}
|
|
if opts.IncludeCompetitor {
|
|
record = append(record, formatMoneyValue(row.Competitor))
|
|
}
|
|
return record
|
|
}
|
|
|
|
func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricingExportOptions) []string {
|
|
record := make([]string, 0, 8)
|
|
record = append(record, fmt.Sprintf("%d", cfg.Line))
|
|
if opts.IncludeLOT {
|
|
record = append(record, "")
|
|
}
|
|
record = append(record,
|
|
emptyDash(cfg.Article),
|
|
emptyDash(cfg.Name),
|
|
fmt.Sprintf("%d", exportPositiveInt(cfg.ServerCount, 1)),
|
|
)
|
|
if opts.IncludeBOM {
|
|
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.BOMTotal })))
|
|
}
|
|
if opts.IncludeEstimate {
|
|
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Estimate })))
|
|
}
|
|
if opts.IncludeStock {
|
|
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Stock })))
|
|
}
|
|
if opts.IncludeCompetitor {
|
|
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Competitor })))
|
|
}
|
|
return record
|
|
}
|
|
|
|
func formatLotDisplay(mappings []localdb.VendorSpecLotMapping) string {
|
|
switch len(mappings) {
|
|
case 0:
|
|
return "н/д"
|
|
case 1:
|
|
return mappings[0].LotName
|
|
default:
|
|
return fmt.Sprintf("%s +%d", mappings[0].LotName, len(mappings)-1)
|
|
}
|
|
}
|
|
|
|
func formatMoneyValue(value *float64) string {
|
|
if value == nil {
|
|
return "—"
|
|
}
|
|
n := math.Round(*value*100) / 100
|
|
sign := ""
|
|
if n < 0 {
|
|
sign = "-"
|
|
n = -n
|
|
}
|
|
whole := int64(n)
|
|
fraction := int(math.Round((n - float64(whole)) * 100))
|
|
if fraction == 100 {
|
|
whole++
|
|
fraction = 0
|
|
}
|
|
return fmt.Sprintf("%s%s,%02d", sign, formatIntWithSpace(whole), fraction)
|
|
}
|
|
|
|
func emptyDash(value string) string {
|
|
if strings.TrimSpace(value) == "" {
|
|
return "—"
|
|
}
|
|
return value
|
|
}
|
|
|
|
func sumPricingColumn(rows []ProjectPricingExportRow, selector func(ProjectPricingExportRow) *float64) *float64 {
|
|
total := 0.0
|
|
hasValue := false
|
|
for _, row := range rows {
|
|
value := selector(row)
|
|
if value == nil {
|
|
continue
|
|
}
|
|
total += *value
|
|
hasValue = true
|
|
}
|
|
if !hasValue {
|
|
return nil
|
|
}
|
|
return floatPtr(total)
|
|
}
|
|
|
|
func floatPtr(value float64) *float64 {
|
|
v := value
|
|
return &v
|
|
}
|
|
|
|
func exportPositiveInt(value, fallback int) int {
|
|
if value < 1 {
|
|
return fallback
|
|
}
|
|
return value
|
|
}
|
|
|
|
// formatPriceComma formats a price with comma as decimal separator (e.g., "2074,5").
|
|
// Trailing zeros after the comma are trimmed, and if the value is an integer, no comma is shown.
|
|
func formatPriceComma(value float64) string {
|
|
if value == math.Trunc(value) {
|
|
return fmt.Sprintf("%.0f", value)
|
|
}
|
|
s := fmt.Sprintf("%.2f", value)
|
|
s = strings.ReplaceAll(s, ".", ",")
|
|
// Trim trailing zero: "2074,50" -> "2074,5"
|
|
s = strings.TrimRight(s, "0")
|
|
s = strings.TrimRight(s, ",")
|
|
return s
|
|
}
|
|
|
|
// formatPriceInt formats price as integer (rounded), no decimal.
|
|
func formatPriceInt(value float64) string {
|
|
return fmt.Sprintf("%.0f", math.Round(value))
|
|
}
|
|
|
|
// formatPriceWithSpace formats a price as an integer with space as thousands separator (e.g., "104 700").
|
|
func formatPriceWithSpace(value float64) string {
|
|
intVal := int64(math.Round(value))
|
|
if intVal < 0 {
|
|
return "-" + formatIntWithSpace(-intVal)
|
|
}
|
|
return formatIntWithSpace(intVal)
|
|
}
|
|
|
|
func formatIntWithSpace(n int64) string {
|
|
s := fmt.Sprintf("%d", n)
|
|
if len(s) <= 3 {
|
|
return s
|
|
}
|
|
|
|
var result strings.Builder
|
|
remainder := len(s) % 3
|
|
if remainder > 0 {
|
|
result.WriteString(s[:remainder])
|
|
}
|
|
for i := remainder; i < len(s); i += 3 {
|
|
if result.Len() > 0 {
|
|
result.WriteByte(' ')
|
|
}
|
|
result.WriteString(s[i : i+3])
|
|
}
|
|
return result.String()
|
|
}
|