Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
d204e337b5 | ||
|
|
d340bf80af |
@@ -48,7 +48,7 @@ Runtime read/write:
|
|||||||
- `qt_pricelist_sync_status` — pricelist sync timestamps per user
|
- `qt_pricelist_sync_status` — pricelist sync timestamps per user
|
||||||
|
|
||||||
Insert-only tracking:
|
Insert-only tracking:
|
||||||
- `qt_vendor_partnumber_seen` — vendor partnumbers encountered during sync
|
- `qt_vendor_partnumber_seen` — vendor partnumbers encountered during sync; `lot_suggestion` column updated when user manually maps PN → LOT in vendor-spec UI
|
||||||
|
|
||||||
Server-side only (not queried by client runtime):
|
Server-side only (not queried by client runtime):
|
||||||
- `qt_component_usage_stats` — aggregated component popularity stats (written by server jobs)
|
- `qt_component_usage_stats` — aggregated component popularity stats (written by server jobs)
|
||||||
@@ -312,6 +312,7 @@ PK: job_name
|
|||||||
| ignored_by | varchar(100) | |
|
| ignored_by | varchar(100) | |
|
||||||
| created_at | datetime(3) | |
|
| created_at | datetime(3) | |
|
||||||
| updated_at | datetime(3) | |
|
| updated_at | datetime(3) | |
|
||||||
|
| lot_suggestion | longtext (JSON) | nullable; set when user manually maps PN → LOT in vendor-spec UI; same format as `qt_partnumber_book_items.lots_json`; see [11-lot-suggestions.md](11-lot-suggestions.md) |
|
||||||
|
|
||||||
### stock_ignore_rules
|
### stock_ignore_rules
|
||||||
| Column | Type | Notes |
|
| Column | Type | Notes |
|
||||||
|
|||||||
161
bible-local/11-lot-suggestions.md
Normal file
161
bible-local/11-lot-suggestions.md
Normal file
@@ -0,0 +1,161 @@
|
|||||||
|
# 11 - Lot Suggestions (qt_vendor_partnumber_seen)
|
||||||
|
|
||||||
|
## Purpose
|
||||||
|
|
||||||
|
`qt_vendor_partnumber_seen` records vendor partnumbers encountered during import
|
||||||
|
that have no mapping in the active partnumber book. When a user manually maps
|
||||||
|
such a partnumber to one or more LOT names in the QuoteForge UI, those mappings
|
||||||
|
are written back to the server as **lot suggestions** — hints for the team that
|
||||||
|
maintains `qt_partnumber_book_items`.
|
||||||
|
|
||||||
|
## Schema Extension
|
||||||
|
|
||||||
|
Add one nullable column to `qt_vendor_partnumber_seen`:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE `qt_vendor_partnumber_seen`
|
||||||
|
ADD COLUMN `lot_suggestion` longtext DEFAULT NULL
|
||||||
|
COMMENT 'JSON array [{lot_name, qty}] — user-entered LOT mappings from the UI';
|
||||||
|
```
|
||||||
|
|
||||||
|
### Updated table contract (relevant columns only)
|
||||||
|
|
||||||
|
| Column | Type | Notes |
|
||||||
|
|--------|------|-------|
|
||||||
|
| `partnumber` | varchar(255) UNIQUE NOT NULL | natural key |
|
||||||
|
| `lot_suggestion` | longtext (JSON) | nullable; set when user maps the PN manually |
|
||||||
|
|
||||||
|
`lot_suggestion` contains the same JSON shape as `qt_partnumber_book_items.lots_json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "lot_name": "LOT_A", "qty": 1 },
|
||||||
|
{ "lot_name": "LOT_B", "qty": 2 }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- `null` or absent means no suggestion has been entered yet;
|
||||||
|
- an empty array `[]` is not a valid value — use `null` instead;
|
||||||
|
- a single PN may map to multiple lots (`lot_name` entries), each with its own `qty`;
|
||||||
|
- the array is ordered — the order reflects the order of `lot_mappings[]` in the
|
||||||
|
vendor spec row at the time of last user save;
|
||||||
|
- `qty` must be a positive integer (≥ 1).
|
||||||
|
|
||||||
|
## Write Contract (QuoteForge → MariaDB)
|
||||||
|
|
||||||
|
QuoteForge writes `lot_suggestion` when all of the following are true:
|
||||||
|
|
||||||
|
1. The user saves a vendor BOM via `PUT /api/configs/:uuid/vendor-spec`.
|
||||||
|
2. At least one `vendor_spec` row has a non-empty `lot_mappings[]` array (manually
|
||||||
|
entered or confirmed by the user — not auto-resolved from a partnumber book).
|
||||||
|
3. The MariaDB connection is available at the time of save.
|
||||||
|
|
||||||
|
For each such row:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
INSERT INTO qt_vendor_partnumber_seen
|
||||||
|
(source_type, vendor, partnumber, description, is_ignored, last_seen_at, lot_suggestion)
|
||||||
|
VALUES
|
||||||
|
('manual', '', ?, ?, 0, NOW(3), ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
lot_suggestion = VALUES(lot_suggestion),
|
||||||
|
last_seen_at = IF(lot_suggestion IS NULL, last_seen_at, NOW(3))
|
||||||
|
```
|
||||||
|
|
||||||
|
- `lot_suggestion` value = JSON-marshalled `lot_mappings[]` from the vendor spec item,
|
||||||
|
reusing the same `{lot_name, qty}` shape.
|
||||||
|
- If the PN row already exists and `lot_suggestion` is already set, it is **overwritten**
|
||||||
|
with the latest user input (the user is assumed to have corrected it).
|
||||||
|
- If the user **clears** all lot_mappings for a PN (sets to empty), no update is sent —
|
||||||
|
the existing `lot_suggestion` on the server is left untouched.
|
||||||
|
- Rows where `lot_mappings[]` is empty or nil are skipped entirely (no insert, no update).
|
||||||
|
- Writes are best-effort: a MariaDB error for one row is logged and skipped; remaining
|
||||||
|
rows continue. A write failure does not fail the vendor-spec save.
|
||||||
|
|
||||||
|
## Read Contract (Partnumber-Book Creation Tool → MariaDB)
|
||||||
|
|
||||||
|
The tool that maintains `qt_partnumber_book_items` reads `qt_vendor_partnumber_seen`
|
||||||
|
to discover new partnumbers and their suggested mappings.
|
||||||
|
|
||||||
|
### Discovery query
|
||||||
|
|
||||||
|
```sql
|
||||||
|
SELECT
|
||||||
|
s.id,
|
||||||
|
s.partnumber,
|
||||||
|
s.description,
|
||||||
|
s.vendor,
|
||||||
|
s.lot_suggestion,
|
||||||
|
s.last_seen_at,
|
||||||
|
b.lots_json AS book_lots_json
|
||||||
|
FROM qt_vendor_partnumber_seen s
|
||||||
|
LEFT JOIN qt_partnumber_book_items b ON b.partnumber = s.partnumber
|
||||||
|
WHERE s.is_ignored = 0
|
||||||
|
AND s.lot_suggestion IS NOT NULL
|
||||||
|
ORDER BY s.last_seen_at DESC;
|
||||||
|
```
|
||||||
|
|
||||||
|
### Interpretation rules
|
||||||
|
|
||||||
|
| Condition | Meaning | Suggested action |
|
||||||
|
|-----------|---------|-----------------|
|
||||||
|
| `book_lots_json IS NULL` AND `lot_suggestion IS NOT NULL` | No book entry yet; user suggested mapping | Create new `qt_partnumber_book_items` row with `lots_json = lot_suggestion` |
|
||||||
|
| `book_lots_json IS NOT NULL` AND `lot_suggestion IS NOT NULL` AND they differ | User corrected or extended the existing mapping | Review diff and decide whether to update `qt_partnumber_book_items` |
|
||||||
|
| `book_lots_json IS NOT NULL` AND `lot_suggestion IS NOT NULL` AND they match | Suggestion already applied | No action needed |
|
||||||
|
|
||||||
|
### Suggestion format
|
||||||
|
|
||||||
|
`lot_suggestion` is valid JSON (or `null`). Parse it as an array of objects:
|
||||||
|
|
||||||
|
```json
|
||||||
|
[
|
||||||
|
{ "lot_name": "LOT_A", "qty": 1 },
|
||||||
|
{ "lot_name": "LOT_B", "qty": 2 }
|
||||||
|
]
|
||||||
|
```
|
||||||
|
|
||||||
|
Map directly to `qt_partnumber_book_items.lots_json` — the formats are identical.
|
||||||
|
|
||||||
|
### Multiple lots per PN
|
||||||
|
|
||||||
|
One PN may have multiple suggestion entries (e.g., a bundle). The array carries
|
||||||
|
all of them. The book-creation tool must preserve the full array when writing
|
||||||
|
`lots_json`, not just the first element.
|
||||||
|
|
||||||
|
### Qty semantics
|
||||||
|
|
||||||
|
`qty` in a lot suggestion means "how many of this LOT per one occurrence of the
|
||||||
|
vendor PN". This matches `qt_partnumber_book_items.lots_json` exactly. Example:
|
||||||
|
a server platform that comes with 4 PSUs would produce
|
||||||
|
`[{"lot_name": "PS_1300W_Titanium", "qty": 4}]`.
|
||||||
|
|
||||||
|
## Permissions
|
||||||
|
|
||||||
|
The existing `qfs_user` grant covers this column — no new permission is required:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen TO 'qfs_user'@'%';
|
||||||
|
```
|
||||||
|
|
||||||
|
The book-creation tool connects with its own credentials and needs at minimum:
|
||||||
|
|
||||||
|
```sql
|
||||||
|
GRANT SELECT ON RFQ_LOG.qt_vendor_partnumber_seen TO '<book_tool_user>'@'%';
|
||||||
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_partnumber_book_items TO '<book_tool_user>'@'%';
|
||||||
|
```
|
||||||
|
|
||||||
|
## Migration
|
||||||
|
|
||||||
|
Migration is applied outside this repo (server-side DDL):
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE `qt_vendor_partnumber_seen`
|
||||||
|
ADD COLUMN IF NOT EXISTS `lot_suggestion` longtext DEFAULT NULL
|
||||||
|
COMMENT 'JSON [{lot_name, qty}] — user LOT suggestions from QuoteForge UI';
|
||||||
|
```
|
||||||
|
|
||||||
|
QuoteForge handles a missing column gracefully: if the migration has not run yet,
|
||||||
|
the write with `lot_suggestion` fails with "Unknown column" (MariaDB 1054), a warning
|
||||||
|
is logged, and the row is re-inserted without the column. The app never crashes on
|
||||||
|
migration lag.
|
||||||
@@ -15,6 +15,7 @@ Project-specific architecture and operational contracts.
|
|||||||
| [07-dev.md](07-dev.md) | Development commands and guardrails |
|
| [07-dev.md](07-dev.md) | Development commands and guardrails |
|
||||||
| [09-vendor-spec.md](09-vendor-spec.md) | Vendor BOM and CFXML import contract |
|
| [09-vendor-spec.md](09-vendor-spec.md) | Vendor BOM and CFXML import contract |
|
||||||
| [10-agent-api-guide.md](10-agent-api-guide.md) | End-to-end API guide for agents pricing servers from a TZ |
|
| [10-agent-api-guide.md](10-agent-api-guide.md) | End-to-end API guide for agents pricing servers from a TZ |
|
||||||
|
| [11-lot-suggestions.md](11-lot-suggestions.md) | lot_suggestion column in qt_vendor_partnumber_seen — write/read contract for manual UI mappings |
|
||||||
|
|
||||||
## Rules
|
## Rules
|
||||||
|
|
||||||
|
|||||||
@@ -779,7 +779,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
quoteHandler := handlers.NewQuoteHandler(quoteService)
|
quoteHandler := handlers.NewQuoteHandler(quoteService)
|
||||||
exportHandler := handlers.NewExportHandler(exportService, configService, projectService, dbUsername)
|
exportHandler := handlers.NewExportHandler(exportService, configService, projectService, dbUsername)
|
||||||
pricelistHandler := handlers.NewPricelistHandler(local)
|
pricelistHandler := handlers.NewPricelistHandler(local)
|
||||||
vendorSpecHandler := handlers.NewVendorSpecHandler(local)
|
vendorSpecHandler := handlers.NewVendorSpecHandler(local, syncService)
|
||||||
partnumberBooksHandler := handlers.NewPartnumberBooksHandler(local)
|
partnumberBooksHandler := handlers.NewPartnumberBooksHandler(local)
|
||||||
respondError := handlers.RespondError
|
respondError := handlers.RespondError
|
||||||
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
|
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||||
|
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -16,12 +17,14 @@ import (
|
|||||||
type VendorSpecHandler struct {
|
type VendorSpecHandler struct {
|
||||||
localDB *localdb.LocalDB
|
localDB *localdb.LocalDB
|
||||||
configService *services.LocalConfigurationService
|
configService *services.LocalConfigurationService
|
||||||
|
syncService *syncsvc.Service // optional; nil = no server push
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewVendorSpecHandler(localDB *localdb.LocalDB) *VendorSpecHandler {
|
func NewVendorSpecHandler(localDB *localdb.LocalDB, syncService *syncsvc.Service) *VendorSpecHandler {
|
||||||
return &VendorSpecHandler{
|
return &VendorSpecHandler{
|
||||||
localDB: localDB,
|
localDB: localDB,
|
||||||
configService: services.NewLocalConfigurationService(localDB, nil, nil, func() bool { return false }),
|
configService: services.NewLocalConfigurationService(localDB, nil, nil, func() bool { return false }),
|
||||||
|
syncService: syncService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -111,9 +114,57 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Push manual lot mappings as suggestions to the server (best-effort, non-blocking).
|
||||||
|
h.pushLotSuggestions(body.VendorSpec)
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{"vendor_spec": spec})
|
c.JSON(http.StatusOK, gin.H{"vendor_spec": spec})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// pushLotSuggestions sends manual PN→LOT mappings to qt_vendor_partnumber_seen.lot_suggestion.
|
||||||
|
// Errors are logged and silently dropped — they must not affect the HTTP response.
|
||||||
|
func (h *VendorSpecHandler) pushLotSuggestions(spec []localdb.VendorSpecItem) {
|
||||||
|
if h.syncService == nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []syncsvc.SeenPartnumber
|
||||||
|
for _, row := range spec {
|
||||||
|
if row.VendorPartnumber == "" || len(row.LotMappings) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
suggestion := make([]syncsvc.LotSuggestionEntry, 0, len(row.LotMappings))
|
||||||
|
for _, m := range row.LotMappings {
|
||||||
|
if m.LotName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
qty := m.QuantityPerPN
|
||||||
|
if qty < 1 {
|
||||||
|
qty = 1
|
||||||
|
}
|
||||||
|
suggestion = append(suggestion, syncsvc.LotSuggestionEntry{
|
||||||
|
LotName: m.LotName,
|
||||||
|
Qty: qty,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(suggestion) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
items = append(items, syncsvc.SeenPartnumber{
|
||||||
|
Partnumber: row.VendorPartnumber,
|
||||||
|
Description: row.Description,
|
||||||
|
LotSuggestion: suggestion,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(items) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := h.syncService.PushPartnumberSeen(items); err != nil {
|
||||||
|
slog.Warn("vendor_spec: failed to push lot suggestions to server", "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func normalizeLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
|
func normalizeLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
|
||||||
if len(in) == 0 {
|
if len(in) == 0 {
|
||||||
return nil
|
return nil
|
||||||
|
|||||||
@@ -1,20 +1,31 @@
|
|||||||
package sync
|
package sync
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
)
|
)
|
||||||
|
|
||||||
// SeenPartnumber represents an unresolved vendor partnumber to report.
|
// SeenPartnumber represents an unresolved vendor partnumber to report.
|
||||||
type SeenPartnumber struct {
|
type SeenPartnumber struct {
|
||||||
Partnumber string
|
Partnumber string
|
||||||
Description string
|
Description string
|
||||||
Ignored bool
|
Ignored bool
|
||||||
|
LotSuggestion []LotSuggestionEntry // optional; set when user manually mapped PN → LOT in UI
|
||||||
|
}
|
||||||
|
|
||||||
|
// LotSuggestionEntry is one suggested LOT mapping for a vendor partnumber.
|
||||||
|
// JSON shape mirrors qt_partnumber_book_items.lots_json: {"lot_name", "qty"}.
|
||||||
|
type LotSuggestionEntry struct {
|
||||||
|
LotName string `json:"lot_name"`
|
||||||
|
Qty int `json:"qty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// PushPartnumberSeen inserts unresolved vendor partnumbers into qt_vendor_partnumber_seen on MariaDB.
|
// PushPartnumberSeen inserts unresolved vendor partnumbers into qt_vendor_partnumber_seen on MariaDB.
|
||||||
// Existing rows are left untouched: no updates to last_seen_at, is_ignored, or description.
|
// When LotSuggestion is provided the column is updated too; if the column does not exist yet
|
||||||
|
// (migration pending) the write is retried without it and a warning is logged — the app never panics.
|
||||||
func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
|
func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -30,7 +41,43 @@ func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
|
|||||||
if item.Partnumber == "" {
|
if item.Partnumber == "" {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
err := mariaDB.Exec(`
|
|
||||||
|
if len(item.LotSuggestion) > 0 {
|
||||||
|
suggJSON, marshalErr := json.Marshal(item.LotSuggestion)
|
||||||
|
if marshalErr != nil {
|
||||||
|
slog.Error("partnumber_seen: failed to marshal lot_suggestion, skipping suggestion",
|
||||||
|
"partnumber", item.Partnumber, "error", marshalErr)
|
||||||
|
suggJSON = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
if suggJSON != nil {
|
||||||
|
err = mariaDB.Exec(`
|
||||||
|
INSERT INTO qt_vendor_partnumber_seen
|
||||||
|
(source_type, vendor, partnumber, description, is_ignored, last_seen_at, lot_suggestion)
|
||||||
|
VALUES
|
||||||
|
('manual', '', ?, ?, ?, ?, ?)
|
||||||
|
ON DUPLICATE KEY UPDATE
|
||||||
|
lot_suggestion = VALUES(lot_suggestion),
|
||||||
|
last_seen_at = NOW(3)
|
||||||
|
`, item.Partnumber, item.Description, item.Ignored, now, string(suggJSON)).Error
|
||||||
|
|
||||||
|
if err == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
|
// Column not yet migrated — fall through to insert without lot_suggestion.
|
||||||
|
if !isUnknownColumnError(err) {
|
||||||
|
slog.Error("partnumber_seen: failed to upsert with lot_suggestion",
|
||||||
|
"partnumber", item.Partnumber, "error", err)
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
slog.Warn("partnumber_seen: lot_suggestion column missing (migration pending), inserting without it",
|
||||||
|
"partnumber", item.Partnumber)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Insert without lot_suggestion (baseline behaviour or fallback).
|
||||||
|
err = mariaDB.Exec(`
|
||||||
INSERT INTO qt_vendor_partnumber_seen
|
INSERT INTO qt_vendor_partnumber_seen
|
||||||
(source_type, vendor, partnumber, description, is_ignored, last_seen_at)
|
(source_type, vendor, partnumber, description, is_ignored, last_seen_at)
|
||||||
VALUES
|
VALUES
|
||||||
@@ -40,10 +87,18 @@ func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
|
|||||||
`, item.Partnumber, item.Description, item.Ignored, now).Error
|
`, item.Partnumber, item.Description, item.Ignored, now).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to insert partnumber_seen", "partnumber", item.Partnumber, "error", err)
|
slog.Error("failed to insert partnumber_seen", "partnumber", item.Partnumber, "error", err)
|
||||||
// Continue with remaining items
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("partnumber_seen pushed to server", "count", len(items))
|
slog.Info("partnumber_seen pushed to server", "count", len(items))
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// isUnknownColumnError returns true when MariaDB reports that a column does not exist.
|
||||||
|
func isUnknownColumnError(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msg := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(msg, "unknown column") || strings.Contains(msg, "1054")
|
||||||
|
}
|
||||||
|
|||||||
25
releases/v1.14/RELEASE_NOTES.md
Normal file
25
releases/v1.14/RELEASE_NOTES.md
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
# QuoteForge v1.14
|
||||||
|
|
||||||
|
Дата релиза: 2026-06-16
|
||||||
|
Тег: `v1.14`
|
||||||
|
|
||||||
|
Предыдущий релиз: `v1.13`
|
||||||
|
|
||||||
|
## Ключевые изменения
|
||||||
|
|
||||||
|
- добавлен импорт человекочитаемого текстового BOM формата `<описание> - <кол-во> шт.`
|
||||||
|
(с необязательным заголовком, оканчивающимся на `, в составе:`) — как при загрузке файла
|
||||||
|
через `POST /api/projects/:uuid/vendor-import`, так и при вставке в конфигураторе;
|
||||||
|
- заголовок конфигурации определяется по маркеру `, в составе:` с любым префиксом
|
||||||
|
(`Сервер X3` и `Вычислительный GPU сервер X3` → модель `X3`);
|
||||||
|
- парсинг устойчив к пробелам в начале/конце строки (в P/N не попадает лишний пробел),
|
||||||
|
а также к запятым и дефисам внутри описания (`RAID0,1,10`, `8-GPU-2304GB`);
|
||||||
|
- вставка BOM в конфигураторе и импорт файла используют единый серверный парсер
|
||||||
|
(`POST /api/vendor-spec/parse-text`) — дублирующая логика разбора на фронтенде удалена;
|
||||||
|
- сабмодуль `bible` обновлён до актуальных контрактов (build-version-display,
|
||||||
|
local-first-recovery, резервные копии миграций).
|
||||||
|
|
||||||
|
## Запуск на macOS
|
||||||
|
|
||||||
|
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/qfs-darwin-arm64`
|
||||||
|
После этого бинарник запустится без предупреждения Gatekeeper.
|
||||||
Reference in New Issue
Block a user