feat: импорт собственного CSV QuoteForge + fix обновления цен
Добавлен парсер собственного 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>
This commit is contained in:
@@ -2,6 +2,7 @@ package services
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/csv"
|
||||
"encoding/xml"
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
@@ -47,7 +48,8 @@ type importedConfiguration struct {
|
||||
ServerModel string
|
||||
Article string
|
||||
CurrencyCode string
|
||||
Rows []localdb.VendorSpecItem
|
||||
Rows []localdb.VendorSpecItem // vendor BOM formats (CFXML, Inspur)
|
||||
DirectItems localdb.LocalConfigItems // direct LOT formats (QuoteForge CSV)
|
||||
TotalPrice *float64
|
||||
}
|
||||
|
||||
@@ -128,6 +130,8 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s
|
||||
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:
|
||||
@@ -148,10 +152,28 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s
|
||||
for _, imported := range workspace.Configurations {
|
||||
now := time.Now()
|
||||
cfgUUID := uuid.NewString()
|
||||
groupRows, items, totalPrice, estimatePricelistID, err := s.prepareImportedConfiguration(imported.Rows, imported.ServerCount, bookRepo)
|
||||
if err != nil {
|
||||
return fmt.Errorf("prepare imported configuration group %s: %w", imported.GroupID, err)
|
||||
|
||||
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,
|
||||
@@ -653,3 +675,135 @@ func parseInspurBOM(data []byte, sourceFileName string) (*importedWorkspace, err
|
||||
},
|
||||
}, 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
|
||||
}
|
||||
|
||||
@@ -463,6 +463,112 @@ PowerSupply_1300W*2`
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseQuoteForgeCSV(t *testing.T) {
|
||||
// Format mirrors ToCSV output: col[0]=Line, col[1]=Type, col[2]=p/n,
|
||||
// col[3]=Description, col[4]=Qty(1pcs), col[5]=Qty(total), col[6]=Price(1pcs), col[7]=Price(total)
|
||||
const sample = "\xEF\xBB\xBF" + // UTF-8 BOM
|
||||
"Line;Type;p/n;Description;Qty (1 pcs.);Qty (total);Price (1 pcs.);Price (total)\n" +
|
||||
"10;;DL380-ARTICLE;;;2;10470;20 940\n" +
|
||||
";MEMORY;MB_INTEL_A1;;1;;2074,5;\n" +
|
||||
";CPU;CPU_XEON_X;;2;;5100;\n" +
|
||||
"\n" +
|
||||
"20;;DL380-ARTICLE-2;;;1;8000;8 000\n" +
|
||||
";STORAGE;SSD_NVMe;;4;;1200;\n"
|
||||
|
||||
workspace, err := parseQuoteForgeCSV([]byte(sample), "project.csv")
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
if workspace.SourceFormat != "QuoteForgeCSV" {
|
||||
t.Fatalf("expected SourceFormat QuoteForgeCSV, got %q", workspace.SourceFormat)
|
||||
}
|
||||
if len(workspace.Configurations) != 2 {
|
||||
t.Fatalf("expected 2 configurations, got %d", len(workspace.Configurations))
|
||||
}
|
||||
|
||||
cfg1 := workspace.Configurations[0]
|
||||
if cfg1.Article != "DL380-ARTICLE" {
|
||||
t.Fatalf("cfg1 article: want DL380-ARTICLE, got %q", cfg1.Article)
|
||||
}
|
||||
if cfg1.ServerCount != 2 {
|
||||
t.Fatalf("cfg1 server_count: want 2, got %d", cfg1.ServerCount)
|
||||
}
|
||||
if len(cfg1.DirectItems) != 2 {
|
||||
t.Fatalf("cfg1 items: want 2, got %d", len(cfg1.DirectItems))
|
||||
}
|
||||
if cfg1.DirectItems[0].LotName != "MB_INTEL_A1" || cfg1.DirectItems[0].Quantity != 1 {
|
||||
t.Fatalf("cfg1 item[0]: %+v", cfg1.DirectItems[0])
|
||||
}
|
||||
if cfg1.DirectItems[1].LotName != "CPU_XEON_X" || cfg1.DirectItems[1].Quantity != 2 {
|
||||
t.Fatalf("cfg1 item[1]: %+v", cfg1.DirectItems[1])
|
||||
}
|
||||
if cfg1.DirectItems[1].UnitPrice != 5100 {
|
||||
t.Fatalf("cfg1 item[1] price: want 5100, got %v", cfg1.DirectItems[1].UnitPrice)
|
||||
}
|
||||
|
||||
cfg2 := workspace.Configurations[1]
|
||||
if cfg2.Article != "DL380-ARTICLE-2" {
|
||||
t.Fatalf("cfg2 article: want DL380-ARTICLE-2, got %q", cfg2.Article)
|
||||
}
|
||||
if cfg2.ServerCount != 1 {
|
||||
t.Fatalf("cfg2 server_count: want 1, got %d", cfg2.ServerCount)
|
||||
}
|
||||
if len(cfg2.DirectItems) != 1 || cfg2.DirectItems[0].LotName != "SSD_NVMe" {
|
||||
t.Fatalf("cfg2 items: %+v", cfg2.DirectItems)
|
||||
}
|
||||
}
|
||||
|
||||
func TestIsQuoteForgeCSV(t *testing.T) {
|
||||
withBOM := "\xEF\xBB\xBFLine;Type;p/n;Description;Qty (1 pcs.);Qty (total);Price (1 pcs.);Price (total)\n10;;ART;;1;;100;\n"
|
||||
noBOM := "Line;Type;p/n;Description;Qty (1 pcs.);Qty (total);Price (1 pcs.);Price (total)\n"
|
||||
|
||||
cases := []struct {
|
||||
input string
|
||||
want bool
|
||||
}{
|
||||
{withBOM, true},
|
||||
{noBOM, true},
|
||||
{"<CFXML>\n</CFXML>", false},
|
||||
{"|CPU*1\n|PSU*2", false},
|
||||
{"", false},
|
||||
{"Line;other;columns\n", false},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := IsQuoteForgeCSV([]byte(tc.input))
|
||||
if got != tc.want {
|
||||
t.Errorf("IsQuoteForgeCSV(%q) = %v, want %v", tc.input[:min(len(tc.input), 40)], got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCSVPrice(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
want float64
|
||||
}{
|
||||
{"2074,5", 2074.5},
|
||||
{"5100", 5100},
|
||||
{"104 700", 104700},
|
||||
{"20 940", 20940},
|
||||
{"—", 0},
|
||||
{"", 0},
|
||||
{"abc", 0},
|
||||
}
|
||||
for _, tc := range cases {
|
||||
got := parseCSVPrice(tc.input)
|
||||
if got != tc.want {
|
||||
t.Errorf("parseCSVPrice(%q) = %v, want %v", tc.input, got, tc.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func min(a, b int) int {
|
||||
if a < b {
|
||||
return a
|
||||
}
|
||||
return b
|
||||
}
|
||||
|
||||
func TestIsInspurBOM(t *testing.T) {
|
||||
cases := []struct {
|
||||
input string
|
||||
|
||||
@@ -108,14 +108,14 @@
|
||||
|
||||
<div id="vendor-import-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4 p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Импорт выгрузки вендора</h2>
|
||||
<h2 class="text-xl font-semibold mb-4">Импорт конфигураций</h2>
|
||||
<div class="space-y-4">
|
||||
<div class="text-sm text-gray-600">
|
||||
Загружает `CFXML`-выгрузку в текущий проект и создаёт несколько конфигураций, если они есть в файле.
|
||||
Поддерживаемые форматы: CFXML-выгрузка вендора (.xml), собственный CSV-экспорт QuoteForge (.csv), текстовый BOM Inspur (.txt).
|
||||
</div>
|
||||
<div>
|
||||
<label for="vendor-import-file" class="block text-sm font-medium text-gray-700 mb-1">Файл выгрузки</label>
|
||||
<input id="vendor-import-file" type="file" accept=".xml,text/xml,application/xml"
|
||||
<label for="vendor-import-file" class="block text-sm font-medium text-gray-700 mb-1">Файл</label>
|
||||
<input id="vendor-import-file" type="file" accept=".xml,.csv,.txt,text/xml,application/xml,text/csv,text/plain"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-amber-500 focus:border-amber-500">
|
||||
</div>
|
||||
<div id="vendor-import-status" class="hidden text-sm rounded border px-3 py-2"></div>
|
||||
@@ -911,7 +911,7 @@ async function importVendorWorkspace() {
|
||||
const input = document.getElementById('vendor-import-file');
|
||||
const submit = document.getElementById('vendor-import-submit');
|
||||
if (!input || !input.files || !input.files[0]) {
|
||||
setVendorImportStatus('Выберите XML-файл выгрузки', 'error');
|
||||
setVendorImportStatus('Выберите файл для импорта', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -1611,11 +1611,30 @@ async function refreshAllPrices() {
|
||||
serverSyncSkipped = true;
|
||||
}
|
||||
|
||||
// Resolve latest estimate pricelist ID to pass explicitly, so each config
|
||||
// is updated to the newest pricelist rather than the one stored in the config.
|
||||
let latestEstimatePricelistId = null;
|
||||
try {
|
||||
const plResp = await fetch('/api/pricelists?active_only=true&source=estimate&per_page=1');
|
||||
if (plResp.ok) {
|
||||
const plData = await plResp.json();
|
||||
const list = plData.pricelists || plData.items || plData;
|
||||
if (Array.isArray(list) && list.length > 0 && list[0].id) {
|
||||
latestEstimatePricelistId = Number(list[0].id);
|
||||
}
|
||||
}
|
||||
} catch (_) {}
|
||||
|
||||
let failed = 0;
|
||||
let newTotalSum = 0;
|
||||
for (const cfg of configs) {
|
||||
try {
|
||||
const resp = await fetch('/api/configs/' + cfg.uuid + '/refresh-prices', { method: 'POST' });
|
||||
const body = latestEstimatePricelistId ? JSON.stringify({ pricelist_id: latestEstimatePricelistId }) : undefined;
|
||||
const resp = await fetch('/api/configs/' + cfg.uuid + '/refresh-prices', {
|
||||
method: 'POST',
|
||||
headers: body ? { 'Content-Type': 'application/json' } : {},
|
||||
body,
|
||||
});
|
||||
if (!resp.ok) { failed++; continue; }
|
||||
const updated = await resp.json();
|
||||
if (updated && updated.total_price != null) {
|
||||
|
||||
Reference in New Issue
Block a user