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 (
|
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
|
||||||
|
}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
Reference in New Issue
Block a user