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:
2026-05-24 18:54:08 +03:00
parent 6b56cad248
commit 5d4e1b44f6
3 changed files with 289 additions and 10 deletions

View File

@@ -2,6 +2,7 @@ package services
import ( import (
"bytes" "bytes"
"encoding/csv"
"encoding/xml" "encoding/xml"
"fmt" "fmt"
"path/filepath" "path/filepath"
@@ -47,7 +48,8 @@ type importedConfiguration struct {
ServerModel string ServerModel string
Article string Article string
CurrencyCode string CurrencyCode string
Rows []localdb.VendorSpecItem Rows []localdb.VendorSpecItem // vendor BOM formats (CFXML, Inspur)
DirectItems localdb.LocalConfigItems // direct LOT formats (QuoteForge CSV)
TotalPrice *float64 TotalPrice *float64
} }
@@ -128,6 +130,8 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s
switch { switch {
case IsCFXMLWorkspace(data): case IsCFXMLWorkspace(data):
workspace, err = parseCFXMLWorkspace(data, filepath.Base(sourceFileName)) workspace, err = parseCFXMLWorkspace(data, filepath.Base(sourceFileName))
case IsQuoteForgeCSV(data):
workspace, err = parseQuoteForgeCSV(data, filepath.Base(sourceFileName))
case IsInspurBOM(data): case IsInspurBOM(data):
workspace, err = parseInspurBOM(data, filepath.Base(sourceFileName)) workspace, err = parseInspurBOM(data, filepath.Base(sourceFileName))
default: default:
@@ -148,10 +152,28 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s
for _, imported := range workspace.Configurations { for _, imported := range workspace.Configurations {
now := time.Now() now := time.Now()
cfgUUID := uuid.NewString() cfgUUID := uuid.NewString()
groupRows, items, totalPrice, estimatePricelistID, err := s.prepareImportedConfiguration(imported.Rows, imported.ServerCount, bookRepo)
if err != nil { var groupRows localdb.VendorSpec
return fmt.Errorf("prepare imported configuration group %s: %w", imported.GroupID, err) 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{ localCfg := &localdb.LocalConfiguration{
UUID: cfgUUID, UUID: cfgUUID,
ProjectUUID: &projectUUID, ProjectUUID: &projectUUID,
@@ -653,3 +675,135 @@ func parseInspurBOM(data []byte, sourceFileName string) (*importedWorkspace, err
}, },
}, nil }, 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
}

View File

@@ -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) { func TestIsInspurBOM(t *testing.T) {
cases := []struct { cases := []struct {
input string input string

View File

@@ -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 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"> <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="space-y-4">
<div class="text-sm text-gray-600"> <div class="text-sm text-gray-600">
Загружает `CFXML`-выгрузку в текущий проект и создаёт несколько конфигураций, если они есть в файле. Поддерживаемые форматы: CFXML-выгрузка вендора (.xml), собственный CSV-экспорт QuoteForge (.csv), текстовый BOM Inspur (.txt).
</div> </div>
<div> <div>
<label for="vendor-import-file" class="block text-sm font-medium text-gray-700 mb-1">Файл выгрузки</label> <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" <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"> class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-amber-500 focus:border-amber-500">
</div> </div>
<div id="vendor-import-status" class="hidden text-sm rounded border px-3 py-2"></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 input = document.getElementById('vendor-import-file');
const submit = document.getElementById('vendor-import-submit'); const submit = document.getElementById('vendor-import-submit');
if (!input || !input.files || !input.files[0]) { if (!input || !input.files || !input.files[0]) {
setVendorImportStatus('Выберите XML-файл выгрузки', 'error'); setVendorImportStatus('Выберите файл для импорта', 'error');
return; return;
} }
@@ -1611,11 +1611,30 @@ async function refreshAllPrices() {
serverSyncSkipped = true; 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 failed = 0;
let newTotalSum = 0; let newTotalSum = 0;
for (const cfg of configs) { for (const cfg of configs) {
try { 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; } if (!resp.ok) { failed++; continue; }
const updated = await resp.json(); const updated = await resp.json();
if (updated && updated.total_price != null) { if (updated && updated.total_price != null) {