326 lines
9.1 KiB
Go
326 lines
9.1 KiB
Go
package services
|
|
|
|
import (
|
|
"bytes"
|
|
"encoding/csv"
|
|
"fmt"
|
|
"io"
|
|
"math"
|
|
"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
|
|
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
|
|
}
|
|
|
|
// 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 := (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
|
|
}
|
|
|
|
// 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 {
|
|
blocks := make([]ConfigExportBlock, 0, len(configs))
|
|
for i := range configs {
|
|
blocks = append(blocks, s.buildExportBlock(&configs[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,
|
|
ServerCount: serverCount,
|
|
UnitPrice: unitTotal,
|
|
Items: items,
|
|
}
|
|
}
|
|
|
|
// 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]
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
// 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()
|
|
}
|