fix: текстовый BOM работает в пасте конфигуратора через единый серверный парсер
Паста BOM на странице конфигурирования теперь распознаёт текстовый и
Inspur-форматы: вместо дублирования парсера на JS добавлен stateless
эндпоинт POST /api/vendor-spec/parse-text, который использует те же
детекторы и парсеры, что и импорт файла (KISS — один парсер на оба
входа). JS-копии _parseInspurBOMText/_isInspurBOMText удалены.
Заголовок конфигурации определяется по маркеру ", в составе:" с любым
префиксом ("Сервер X3" и "Вычислительный GPU сервер X3" → модель X3);
строки тримятся, пробел в начале не попадает в P/N; запятые и дефисы
внутри описания сохраняются (RAID0,1,10; 8-GPU-2304GB).
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -85,14 +85,14 @@ Rules:
|
|||||||
|
|
||||||
The same endpoint `POST /api/projects/:uuid/vendor-import` also accepts a human-readable Russian text BOM.
|
The same endpoint `POST /api/projects/:uuid/vendor-import` also accepts a human-readable Russian text BOM.
|
||||||
|
|
||||||
Format: an optional header line `Сервер <model>, в составе:` followed by one component per line as
|
Format: an optional header line ending with `, в составе:` followed by one component per line as
|
||||||
`<description> - <quantity> шт.`. The separator may be a hyphen, en-dash, or em-dash; the space before
|
`<description> - <quantity> шт.`. The separator may be a hyphen, en-dash, or em-dash; the space before
|
||||||
`шт` is optional; a trailing `.` after `шт` is optional. Quantities are anchored to the end of the line,
|
`шт` is optional; a trailing `.` after `шт` is optional. Quantities are anchored to the end of the line,
|
||||||
so hyphens and digits inside the description (e.g. `8-GPU-2304GB`) are preserved.
|
so hyphens, commas, and digits inside the description (e.g. `8-GPU-2304GB`, `RAID0,1,10`) are preserved.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
```
|
```
|
||||||
Сервер KR9288X3, в составе:
|
Вычислительный GPU сервер G5500V7, в составе:
|
||||||
GPU-NVIDIA HGX B300 8-GPU-2304GB HBM3E - 1 шт.
|
GPU-NVIDIA HGX B300 8-GPU-2304GB HBM3E - 1 шт.
|
||||||
CPU Intel 6760P Xeon 2.2GHz 64C 320M 330W - 2 шт.
|
CPU Intel 6760P Xeon 2.2GHz 64C 320M 330W - 2 шт.
|
||||||
Mem 128G DDR5-6400MHz ECC-RDIMM - 16 шт.
|
Mem 128G DDR5-6400MHz ECC-RDIMM - 16 шт.
|
||||||
@@ -101,10 +101,25 @@ NVIDIA twin port transceiver, 800Gbps, OSFP - 8шт.
|
|||||||
|
|
||||||
Rules:
|
Rules:
|
||||||
- the entire file becomes a single configuration (`server_count = 1`);
|
- the entire file becomes a single configuration (`server_count = 1`);
|
||||||
- the `Сервер <model>, в составе:` header supplies the configuration `name` and `server_model`;
|
- the header (any line ending with `, в составе:`) supplies `server_model` and `name`; the model is the
|
||||||
|
last whitespace-separated token before the comma (so both `Сервер X3` and `Вычислительный GPU сервер X3`
|
||||||
|
resolve to `X3`);
|
||||||
- without a header, `name` falls back to the uploaded filename (without extension) and `server_model` is empty;
|
- without a header, `name` falls back to the uploaded filename (without extension) and `server_model` is empty;
|
||||||
|
- each line is trimmed, so leading/trailing whitespace never enters `vendor_partnumber`;
|
||||||
- the format carries no partnumbers — each line's description is stored as both `vendor_partnumber` and
|
- the format carries no partnumbers — each line's description is stored as both `vendor_partnumber` and
|
||||||
`description`, so rows resolve through the active partnumber book when matched and otherwise stay
|
`description`, so rows resolve through the active partnumber book when matched and otherwise stay
|
||||||
unresolved and editable in the UI;
|
unresolved and editable in the UI;
|
||||||
- lines that do not match `<description> - <quantity> шт.` are skipped;
|
- lines that do not match `<description> - <quantity> шт.` are skipped;
|
||||||
- no price data is present in the format; `unit_price` and `total_price` are left nil.
|
- no price data is present in the format; `unit_price` and `total_price` are left nil.
|
||||||
|
|
||||||
|
## Pasted BOM text parsing
|
||||||
|
|
||||||
|
`POST /api/vendor-spec/parse-text` is a stateless endpoint that parses pasted single-column text BOM
|
||||||
|
(Inspur and Russian text BOM) into rows. Request body: `{"text": "..."}`. Response:
|
||||||
|
`{"rows": [{vendor_partnumber, quantity, description}], "format": "Inspur"|"Text"|""}`.
|
||||||
|
|
||||||
|
This shares the exact detectors and parsers used by the file-import path
|
||||||
|
(`ParsePastedBOMText` → `IsInspurBOM`/`parseInspurBOM`, `IsTextBOM`/`parseTextBOM`), so paste and upload
|
||||||
|
behave identically — there is no second parser in the frontend. The configurator's BOM paste box calls
|
||||||
|
this endpoint; an empty `rows` result (or any payload containing tabs, i.e. a real spreadsheet table)
|
||||||
|
falls back to the manual column-mapping grid.
|
||||||
|
|||||||
@@ -951,6 +951,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
pnBooks.GET("/:id", partnumberBooksHandler.GetItems)
|
pnBooks.GET("/:id", partnumberBooksHandler.GetItems)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Stateless BOM text parsing shared by paste and file-import paths.
|
||||||
|
api.POST("/vendor-spec/parse-text", vendorSpecHandler.ParseText)
|
||||||
|
|
||||||
// Configurations (public - RBAC disabled)
|
// Configurations (public - RBAC disabled)
|
||||||
configs := api.Group("/configs")
|
configs := api.Group("/configs")
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -37,6 +37,28 @@ func (h *VendorSpecHandler) lookupConfig(uuid string) (*localdb.LocalConfigurati
|
|||||||
return cfg, nil
|
return cfg, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ParseText parses a pasted single-column text BOM (Inspur or Russian text BOM)
|
||||||
|
// using the same parsers as the vendor file-import path. It is stateless: no
|
||||||
|
// configuration is required. Returns the parsed rows and the detected format, or
|
||||||
|
// an empty result when the text is not a recognized single-column format (the
|
||||||
|
// client then falls back to manual column mapping).
|
||||||
|
// POST /api/vendor-spec/parse-text
|
||||||
|
func (h *VendorSpecHandler) ParseText(c *gin.Context) {
|
||||||
|
var body struct {
|
||||||
|
Text string `json:"text"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
|
RespondError(c, http.StatusUnprocessableEntity, "invalid request", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
rows, format := services.ParsePastedBOMText(body.Text)
|
||||||
|
if rows == nil {
|
||||||
|
rows = []localdb.VendorSpecItem{}
|
||||||
|
}
|
||||||
|
c.JSON(http.StatusOK, gin.H{"rows": rows, "format": format})
|
||||||
|
}
|
||||||
|
|
||||||
// GetVendorSpec returns the vendor spec (BOM) for a configuration.
|
// GetVendorSpec returns the vendor spec (BOM) for a configuration.
|
||||||
// GET /api/configs/:uuid/vendor-spec
|
// GET /api/configs/:uuid/vendor-spec
|
||||||
func (h *VendorSpecHandler) GetVendorSpec(c *gin.Context) {
|
func (h *VendorSpecHandler) GetVendorSpec(c *gin.Context) {
|
||||||
|
|||||||
@@ -690,8 +690,35 @@ func parseInspurBOM(data []byte, sourceFileName string) (*importedWorkspace, err
|
|||||||
// description (e.g. "8-GPU-2304GB") from being mistaken for the separator.
|
// description (e.g. "8-GPU-2304GB") from being mistaken for the separator.
|
||||||
var textBOMItemLine = regexp.MustCompile(`(?i)^(.*\S)\s*[-–—]\s*(\d+)\s*шт\.?\s*$`)
|
var textBOMItemLine = regexp.MustCompile(`(?i)^(.*\S)\s*[-–—]\s*(\d+)\s*шт\.?\s*$`)
|
||||||
|
|
||||||
// textBOMHeaderLine matches the configuration header "Сервер <model>, в составе:".
|
// textBOMHeaderLine matches a configuration header ending with ", в составе:"
|
||||||
var textBOMHeaderLine = regexp.MustCompile(`(?i)^\s*сервер\s+(.+?)\s*,\s*в\s+составе`)
|
// regardless of the leading words (e.g. "Сервер <model>" or
|
||||||
|
// "Вычислительный GPU сервер <model>"). The captured group is everything before
|
||||||
|
// the comma; the model is its last whitespace-separated token.
|
||||||
|
var textBOMHeaderLine = regexp.MustCompile(`(?i)^(.*?)\s*,\s*в\s+составе`)
|
||||||
|
|
||||||
|
// ParsePastedBOMText detects and parses a single-column text BOM (Inspur or
|
||||||
|
// Russian text BOM) pasted into the configurator. It shares the same detectors
|
||||||
|
// and parsers as the vendor file-import path, so paste and upload behave
|
||||||
|
// identically. It returns the parsed vendor spec rows and the detected format,
|
||||||
|
// or (nil, "") when the text is not a recognized single-column format and the
|
||||||
|
// caller should fall back to manual column mapping.
|
||||||
|
func ParsePastedBOMText(text string) ([]localdb.VendorSpecItem, string) {
|
||||||
|
data := []byte(text)
|
||||||
|
var ws *importedWorkspace
|
||||||
|
var err error
|
||||||
|
switch {
|
||||||
|
case IsInspurBOM(data):
|
||||||
|
ws, err = parseInspurBOM(data, "")
|
||||||
|
case IsTextBOM(data):
|
||||||
|
ws, err = parseTextBOM(data, "")
|
||||||
|
default:
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
if err != nil || ws == nil || len(ws.Configurations) == 0 {
|
||||||
|
return nil, ""
|
||||||
|
}
|
||||||
|
return ws.Configurations[0].Rows, ws.SourceFormat
|
||||||
|
}
|
||||||
|
|
||||||
// IsTextBOM reports whether data looks like a human-readable Russian text BOM,
|
// IsTextBOM reports whether data looks like a human-readable Russian text BOM,
|
||||||
// i.e. it contains at least one "<description> - <quantity> шт." line.
|
// i.e. it contains at least one "<description> - <quantity> шт." line.
|
||||||
@@ -721,7 +748,9 @@ func parseTextBOM(data []byte, sourceFileName string) (*importedWorkspace, error
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
if m := textBOMHeaderLine.FindStringSubmatch(line); m != nil {
|
if m := textBOMHeaderLine.FindStringSubmatch(line); m != nil {
|
||||||
serverModel = strings.TrimSpace(m[1])
|
if fields := strings.Fields(m[1]); len(fields) > 0 {
|
||||||
|
serverModel = fields[len(fields)-1]
|
||||||
|
}
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
m := textBOMItemLine.FindStringSubmatch(line)
|
m := textBOMItemLine.FindStringSubmatch(line)
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ package services
|
|||||||
|
|
||||||
import (
|
import (
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
@@ -689,6 +690,117 @@ NVIDIA twin port transceiver, 800Gbps,2xNDR, OSFP, 2xMPO12 APC, 850nm MM F, up t
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestParseTextBOMVariantHeaderAndLeadingSpace(t *testing.T) {
|
||||||
|
// Header does not start with "Сервер"; some lines have leading/trailing spaces;
|
||||||
|
// descriptions contain commas and internal hyphens.
|
||||||
|
const sample = `Вычислительный GPU сервер G5500V7, в составе:
|
||||||
|
Серверное шасси G5500 V7 (12NVMe + 8SAS/SATA) - 1 шт.
|
||||||
|
Процессор Intel 8558P 48C 2.7G 260MB 350W - 2 шт.
|
||||||
|
Модуль оперативной памяти Mem 128G DDR5-5600MHz ECC-RDIMM - 16 шт.
|
||||||
|
Накопитель SSD 2.5" NVMe 3.84TB - 8 шт.
|
||||||
|
Накопитель SSD 2.5" SATA 3.84TB - 2 шт.
|
||||||
|
Адаптер SAS/SATA RAID0,1,10 12Gb/s-no Cache - 1 шт.
|
||||||
|
Адаптер 25GE(CX6-Lx)-Dual Port SFP28 - 1 шт.
|
||||||
|
Сетевая карта 4 x 1G, Base-T - 1 шт.
|
||||||
|
Адаптер HBA Emulex LPe32002 2 Port 32GFC - 1 шт.
|
||||||
|
Крепежный комплект Ball Bearing Rail Kit - 1 шт.
|
||||||
|
Кабельный органайзер Cable Management Arm - 1 шт.
|
||||||
|
Кабель питания PowerCord 3m C20 C19 - 4 шт.
|
||||||
|
Видеокарта (видеоускоритель) NVIDIA RTX PRO 6000 Server Edition 96GB GDDR7 - 8 шт.
|
||||||
|
Блок питания 3000W Titanium AC Power Supply - 4 шт.`
|
||||||
|
|
||||||
|
workspace, err := parseTextBOM([]byte(sample), "spec.txt")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("unexpected error: %v", err)
|
||||||
|
}
|
||||||
|
cfg := workspace.Configurations[0]
|
||||||
|
if cfg.ServerModel != "G5500V7" {
|
||||||
|
t.Fatalf("expected ServerModel G5500V7 (last token before comma), got %q", cfg.ServerModel)
|
||||||
|
}
|
||||||
|
if cfg.Name != "G5500V7" {
|
||||||
|
t.Fatalf("expected name G5500V7, got %q", cfg.Name)
|
||||||
|
}
|
||||||
|
const wantRows = 14
|
||||||
|
if len(cfg.Rows) != wantRows {
|
||||||
|
t.Fatalf("expected %d rows, got %d", wantRows, len(cfg.Rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, r := range cfg.Rows {
|
||||||
|
if r.VendorPartnumber != strings.TrimSpace(r.VendorPartnumber) {
|
||||||
|
t.Fatalf("vendor_partnumber has surrounding whitespace: %q", r.VendorPartnumber)
|
||||||
|
}
|
||||||
|
if r.Description != strings.TrimSpace(r.Description) {
|
||||||
|
t.Fatalf("description has surrounding whitespace: %q", r.Description)
|
||||||
|
}
|
||||||
|
if r.VendorPartnumber == "" {
|
||||||
|
t.Fatal("empty vendor_partnumber")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rowsByDesc := make(map[string]localdb.VendorSpecItem, len(cfg.Rows))
|
||||||
|
for _, r := range cfg.Rows {
|
||||||
|
rowsByDesc[r.VendorPartnumber] = r
|
||||||
|
}
|
||||||
|
|
||||||
|
// Leading-space line must yield a trimmed P/N.
|
||||||
|
sata, ok := rowsByDesc[`Накопитель SSD 2.5" SATA 3.84TB`]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected SATA SSD row not found (check leading-space trimming)")
|
||||||
|
}
|
||||||
|
if sata.Quantity != 2 {
|
||||||
|
t.Fatalf("SATA SSD: expected qty 2, got %d", sata.Quantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Commas inside the description must not break parsing.
|
||||||
|
raid, ok := rowsByDesc["Адаптер SAS/SATA RAID0,1,10 12Gb/s-no Cache"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected RAID adapter row not found (check commas in description)")
|
||||||
|
}
|
||||||
|
if raid.Quantity != 1 {
|
||||||
|
t.Fatalf("RAID adapter: expected qty 1, got %d", raid.Quantity)
|
||||||
|
}
|
||||||
|
|
||||||
|
gpu, ok := rowsByDesc["Видеокарта (видеоускоритель) NVIDIA RTX PRO 6000 Server Edition 96GB GDDR7"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("expected GPU row not found")
|
||||||
|
}
|
||||||
|
if gpu.Quantity != 8 {
|
||||||
|
t.Fatalf("GPU: expected qty 8, got %d", gpu.Quantity)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestParsePastedBOMText(t *testing.T) {
|
||||||
|
t.Run("text BOM", func(t *testing.T) {
|
||||||
|
rows, format := ParsePastedBOMText("Сервер X1, в составе:\nCPU Intel 6760P - 2 шт.\nMem 128G - 16 шт.")
|
||||||
|
if format != "Text" {
|
||||||
|
t.Fatalf("expected format Text, got %q", format)
|
||||||
|
}
|
||||||
|
if len(rows) != 2 {
|
||||||
|
t.Fatalf("expected 2 rows, got %d", len(rows))
|
||||||
|
}
|
||||||
|
if rows[0].VendorPartnumber != "CPU Intel 6760P" || rows[0].Quantity != 2 {
|
||||||
|
t.Fatalf("unexpected first row: %+v", rows[0])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("inspur BOM", func(t *testing.T) {
|
||||||
|
rows, format := ParsePastedBOMText("|CPU_AMD*1\n|PSU*2")
|
||||||
|
if format != "Inspur" {
|
||||||
|
t.Fatalf("expected format Inspur, got %q", format)
|
||||||
|
}
|
||||||
|
if len(rows) != 2 || rows[1].Quantity != 2 {
|
||||||
|
t.Fatalf("unexpected rows: %+v", rows)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("unrecognized falls through", func(t *testing.T) {
|
||||||
|
rows, format := ParsePastedBOMText("col a\tcol b\nfoo\tbar")
|
||||||
|
if rows != nil || format != "" {
|
||||||
|
t.Fatalf("expected nil/empty for unrecognized text, got rows=%+v format=%q", rows, format)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestParseTextBOMNameFromFilename(t *testing.T) {
|
func TestParseTextBOMNameFromFilename(t *testing.T) {
|
||||||
const sample = "CPU Intel 6760P - 2 шт.\nMem 128G - 16 шт."
|
const sample = "CPU Intel 6760P - 2 шт.\nMem 128G - 16 шт."
|
||||||
workspace, err := parseTextBOM([]byte(sample), "my-config.txt")
|
workspace, err := parseTextBOM([]byte(sample), "my-config.txt")
|
||||||
|
|||||||
@@ -3054,62 +3054,44 @@ function _normalizeBomRawRows(rows) {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function _parseInspurBOMText(text) {
|
function _applyParsedBOMRows(parsed) {
|
||||||
const lines = text.split(/\r?\n/);
|
if (!Array.isArray(parsed) || !parsed.length) return false;
|
||||||
const result = [];
|
bomImportRaw = {
|
||||||
for (const raw of lines) {
|
mode: 'raw',
|
||||||
const line = raw.trim();
|
rows: parsed,
|
||||||
if (!line) continue;
|
columnTypes: ['pn', 'qty'],
|
||||||
const clean = line.startsWith('|') ? line.slice(1).trim() : line;
|
ignoredRows: {},
|
||||||
if (!clean) continue;
|
rowErrors: {},
|
||||||
const starIdx = clean.lastIndexOf('*');
|
uiError: ''
|
||||||
if (starIdx > 0) {
|
};
|
||||||
const suffix = clean.slice(starIdx + 1).trim();
|
bomRows = [];
|
||||||
if (/^\d+$/.test(suffix)) {
|
_setBomUIError('');
|
||||||
result.push([clean.slice(0, starIdx).trim(), suffix]);
|
rebuildBOMRowsFromRaw();
|
||||||
continue;
|
renderBOMTable();
|
||||||
}
|
return true;
|
||||||
}
|
|
||||||
result.push([clean, '1']);
|
|
||||||
}
|
|
||||||
return result;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function _isInspurBOMText(text) {
|
// Detection and parsing of known single-column text BOM formats (Inspur, Russian
|
||||||
const lines = text.split(/\r?\n/).filter(l => l.trim().length > 0);
|
// text BOM) lives only on the server (internal/services/vendor_workspace_import.go),
|
||||||
if (!lines.length) return false;
|
// shared with the vendor file-import path. The paste handler asks the server to
|
||||||
let matches = 0;
|
// parse; an unrecognized payload falls back to the generic Excel column grid below.
|
||||||
for (const line of lines) {
|
async function _serverParseBOMText(text) {
|
||||||
const t = line.trim();
|
try {
|
||||||
const idx = t.lastIndexOf('*');
|
const resp = await fetch('/api/vendor-spec/parse-text', {
|
||||||
if (idx > 0 && /^\d+$/.test(t.slice(idx + 1).trim())) matches++;
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify({text})
|
||||||
|
});
|
||||||
|
if (!resp.ok) return null;
|
||||||
|
const data = await resp.json();
|
||||||
|
if (!Array.isArray(data.rows) || !data.rows.length) return null;
|
||||||
|
return data.rows.map(r => [r.vendor_partnumber || '', String(r.quantity || '')]);
|
||||||
|
} catch (e) {
|
||||||
|
return null;
|
||||||
}
|
}
|
||||||
return matches > 0 && matches >= Math.ceil(lines.length * 0.5);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleBOMPaste(event) {
|
function _applyGenericBOMPaste(text) {
|
||||||
event.preventDefault();
|
|
||||||
const text = event.clipboardData.getData('text/plain');
|
|
||||||
if (!text || !text.trim()) return;
|
|
||||||
|
|
||||||
if (_isInspurBOMText(text)) {
|
|
||||||
const parsed = _parseInspurBOMText(text);
|
|
||||||
if (!parsed.length) return;
|
|
||||||
bomImportRaw = {
|
|
||||||
mode: 'raw',
|
|
||||||
rows: parsed,
|
|
||||||
columnTypes: ['pn', 'qty'],
|
|
||||||
ignoredRows: {},
|
|
||||||
rowErrors: {},
|
|
||||||
uiError: ''
|
|
||||||
};
|
|
||||||
bomRows = [];
|
|
||||||
_setBomUIError('');
|
|
||||||
rebuildBOMRowsFromRaw();
|
|
||||||
renderBOMTable();
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const lines = text.split(/\r?\n/).filter(l => l.length > 0);
|
const lines = text.split(/\r?\n/).filter(l => l.length > 0);
|
||||||
if (!lines.length) return;
|
if (!lines.length) return;
|
||||||
const rows = lines.map(l => l.split('\t').map(c => c.trim()));
|
const rows = lines.map(l => l.split('\t').map(c => c.trim()));
|
||||||
@@ -3129,6 +3111,20 @@ function handleBOMPaste(event) {
|
|||||||
renderBOMTable();
|
renderBOMTable();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function handleBOMPaste(event) {
|
||||||
|
event.preventDefault();
|
||||||
|
const text = event.clipboardData.getData('text/plain');
|
||||||
|
if (!text || !text.trim()) return;
|
||||||
|
|
||||||
|
// Tabs mean a real spreadsheet table — go straight to the column grid.
|
||||||
|
if (!text.includes('\t')) {
|
||||||
|
const parsed = await _serverParseBOMText(text);
|
||||||
|
if (_applyParsedBOMRows(parsed)) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_applyGenericBOMPaste(text);
|
||||||
|
}
|
||||||
|
|
||||||
function _getBomColumnTypeIndexes() {
|
function _getBomColumnTypeIndexes() {
|
||||||
if (!bomImportRaw || !Array.isArray(bomImportRaw.columnTypes)) return null;
|
if (!bomImportRaw || !Array.isArray(bomImportRaw.columnTypes)) return null;
|
||||||
const idx = { ignore: [], pn: [], qty: [], price: [], description: [] };
|
const idx = { ignore: [], pn: [], qty: [], price: [], description: [] };
|
||||||
|
|||||||
Reference in New Issue
Block a user