Добавлен парсер собственного CSV-экспорта QuoteForge (IsQuoteForgeCSV / parseQuoteForgeCSV). Формат: UTF-8 BOM + заголовок Line;Type;p/n;..., блоки сервер → компоненты. DirectItems создаются напрямую без прохода через VendorSpecResolver. Модальное окно импорта принимает .csv/.txt/.xml. Fix кнопки «Обновить цены» на странице варианта: после синхронизации прайс-листов запрашивается актуальный estimate-прайслист и передаётся явным pricelist_id в каждый POST /api/configs/:uuid/refresh-prices. Ранее использовался устаревший ID, сохранённый в конфигурации. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
810 lines
23 KiB
Go
810 lines
23 KiB
Go
package services
|
||
|
||
import (
|
||
"bytes"
|
||
"encoding/csv"
|
||
"encoding/xml"
|
||
"fmt"
|
||
"path/filepath"
|
||
"sort"
|
||
"strconv"
|
||
"strings"
|
||
"time"
|
||
|
||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||
"github.com/google/uuid"
|
||
"gorm.io/gorm"
|
||
)
|
||
|
||
type VendorWorkspaceImportResult struct {
|
||
Imported int `json:"imported"`
|
||
Project *models.Project `json:"project,omitempty"`
|
||
Configs []VendorWorkspaceImportedConfig `json:"configs"`
|
||
}
|
||
|
||
type VendorWorkspaceImportedConfig struct {
|
||
UUID string `json:"uuid"`
|
||
Name string `json:"name"`
|
||
ServerCount int `json:"server_count"`
|
||
ServerModel string `json:"server_model,omitempty"`
|
||
Rows int `json:"rows"`
|
||
}
|
||
|
||
type importedWorkspace struct {
|
||
SourceFormat string
|
||
SourceDocID string
|
||
SourceFileName string
|
||
CurrencyCode string
|
||
Configurations []importedConfiguration
|
||
}
|
||
|
||
type importedConfiguration struct {
|
||
GroupID string
|
||
Name string
|
||
Line int
|
||
ServerCount int
|
||
ServerModel string
|
||
Article string
|
||
CurrencyCode string
|
||
Rows []localdb.VendorSpecItem // vendor BOM formats (CFXML, Inspur)
|
||
DirectItems localdb.LocalConfigItems // direct LOT formats (QuoteForge CSV)
|
||
TotalPrice *float64
|
||
}
|
||
|
||
type groupedItem struct {
|
||
order int
|
||
row cfxmlProductLineItem
|
||
}
|
||
|
||
type cfxmlDocument struct {
|
||
XMLName xml.Name `xml:"CFXML"`
|
||
ThisDocumentIdentifier cfxmlDocumentIdentifier `xml:"thisDocumentIdentifier"`
|
||
CFData cfxmlData `xml:"CFData"`
|
||
}
|
||
|
||
type cfxmlDocumentIdentifier struct {
|
||
ProprietaryDocumentIdentifier string `xml:"ProprietaryDocumentIdentifier"`
|
||
}
|
||
|
||
type cfxmlData struct {
|
||
ProprietaryInformation []cfxmlProprietaryInformation `xml:"ProprietaryInformation"`
|
||
ProductLineItems []cfxmlProductLineItem `xml:"ProductLineItem"`
|
||
}
|
||
|
||
type cfxmlProprietaryInformation struct {
|
||
Name string `xml:"Name"`
|
||
Value string `xml:"Value"`
|
||
}
|
||
|
||
type cfxmlProductLineItem struct {
|
||
ProductLineNumber string `xml:"ProductLineNumber"`
|
||
ItemNo string `xml:"ItemNo"`
|
||
TransactionType string `xml:"TransactionType"`
|
||
ProprietaryGroupIdentifier string `xml:"ProprietaryGroupIdentifier"`
|
||
ConfigurationGroupLineNumberReference string `xml:"ConfigurationGroupLineNumberReference"`
|
||
Quantity string `xml:"Quantity"`
|
||
ProductIdentification cfxmlProductIdentification `xml:"ProductIdentification"`
|
||
UnitListPrice cfxmlUnitListPrice `xml:"UnitListPrice"`
|
||
ProductSubLineItems []cfxmlProductSubLineItem `xml:"ProductSubLineItem"`
|
||
}
|
||
|
||
type cfxmlProductSubLineItem struct {
|
||
LineNumber string `xml:"LineNumber"`
|
||
TransactionType string `xml:"TransactionType"`
|
||
Quantity string `xml:"Quantity"`
|
||
ProductIdentification cfxmlProductIdentification `xml:"ProductIdentification"`
|
||
UnitListPrice cfxmlUnitListPrice `xml:"UnitListPrice"`
|
||
}
|
||
|
||
type cfxmlProductIdentification struct {
|
||
PartnerProductIdentification cfxmlPartnerProductIdentification `xml:"PartnerProductIdentification"`
|
||
}
|
||
|
||
type cfxmlPartnerProductIdentification struct {
|
||
ProprietaryProductIdentifier string `xml:"ProprietaryProductIdentifier"`
|
||
ProprietaryProductChar string `xml:"ProprietaryProductChar"`
|
||
ProductCharacter string `xml:"ProductCharacter"`
|
||
ProductDescription string `xml:"ProductDescription"`
|
||
ProductName string `xml:"ProductName"`
|
||
ProductTypeCode string `xml:"ProductTypeCode"`
|
||
}
|
||
|
||
type cfxmlUnitListPrice struct {
|
||
FinancialAmount cfxmlFinancialAmount `xml:"FinancialAmount"`
|
||
}
|
||
|
||
type cfxmlFinancialAmount struct {
|
||
GlobalCurrencyCode string `xml:"GlobalCurrencyCode"`
|
||
MonetaryAmount string `xml:"MonetaryAmount"`
|
||
}
|
||
|
||
func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID string, sourceFileName string, data []byte, ownerUsername string) (*VendorWorkspaceImportResult, error) {
|
||
project, err := s.localDB.GetProjectByUUID(projectUUID)
|
||
if err != nil {
|
||
return nil, ErrProjectNotFound
|
||
}
|
||
|
||
var workspace *importedWorkspace
|
||
switch {
|
||
case IsCFXMLWorkspace(data):
|
||
workspace, err = parseCFXMLWorkspace(data, filepath.Base(sourceFileName))
|
||
case IsQuoteForgeCSV(data):
|
||
workspace, err = parseQuoteForgeCSV(data, filepath.Base(sourceFileName))
|
||
case IsInspurBOM(data):
|
||
workspace, err = parseInspurBOM(data, filepath.Base(sourceFileName))
|
||
default:
|
||
return nil, fmt.Errorf("unsupported vendor export format")
|
||
}
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
result := &VendorWorkspaceImportResult{
|
||
Imported: 0,
|
||
Project: localdb.LocalToProject(project),
|
||
Configs: make([]VendorWorkspaceImportedConfig, 0, len(workspace.Configurations)),
|
||
}
|
||
|
||
err = s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||
bookRepo := repository.NewPartnumberBookRepository(tx)
|
||
for _, imported := range workspace.Configurations {
|
||
now := time.Now()
|
||
cfgUUID := uuid.NewString()
|
||
|
||
var groupRows localdb.VendorSpec
|
||
var items localdb.LocalConfigItems
|
||
var totalPrice *float64
|
||
var estimatePricelistID *uint
|
||
|
||
if len(imported.DirectItems) > 0 {
|
||
items = imported.DirectItems
|
||
estimatePricelist, _ := s.localDB.GetLatestLocalPricelistBySource("estimate")
|
||
if estimatePricelist != nil {
|
||
estimatePricelistID = &estimatePricelist.ServerID
|
||
}
|
||
val := items.Total() * float64(maxInt(imported.ServerCount, 1))
|
||
totalPrice = &val
|
||
} else {
|
||
var prepErr error
|
||
groupRows, items, totalPrice, estimatePricelistID, prepErr = s.prepareImportedConfiguration(imported.Rows, imported.ServerCount, bookRepo)
|
||
if prepErr != nil {
|
||
return fmt.Errorf("prepare imported configuration group %s: %w", imported.GroupID, prepErr)
|
||
}
|
||
}
|
||
|
||
localCfg := &localdb.LocalConfiguration{
|
||
UUID: cfgUUID,
|
||
ProjectUUID: &projectUUID,
|
||
IsActive: true,
|
||
Name: imported.Name,
|
||
Items: items,
|
||
TotalPrice: totalPrice,
|
||
ServerCount: imported.ServerCount,
|
||
ServerModel: imported.ServerModel,
|
||
Article: imported.Article,
|
||
PricelistID: estimatePricelistID,
|
||
VendorSpec: groupRows,
|
||
CreatedAt: now,
|
||
UpdatedAt: now,
|
||
SyncStatus: "pending",
|
||
OriginalUsername: ownerUsername,
|
||
}
|
||
|
||
if err := s.createWithVersionTx(tx, localCfg, ownerUsername); err != nil {
|
||
return fmt.Errorf("import configuration group %s: %w", imported.GroupID, err)
|
||
}
|
||
|
||
result.Imported++
|
||
result.Configs = append(result.Configs, VendorWorkspaceImportedConfig{
|
||
UUID: localCfg.UUID,
|
||
Name: localCfg.Name,
|
||
ServerCount: localCfg.ServerCount,
|
||
ServerModel: localCfg.ServerModel,
|
||
Rows: len(localCfg.VendorSpec),
|
||
})
|
||
}
|
||
return nil
|
||
})
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
|
||
return result, nil
|
||
}
|
||
|
||
func (s *LocalConfigurationService) prepareImportedConfiguration(rows []localdb.VendorSpecItem, serverCount int, bookRepo *repository.PartnumberBookRepository) (localdb.VendorSpec, localdb.LocalConfigItems, *float64, *uint, error) {
|
||
resolver := NewVendorSpecResolver(bookRepo)
|
||
resolved, err := resolver.Resolve(append([]localdb.VendorSpecItem(nil), rows...))
|
||
if err != nil {
|
||
return nil, nil, nil, nil, err
|
||
}
|
||
|
||
canonical := make(localdb.VendorSpec, 0, len(resolved))
|
||
for _, row := range resolved {
|
||
if len(row.LotMappings) == 0 && strings.TrimSpace(row.ResolvedLotName) != "" {
|
||
row.LotMappings = []localdb.VendorSpecLotMapping{
|
||
{LotName: strings.TrimSpace(row.ResolvedLotName), QuantityPerPN: 1},
|
||
}
|
||
}
|
||
row.LotMappings = normalizeImportedLotMappings(row.LotMappings)
|
||
row.ResolvedLotName = ""
|
||
row.ResolutionSource = ""
|
||
row.ManualLotSuggestion = ""
|
||
row.LotQtyPerPN = 0
|
||
row.LotAllocations = nil
|
||
canonical = append(canonical, row)
|
||
}
|
||
|
||
estimatePricelist, _ := s.localDB.GetLatestLocalPricelistBySource("estimate")
|
||
var serverPricelistID *uint
|
||
if estimatePricelist != nil {
|
||
serverPricelistID = &estimatePricelist.ServerID
|
||
}
|
||
|
||
items := aggregateVendorSpecToItems(canonical, estimatePricelist, s.localDB)
|
||
totalValue := items.Total()
|
||
if serverCount > 1 {
|
||
totalValue *= float64(serverCount)
|
||
}
|
||
totalPrice := &totalValue
|
||
return canonical, items, totalPrice, serverPricelistID, nil
|
||
}
|
||
|
||
func aggregateVendorSpecToItems(spec localdb.VendorSpec, estimatePricelist *localdb.LocalPricelist, local *localdb.LocalDB) localdb.LocalConfigItems {
|
||
if len(spec) == 0 {
|
||
return localdb.LocalConfigItems{}
|
||
}
|
||
|
||
lotMap := make(map[string]int)
|
||
order := make([]string, 0)
|
||
for _, row := range spec {
|
||
for _, mapping := range normalizeImportedLotMappings(row.LotMappings) {
|
||
if _, exists := lotMap[mapping.LotName]; !exists {
|
||
order = append(order, mapping.LotName)
|
||
}
|
||
lotMap[mapping.LotName] += row.Quantity * mapping.QuantityPerPN
|
||
}
|
||
}
|
||
|
||
sort.Strings(order)
|
||
items := make(localdb.LocalConfigItems, 0, len(order))
|
||
for _, lotName := range order {
|
||
unitPrice := 0.0
|
||
if estimatePricelist != nil && local != nil {
|
||
if price, err := local.GetLocalPriceForLot(estimatePricelist.ID, lotName); err == nil && price > 0 {
|
||
unitPrice = price
|
||
}
|
||
}
|
||
items = append(items, localdb.LocalConfigItem{
|
||
LotName: lotName,
|
||
Quantity: lotMap[lotName],
|
||
UnitPrice: unitPrice,
|
||
})
|
||
}
|
||
return items
|
||
}
|
||
|
||
func normalizeImportedLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
|
||
if len(in) == 0 {
|
||
return nil
|
||
}
|
||
merged := make(map[string]int, len(in))
|
||
order := make([]string, 0, len(in))
|
||
for _, mapping := range in {
|
||
lot := strings.TrimSpace(mapping.LotName)
|
||
if lot == "" {
|
||
continue
|
||
}
|
||
qty := mapping.QuantityPerPN
|
||
if qty < 1 {
|
||
qty = 1
|
||
}
|
||
if _, exists := merged[lot]; !exists {
|
||
order = append(order, lot)
|
||
}
|
||
merged[lot] += qty
|
||
}
|
||
out := make([]localdb.VendorSpecLotMapping, 0, len(order))
|
||
for _, lot := range order {
|
||
out = append(out, localdb.VendorSpecLotMapping{
|
||
LotName: lot,
|
||
QuantityPerPN: merged[lot],
|
||
})
|
||
}
|
||
if len(out) == 0 {
|
||
return nil
|
||
}
|
||
return out
|
||
}
|
||
|
||
func parseCFXMLWorkspace(data []byte, sourceFileName string) (*importedWorkspace, error) {
|
||
var doc cfxmlDocument
|
||
if err := xml.Unmarshal(data, &doc); err != nil {
|
||
return nil, fmt.Errorf("parse CFXML workspace: %w", err)
|
||
}
|
||
if doc.XMLName.Local != "CFXML" {
|
||
return nil, fmt.Errorf("unsupported workspace root: %s", doc.XMLName.Local)
|
||
}
|
||
if len(doc.CFData.ProductLineItems) == 0 {
|
||
return nil, fmt.Errorf("CFXML workspace has no ProductLineItem rows")
|
||
}
|
||
|
||
workspace := &importedWorkspace{
|
||
SourceFormat: "CFXML",
|
||
SourceDocID: strings.TrimSpace(doc.ThisDocumentIdentifier.ProprietaryDocumentIdentifier),
|
||
SourceFileName: sourceFileName,
|
||
CurrencyCode: detectWorkspaceCurrency(doc.CFData.ProprietaryInformation, doc.CFData.ProductLineItems),
|
||
}
|
||
|
||
type groupBucket struct {
|
||
order int
|
||
items []groupedItem
|
||
}
|
||
|
||
groupOrder := make([]string, 0)
|
||
groups := make(map[string]*groupBucket)
|
||
for idx, item := range doc.CFData.ProductLineItems {
|
||
groupID := strings.TrimSpace(item.ProprietaryGroupIdentifier)
|
||
if groupID == "" {
|
||
groupID = firstNonEmpty(strings.TrimSpace(item.ProductLineNumber), strings.TrimSpace(item.ItemNo), fmt.Sprintf("group-%d", idx+1))
|
||
}
|
||
bucket := groups[groupID]
|
||
if bucket == nil {
|
||
bucket = &groupBucket{order: idx}
|
||
groups[groupID] = bucket
|
||
groupOrder = append(groupOrder, groupID)
|
||
}
|
||
bucket.items = append(bucket.items, groupedItem{order: idx, row: item})
|
||
}
|
||
|
||
for lineIdx, groupID := range groupOrder {
|
||
bucket := groups[groupID]
|
||
if bucket == nil || len(bucket.items) == 0 {
|
||
continue
|
||
}
|
||
primary := pickPrimaryTopLevelRow(bucket.items)
|
||
serverCount := maxInt(parseInt(primary.row.Quantity), 1)
|
||
rows := make([]localdb.VendorSpecItem, 0, len(bucket.items)*4)
|
||
sortOrder := 10
|
||
|
||
for _, item := range bucket.items {
|
||
topRow := vendorSpecItemFromTopLevel(item.row, serverCount, sortOrder)
|
||
if topRow != nil {
|
||
rows = append(rows, *topRow)
|
||
sortOrder += 10
|
||
}
|
||
|
||
for _, sub := range item.row.ProductSubLineItems {
|
||
subRow := vendorSpecItemFromSubLine(sub, sortOrder)
|
||
if subRow == nil {
|
||
continue
|
||
}
|
||
rows = append(rows, *subRow)
|
||
sortOrder += 10
|
||
}
|
||
}
|
||
|
||
total := sumVendorSpecRows(rows, serverCount)
|
||
name := strings.TrimSpace(primary.row.ProductIdentification.PartnerProductIdentification.ProductName)
|
||
if name == "" {
|
||
name = strings.TrimSpace(primary.row.ProductIdentification.PartnerProductIdentification.ProductDescription)
|
||
}
|
||
if name == "" {
|
||
name = fmt.Sprintf("Imported config %d", lineIdx+1)
|
||
}
|
||
|
||
workspace.Configurations = append(workspace.Configurations, importedConfiguration{
|
||
GroupID: groupID,
|
||
Name: name,
|
||
Line: (lineIdx + 1) * 10,
|
||
ServerCount: serverCount,
|
||
ServerModel: strings.TrimSpace(primary.row.ProductIdentification.PartnerProductIdentification.ProductDescription),
|
||
Article: strings.TrimSpace(primary.row.ProductIdentification.PartnerProductIdentification.ProprietaryProductIdentifier),
|
||
CurrencyCode: workspace.CurrencyCode,
|
||
Rows: rows,
|
||
TotalPrice: total,
|
||
})
|
||
}
|
||
|
||
if len(workspace.Configurations) == 0 {
|
||
return nil, fmt.Errorf("CFXML workspace has no importable configuration groups")
|
||
}
|
||
|
||
return workspace, nil
|
||
}
|
||
|
||
func detectWorkspaceCurrency(meta []cfxmlProprietaryInformation, rows []cfxmlProductLineItem) string {
|
||
for _, item := range meta {
|
||
if strings.EqualFold(strings.TrimSpace(item.Name), "Currencies") {
|
||
value := strings.TrimSpace(item.Value)
|
||
if value != "" {
|
||
return value
|
||
}
|
||
}
|
||
}
|
||
for _, row := range rows {
|
||
code := strings.TrimSpace(row.UnitListPrice.FinancialAmount.GlobalCurrencyCode)
|
||
if code != "" {
|
||
return code
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func pickPrimaryTopLevelRow(items []groupedItem) groupedItem {
|
||
best := items[0]
|
||
bestScore := primaryScore(best.row)
|
||
for _, item := range items[1:] {
|
||
score := primaryScore(item.row)
|
||
if score > bestScore {
|
||
best = item
|
||
bestScore = score
|
||
continue
|
||
}
|
||
if score == bestScore && compareLineNumbers(item.row.ProductLineNumber, best.row.ProductLineNumber) < 0 {
|
||
best = item
|
||
}
|
||
}
|
||
return best
|
||
}
|
||
|
||
func primaryScore(row cfxmlProductLineItem) int {
|
||
score := len(row.ProductSubLineItems)
|
||
if strings.EqualFold(strings.TrimSpace(row.ProductIdentification.PartnerProductIdentification.ProductTypeCode), "Hardware") {
|
||
score += 100000
|
||
}
|
||
return score
|
||
}
|
||
|
||
func compareLineNumbers(left, right string) int {
|
||
li := parseInt(left)
|
||
ri := parseInt(right)
|
||
switch {
|
||
case li < ri:
|
||
return -1
|
||
case li > ri:
|
||
return 1
|
||
default:
|
||
return strings.Compare(left, right)
|
||
}
|
||
}
|
||
|
||
func vendorSpecItemFromTopLevel(item cfxmlProductLineItem, serverCount int, sortOrder int) *localdb.VendorSpecItem {
|
||
code := strings.TrimSpace(item.ProductIdentification.PartnerProductIdentification.ProprietaryProductIdentifier)
|
||
desc := strings.TrimSpace(item.ProductIdentification.PartnerProductIdentification.ProductDescription)
|
||
if code == "" && desc == "" {
|
||
return nil
|
||
}
|
||
qty := normalizeTopLevelQuantity(item.Quantity, serverCount)
|
||
unitPrice := parseOptionalFloat(item.UnitListPrice.FinancialAmount.MonetaryAmount)
|
||
return &localdb.VendorSpecItem{
|
||
SortOrder: sortOrder,
|
||
VendorPartnumber: code,
|
||
Quantity: qty,
|
||
Description: desc,
|
||
UnitPrice: unitPrice,
|
||
TotalPrice: totalPrice(unitPrice, qty),
|
||
}
|
||
}
|
||
|
||
func vendorSpecItemFromSubLine(item cfxmlProductSubLineItem, sortOrder int) *localdb.VendorSpecItem {
|
||
code := strings.TrimSpace(item.ProductIdentification.PartnerProductIdentification.ProprietaryProductIdentifier)
|
||
desc := strings.TrimSpace(item.ProductIdentification.PartnerProductIdentification.ProductDescription)
|
||
if code == "" && desc == "" {
|
||
return nil
|
||
}
|
||
qty := maxInt(parseInt(item.Quantity), 1)
|
||
unitPrice := parseOptionalFloat(item.UnitListPrice.FinancialAmount.MonetaryAmount)
|
||
return &localdb.VendorSpecItem{
|
||
SortOrder: sortOrder,
|
||
VendorPartnumber: code,
|
||
Quantity: qty,
|
||
Description: desc,
|
||
UnitPrice: unitPrice,
|
||
TotalPrice: totalPrice(unitPrice, qty),
|
||
}
|
||
}
|
||
|
||
func sumVendorSpecRows(rows []localdb.VendorSpecItem, serverCount int) *float64 {
|
||
total := 0.0
|
||
hasTotal := false
|
||
for _, row := range rows {
|
||
if row.TotalPrice == nil {
|
||
continue
|
||
}
|
||
total += *row.TotalPrice
|
||
hasTotal = true
|
||
}
|
||
if !hasTotal {
|
||
return nil
|
||
}
|
||
if serverCount > 1 {
|
||
total *= float64(serverCount)
|
||
}
|
||
return &total
|
||
}
|
||
|
||
func totalPrice(unitPrice *float64, qty int) *float64 {
|
||
if unitPrice == nil {
|
||
return nil
|
||
}
|
||
total := *unitPrice * float64(qty)
|
||
return &total
|
||
}
|
||
|
||
func parseOptionalFloat(raw string) *float64 {
|
||
trimmed := strings.TrimSpace(raw)
|
||
if trimmed == "" {
|
||
return nil
|
||
}
|
||
value, err := strconv.ParseFloat(trimmed, 64)
|
||
if err != nil {
|
||
return nil
|
||
}
|
||
return &value
|
||
}
|
||
|
||
func parseInt(raw string) int {
|
||
trimmed := strings.TrimSpace(raw)
|
||
if trimmed == "" {
|
||
return 0
|
||
}
|
||
value, err := strconv.Atoi(trimmed)
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
return value
|
||
}
|
||
|
||
func firstNonEmpty(values ...string) string {
|
||
for _, value := range values {
|
||
if strings.TrimSpace(value) != "" {
|
||
return strings.TrimSpace(value)
|
||
}
|
||
}
|
||
return ""
|
||
}
|
||
|
||
func maxInt(value, floor int) int {
|
||
if value < floor {
|
||
return floor
|
||
}
|
||
return value
|
||
}
|
||
|
||
func normalizeTopLevelQuantity(raw string, serverCount int) int {
|
||
qty := maxInt(parseInt(raw), 1)
|
||
if serverCount <= 1 {
|
||
return qty
|
||
}
|
||
if qty%serverCount == 0 {
|
||
return maxInt(qty/serverCount, 1)
|
||
}
|
||
return qty
|
||
}
|
||
|
||
func IsCFXMLWorkspace(data []byte) bool {
|
||
return bytes.Contains(data, []byte("<CFXML>")) || bytes.Contains(data, []byte("<CFXML "))
|
||
}
|
||
|
||
func IsInspurBOM(data []byte) bool {
|
||
for _, line := range bytes.Split(data, []byte("\n")) {
|
||
trimmed := bytes.TrimSpace(line)
|
||
if len(trimmed) == 0 {
|
||
continue
|
||
}
|
||
idx := bytes.LastIndexByte(trimmed, '*')
|
||
if idx <= 0 {
|
||
continue
|
||
}
|
||
suffix := bytes.TrimSpace(trimmed[idx+1:])
|
||
if len(suffix) > 0 && allDigits(suffix) {
|
||
return true
|
||
}
|
||
}
|
||
return false
|
||
}
|
||
|
||
func allDigits(b []byte) bool {
|
||
if len(b) == 0 {
|
||
return false
|
||
}
|
||
for _, c := range b {
|
||
if c < '0' || c > '9' {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
func parseInspurBOM(data []byte, sourceFileName string) (*importedWorkspace, error) {
|
||
lines := strings.Split(string(data), "\n")
|
||
rows := make([]localdb.VendorSpecItem, 0, len(lines))
|
||
sortOrder := 10
|
||
for _, raw := range lines {
|
||
line := strings.TrimSpace(raw)
|
||
if line == "" {
|
||
continue
|
||
}
|
||
line = strings.TrimPrefix(line, "|")
|
||
line = strings.TrimSpace(line)
|
||
if line == "" {
|
||
continue
|
||
}
|
||
pn := line
|
||
qty := 1
|
||
if idx := strings.LastIndex(line, "*"); idx > 0 {
|
||
suffix := strings.TrimSpace(line[idx+1:])
|
||
if n, err := strconv.Atoi(suffix); err == nil && n > 0 {
|
||
pn = strings.TrimSpace(line[:idx])
|
||
qty = n
|
||
}
|
||
}
|
||
if pn == "" {
|
||
continue
|
||
}
|
||
rows = append(rows, localdb.VendorSpecItem{
|
||
SortOrder: sortOrder,
|
||
VendorPartnumber: pn,
|
||
Quantity: qty,
|
||
})
|
||
sortOrder += 10
|
||
}
|
||
if len(rows) == 0 {
|
||
return nil, fmt.Errorf("Inspur BOM has no importable rows")
|
||
}
|
||
|
||
name := strings.TrimSuffix(filepath.Base(sourceFileName), filepath.Ext(sourceFileName))
|
||
if name == "" {
|
||
name = "Inspur Import"
|
||
}
|
||
|
||
return &importedWorkspace{
|
||
SourceFormat: "Inspur",
|
||
SourceFileName: sourceFileName,
|
||
Configurations: []importedConfiguration{
|
||
{
|
||
GroupID: "inspur-0",
|
||
Name: name,
|
||
Line: 10,
|
||
ServerCount: 1,
|
||
Rows: rows,
|
||
},
|
||
},
|
||
}, nil
|
||
}
|
||
|
||
// IsQuoteForgeCSV reports whether data looks like a QuoteForge own CSV export.
|
||
// The file starts (after optional UTF-8 BOM) with the header line:
|
||
// "Line;Type;p/n;Description;Qty (1 pcs.);Qty (total);..."
|
||
func IsQuoteForgeCSV(data []byte) bool {
|
||
trimmed := bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
|
||
firstLine := trimmed
|
||
if idx := bytes.IndexByte(trimmed, '\n'); idx >= 0 {
|
||
firstLine = trimmed[:idx]
|
||
}
|
||
return bytes.HasPrefix(bytes.TrimSpace(firstLine), []byte("Line;Type;p/n;"))
|
||
}
|
||
|
||
// parseQuoteForgeCSV parses a QuoteForge own CSV export back into importable configurations.
|
||
// Each server block (row where Line column is non-empty) becomes one importedConfiguration
|
||
// with DirectItems populated from the component rows that follow it.
|
||
func parseQuoteForgeCSV(data []byte, sourceFileName string) (*importedWorkspace, error) {
|
||
data = bytes.TrimPrefix(data, []byte{0xEF, 0xBB, 0xBF})
|
||
|
||
r := csv.NewReader(bytes.NewReader(data))
|
||
r.Comma = ';'
|
||
r.FieldsPerRecord = -1
|
||
r.LazyQuotes = true
|
||
|
||
records, err := r.ReadAll()
|
||
if err != nil {
|
||
return nil, fmt.Errorf("parse QuoteForge CSV: %w", err)
|
||
}
|
||
if len(records) == 0 {
|
||
return nil, fmt.Errorf("QuoteForge CSV is empty")
|
||
}
|
||
|
||
// Skip header row (first row whose first cell is "Line")
|
||
startIdx := 0
|
||
if len(records[0]) > 0 && strings.EqualFold(strings.TrimSpace(records[0][0]), "line") {
|
||
startIdx = 1
|
||
}
|
||
|
||
var configs []importedConfiguration
|
||
var current *importedConfiguration
|
||
blockIdx := 0
|
||
|
||
for _, record := range records[startIdx:] {
|
||
if csvAllEmpty(record) {
|
||
continue
|
||
}
|
||
lineCol := strings.TrimSpace(csvCol(record, 0))
|
||
pn := strings.TrimSpace(csvCol(record, 2))
|
||
|
||
if lineCol != "" {
|
||
// New server block
|
||
if current != nil {
|
||
configs = append(configs, *current)
|
||
}
|
||
blockIdx++
|
||
serverCount := maxInt(parseInt(strings.TrimSpace(csvCol(record, 5))), 1)
|
||
article := pn
|
||
name := article
|
||
if name == "" {
|
||
name = fmt.Sprintf("Config %d", blockIdx)
|
||
}
|
||
current = &importedConfiguration{
|
||
GroupID: fmt.Sprintf("qfcsv-%d", blockIdx),
|
||
Name: name,
|
||
Line: blockIdx * 10,
|
||
ServerCount: serverCount,
|
||
Article: article,
|
||
DirectItems: make(localdb.LocalConfigItems, 0),
|
||
}
|
||
} else if pn != "" && current != nil {
|
||
// Component row
|
||
qty := maxInt(parseInt(strings.TrimSpace(csvCol(record, 4))), 1)
|
||
unitPrice := parseCSVPrice(strings.TrimSpace(csvCol(record, 6)))
|
||
current.DirectItems = append(current.DirectItems, localdb.LocalConfigItem{
|
||
LotName: pn,
|
||
Quantity: qty,
|
||
UnitPrice: unitPrice,
|
||
})
|
||
}
|
||
}
|
||
if current != nil {
|
||
configs = append(configs, *current)
|
||
}
|
||
|
||
if len(configs) == 0 {
|
||
return nil, fmt.Errorf("QuoteForge CSV has no importable configurations")
|
||
}
|
||
|
||
return &importedWorkspace{
|
||
SourceFormat: "QuoteForgeCSV",
|
||
SourceFileName: sourceFileName,
|
||
Configurations: configs,
|
||
}, nil
|
||
}
|
||
|
||
// csvCol returns record[idx] or "" when idx is out of range.
|
||
func csvCol(record []string, idx int) string {
|
||
if idx < len(record) {
|
||
return record[idx]
|
||
}
|
||
return ""
|
||
}
|
||
|
||
// csvAllEmpty reports whether every cell in the record is blank.
|
||
func csvAllEmpty(record []string) bool {
|
||
for _, cell := range record {
|
||
if strings.TrimSpace(cell) != "" {
|
||
return false
|
||
}
|
||
}
|
||
return true
|
||
}
|
||
|
||
// parseCSVPrice parses a price string in QuoteForge CSV format:
|
||
// comma as decimal separator, optional space as thousands separator.
|
||
// Returns 0 on any parse failure.
|
||
func parseCSVPrice(s string) float64 {
|
||
if s == "" || s == "—" {
|
||
return 0
|
||
}
|
||
// Remove thousands separators (space, non-breaking space)
|
||
s = strings.ReplaceAll(s, " ", "")
|
||
s = strings.ReplaceAll(s, " ", "")
|
||
s = strings.ReplaceAll(s, " ", "")
|
||
// Replace comma decimal separator with dot
|
||
s = strings.ReplaceAll(s, ",", ".")
|
||
v, err := strconv.ParseFloat(s, 64)
|
||
if err != nil {
|
||
return 0
|
||
}
|
||
return v
|
||
}
|