diff --git a/.gitignore b/.gitignore index d517283..0600cc1 100644 --- a/.gitignore +++ b/.gitignore @@ -58,6 +58,7 @@ releases/ # Local runtime secrets config.yaml +data/settings.db # Local scratch pricelists_window.md diff --git a/Makefile b/Makefile index de8c7af..e7199dc 100644 --- a/Makefile +++ b/Makefile @@ -1,4 +1,4 @@ -.PHONY: build build-release clean test run version +.PHONY: build build-release clean test run version backup-db # Get version from git VERSION := $(shell git describe --tags --always --dirty 2>/dev/null || echo "dev") @@ -63,6 +63,10 @@ test: run: go run ./cmd/pfs +# Create MariaDB backup using the current config.yaml +backup-db: + go run ./cmd/dbbackup + # Run with auto-restart (requires entr: brew install entr) watch: find . -name '*.go' | entr -r go run ./cmd/pfs @@ -90,6 +94,7 @@ help: @echo " clean Remove build artifacts" @echo " test Run tests" @echo " run Run development server" + @echo " backup-db Create MariaDB backup from current config" @echo " watch Run with auto-restart (requires entr)" @echo " deps Install/update dependencies" @echo " help Show this help" diff --git a/bible b/bible index 472c8a6..d2e11b8 160000 --- a/bible +++ b/bible @@ -1 +1 @@ -Subproject commit 472c8a6918913a2500e238d54f9c819597de8ab9 +Subproject commit d2e11b8bddef1623e2b3b4df37c67a6b544fc1ee diff --git a/bible-local/history.md b/bible-local/history.md index d517ca8..87d6d21 100644 --- a/bible-local/history.md +++ b/bible-local/history.md @@ -108,6 +108,8 @@ Another application writes non-partnumber LOT identifiers into `qt_vendor_partnu ## 2026-02-21: Partnumber Book Snapshots for QuoteForge +Superseded in storage shape by the 2026-03-07 decision below. This section is retained as historical context. + ### Decision Implemented versioned snapshots of the `lot_partnumbers` → LOT mapping in `qt_partnumber_books` / `qt_partnumber_book_items`. PriceForge writes; QuoteForge reads (SELECT only). @@ -154,6 +156,59 @@ QuoteForge needs a stable, versioned copy of the PN→LOT mapping to resolve BOM - Routes: `cmd/pfs/main.go` - UI: `web/templates/vendor_mappings.html` +## 2026-03-07: Partnumber Book Catalog Deduplication + +### Decision + +Reworked partnumber book storage so `qt_partnumber_book_items` is a deduplicated source-of-truth catalog by `partnumber`, and each snapshot book stores its included PN list in `qt_partnumber_books.partnumbers_json`. + +### What changed + +- Migration `029` replaces expanded snapshot rows with `lots_json` in `qt_partnumber_book_items`. +- `qt_partnumber_book_items` now stores one row per `partnumber` with fields: + - `partnumber` + - `lots_json` (`[{lot_name, qty}, ...]`) + - `is_primary_pn` + - `description` +- `qt_partnumber_books` now stores `partnumbers_json`, the sorted list of PN values included in that book. +- QuoteForge read contract is now: + - read active book from `qt_partnumber_books` + - parse `partnumbers_json` + - load PN payloads via `SELECT partnumber, lots_json, is_primary_pn, description FROM qt_partnumber_book_items WHERE partnumber IN (...)` +- `PartnumberBookService.CreateSnapshot` now: + - builds one logical item per PN, + - serializes bundle composition into `lots_json`, + - upserts the global catalog, + - writes PN membership into the book header. +- `ListBooks` now derives item counts from `partnumbers_json`. +- Added regression tests covering: + - direct PN -> one LOT with qty `1` + - bundle PN -> multiple LOT with explicit quantities + - deduplication of catalog rows across multiple books + +### Rationale + +- A real vendor PN may consist of several different LOT with different quantities. +- Expanded rows in `qt_partnumber_book_items` lost quantity semantics and duplicated the same logical item across books. +- The new shape keeps PN composition intact and makes the items table the canonical catalog. + +### Constraints + +- `qt_partnumber_book_items` must not contain duplicate `partnumber` rows. +- `lots_json` is the only source of truth for PN composition. +- `qt_partnumber_books.partnumbers_json` stores membership only; the resolved PN composition comes from `qt_partnumber_book_items`. +- `qt_partnumber_book_item_links` is not part of the architecture and must not exist. +- If one snapshot build encounters multiple distinct compositions for the same PN, the build must fail instead of choosing one silently. +- Historical books remain snapshots of included PN membership; item payloads are read from the current catalog. + +### Files + +- Migration: `migrations/029_change_partnumber_book_items_to_lots_json.sql` +- Models: `internal/models/lot.go` +- Service: `internal/services/partnumber_book.go` +- Tests: `internal/services/partnumber_book_test.go` +- Docs: `bible-local/vendor-mapping.md` + --- ## 2026-02-20: Pricelist Formation Hardening (Estimate/Warehouse/Meta) diff --git a/bible-local/operations.md b/bible-local/operations.md index 395ce55..e6d9c27 100644 --- a/bible-local/operations.md +++ b/bible-local/operations.md @@ -69,6 +69,7 @@ make build-all # cross-compile Linux/macOS/Windows # Migrations go run ./cmd/pfs -migrate +make backup-db # Version ./bin/pfs -version @@ -87,6 +88,7 @@ make clean | Target | Action | |--------|--------| | `run` | `go run ./cmd/pfs` | +| `backup-db` | Run `go run ./cmd/dbbackup` using current `config.yaml` | | `build` | Dev build with debug info | | `build-release` | `-s -w` stripped + version ldflags | | `build-linux` | GOOS=linux GOARCH=amd64 | @@ -121,6 +123,32 @@ SQL migrations in `migrations/` (25 files). Applied automatically on startup. Manual run: `go run ./cmd/pfs -migrate` +Before any migration or DB repair: + +```bash +make backup-db +``` + +Backup helper: + +```bash +go run ./cmd/dbbackup +# optional: +go run ./cmd/dbbackup --config /path/to/config.yaml --out-dir /path/to/backups +``` + +Default output path: + +```text +/backups/ +``` + +Example on macOS: + +```text +~/Library/Application Support/PriceForge/backups/ +``` + Migration runner: `internal/models/sql_migrations.go` --- diff --git a/bible-local/vendor-mapping.md b/bible-local/vendor-mapping.md index 6380f0f..3511fb1 100644 --- a/bible-local/vendor-mapping.md +++ b/bible-local/vendor-mapping.md @@ -71,8 +71,8 @@ qt_lot_bundle_items: bundle_lot_name, lot_name, qty | `qt_lot_bundles` | Определения бандлов (bundle LOT → описание) | | `qt_lot_bundle_items` | Состав бандла: `(bundle_lot_name, lot_name, qty)` | | `qt_vendor_partnumber_seen` | Реестр seen-записей (уникально по `partnumber`) + флаг `is_ignored` | -| `qt_partnumber_books` | Версионированные снимки маппинга для QuoteForge (пишет PriceForge) | -| `qt_partnumber_book_items` | Строки снимка: `(book_id, partnumber, lot_name, is_primary_pn, description)`; бандлы развёрнуты; пустые lot_name и ignored PN исключены | +| `qt_partnumber_books` | Версионированные книги PN; каждая книга хранит `partnumbers_json` — список PN, входящих в книгу | +| `qt_partnumber_book_items` | Глобальный source-of-truth каталог `partnumber -> lots_json`; без дубликатов по `partnumber`; `lots_json` хранит список `{lot_name, qty}` | Миграции: - `migrations/023_vendor_partnumber_global_mapping.sql` @@ -80,18 +80,47 @@ qt_lot_bundle_items: bundle_lot_name, lot_name, qty - `migrations/026_add_partnumber_books.sql` - `migrations/027_fix_partnumber_books_version_length.sql` - `migrations/028_add_description_to_partnumber_book_items.sql` +- `migrations/029_change_partnumber_book_items_to_lots_json.sql` --- ## Partnumber Book — инварианты снимка -При формировании `qt_partnumber_book_items` обязательно: +При формировании partnumber book обязательно: -1. **Пустой `lot_name` исключается** — `TRIM(lot_name) = ''` или NULL не попадают в снимок. -2. **Ignored PN исключаются** — партномера с `qt_vendor_partnumber_seen.is_ignored = true` не попадают в снимок. -3. **Бандлы разворачиваются** — каждая строка содержит конечный `lot_name` компонента, не имя бандла. -4. **`description`** берётся из `lot_partnumbers.description`; для развёрнутых бандлов — из родительской записи partnumber. -5. **Удаление items при retention** — явное (`DELETE WHERE book_id IN (...)`), не через FK CASCADE. +1. **`qt_partnumber_book_items` содержит одну строку на `partnumber`** — дубликаты по PN не допускаются. +2. **`lots_json`** содержит полный состав PN как JSON-массив объектов `{lot_name, qty}`. +3. **Ignored PN исключаются** — партномера с `qt_vendor_partnumber_seen.is_ignored = true` не попадают ни в каталог, ни в `partnumbers_json` книги. +4. **Бандлы не разворачиваются в отдельные строки** — bundle PN сохраняется одной записью, а состав компонентов уходит в `lots_json`. +5. **`description`** берётся из `lot_partnumbers.description`. +6. **`qt_partnumber_books.partnumbers_json`** хранит отсортированный список PN, входящих в конкретную книгу. +7. **Конфликт разных составов для одного PN недопустим** — если при построении книги один и тот же PN даёт разные `lots_json`, создание snapshot должно завершиться ошибкой. + +## QuoteForge Read Contract + +QuoteForge must read the active book header first: + +```sql +SELECT id, version, created_at, created_by, partnumbers_json +FROM qt_partnumber_books +WHERE is_active = 1 +ORDER BY created_at DESC, id DESC +LIMIT 1; +``` + +Then load item payloads from the catalog by PN list: + +```sql +SELECT partnumber, lots_json, is_primary_pn, description +FROM qt_partnumber_book_items +WHERE partnumber IN (...PNs from partnumbers_json...); +``` + +Contract notes: +- `partnumbers_json` is the membership snapshot of the selected book. +- `qt_partnumber_book_items` is the current canonical catalog of PN compositions. +- `lots_json` format is JSON array: `[{"lot_name":"CPU_X","qty":2},{"lot_name":"RAM_X","qty":4}]` +- QuoteForge must not expect `lot_name` column in `qt_partnumber_book_items` anymore. --- @@ -101,7 +130,7 @@ qt_lot_bundle_items: bundle_lot_name, lot_name, qty |------|------| | Миграция | `migrations/023_vendor_partnumber_global_mapping.sql` | | Миграция | `migrations/025_dedup_vendor_seen_by_partnumber.sql` | -| Миграции снимков | `migrations/026–028` | +| Миграции снимков | `migrations/026–029` | | Резолвер | `internal/lotmatch/matcher.go` | | Сервис маппингов | `internal/services/vendor_mapping.go` | | Сервис снимков | `internal/services/partnumber_book.go` | diff --git a/internal/models/lot.go b/internal/models/lot.go index 733c5c4..3e682c5 100644 --- a/internal/models/lot.go +++ b/internal/models/lot.go @@ -57,11 +57,11 @@ func (StockLog) TableName() string { // LotPartnumber maps external part numbers to internal lots. type LotPartnumber struct { - Vendor string `gorm:"column:vendor;size:255;primaryKey" json:"vendor"` - Partnumber string `gorm:"column:partnumber;size:255;primaryKey" json:"partnumber"` - LotName string `gorm:"column:lot_name;size:255" json:"lot_name"` - Description *string `gorm:"column:description;size:10000" json:"description,omitempty"` - IsPrimaryPN bool `gorm:"column:is_primary_pn;not null;default:true" json:"is_primary_pn"` + Vendor string `gorm:"column:vendor;size:255;primaryKey" json:"vendor"` + Partnumber string `gorm:"column:partnumber;size:255;primaryKey" json:"partnumber"` + LotName string `gorm:"column:lot_name;size:255" json:"lot_name"` + Description *string `gorm:"column:description;size:10000" json:"description,omitempty"` + IsPrimaryPN bool `gorm:"column:is_primary_pn;not null;default:true" json:"is_primary_pn"` } func (LotPartnumber) TableName() string { @@ -71,24 +71,29 @@ func (LotPartnumber) TableName() string { // PartnumberBook is a versioned snapshot of the partnumber→LOT mapping. // Written by PriceForge; QuoteForge reads via SELECT only. type PartnumberBook struct { - ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` - Version string `gorm:"column:version;size:30;not null;uniqueIndex:uq_qt_partnumber_books_version" json:"version"` - CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` - CreatedBy string `gorm:"column:created_by;size:100;not null;default:''" json:"created_by"` - IsActive bool `gorm:"column:is_active;not null;default:false" json:"is_active"` + ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` + Version string `gorm:"column:version;size:30;not null;uniqueIndex:uq_qt_partnumber_books_version" json:"version"` + CreatedAt time.Time `gorm:"column:created_at;autoCreateTime" json:"created_at"` + CreatedBy string `gorm:"column:created_by;size:100;not null;default:''" json:"created_by"` + IsActive bool `gorm:"column:is_active;not null;default:false" json:"is_active"` + PartnumbersJSON string `gorm:"column:partnumbers_json;type:longtext;not null" json:"partnumbers_json"` } func (PartnumberBook) TableName() string { return "qt_partnumber_books" } -// PartnumberBookItem is one mapping row in a PartnumberBook snapshot. -// Bundles are expanded: a single partnumber may have multiple rows (one per LOT component). +type PartnumberBookLot struct { + LotName string `json:"lot_name"` + Qty float64 `json:"qty"` +} + +// PartnumberBookItem is the current source-of-truth row for one partnumber. +// lots_json stores the resolved LOT composition with quantities. type PartnumberBookItem struct { ID uint64 `gorm:"column:id;primaryKey;autoIncrement" json:"id"` - BookID uint64 `gorm:"column:book_id;not null" json:"book_id"` - Partnumber string `gorm:"column:partnumber;size:255;not null" json:"partnumber"` - LotName string `gorm:"column:lot_name;size:255;not null" json:"lot_name"` + Partnumber string `gorm:"column:partnumber;size:255;not null;uniqueIndex:uq_qt_partnumber_book_items_partnumber" json:"partnumber"` + LotsJSON string `gorm:"column:lots_json;type:longtext;not null" json:"lots_json"` IsPrimaryPN bool `gorm:"column:is_primary_pn;not null;default:true" json:"is_primary_pn"` Description *string `gorm:"column:description;size:10000" json:"description,omitempty"` } diff --git a/internal/services/partnumber_book.go b/internal/services/partnumber_book.go index ea8085c..93718c8 100644 --- a/internal/services/partnumber_book.go +++ b/internal/services/partnumber_book.go @@ -1,6 +1,7 @@ package services import ( + "encoding/json" "errors" "fmt" "log/slog" @@ -56,14 +57,10 @@ func (s *PartnumberBookService) ListBooks() ([]PartnumberBookSummary, error) { type row struct { models.PartnumberBook - ItemCount int `gorm:"column:item_count"` } var rows []row if err := s.db.Model(&models.PartnumberBook{}). - Select("qt_partnumber_books.*, COUNT(qt_partnumber_book_items.id) AS item_count"). - Joins("LEFT JOIN qt_partnumber_book_items ON qt_partnumber_book_items.book_id = qt_partnumber_books.id"). - Group("qt_partnumber_books.id"). Order("qt_partnumber_books.created_at DESC"). Find(&rows).Error; err != nil { return nil, fmt.Errorf("listing partnumber books: %w", err) @@ -71,20 +68,27 @@ func (s *PartnumberBookService) ListBooks() ([]PartnumberBookSummary, error) { result := make([]PartnumberBookSummary, len(rows)) for i, r := range rows { + var pns []string + if strings.TrimSpace(r.PartnumbersJSON) != "" { + if err := json.Unmarshal([]byte(r.PartnumbersJSON), &pns); err != nil { + return nil, fmt.Errorf("decode partnumbers_json for book %d: %w", r.ID, err) + } + } result[i] = PartnumberBookSummary{ ID: r.PartnumberBook.ID, Version: r.PartnumberBook.Version, CreatedAt: r.PartnumberBook.CreatedAt, CreatedBy: r.PartnumberBook.CreatedBy, IsActive: r.PartnumberBook.IsActive, - ItemCount: r.ItemCount, + ItemCount: len(pns), } } return result, nil } // CreateSnapshot takes a snapshot of all active partnumber→LOT mappings, -// expands bundles, writes qt_partnumber_book_items, activates the new book, +// serializes resolved LOT composition into qt_partnumber_book_items.lots_json, +// activates the new book, // deactivates all previous books, then applies the retention policy. func (s *PartnumberBookService) CreateSnapshot(createdBy string, onProgress func(PartnumberBookProgress)) (*models.PartnumberBook, error) { if s.db == nil { @@ -124,6 +128,11 @@ func (s *PartnumberBookService) CreateSnapshot(createdBy string, onProgress func return nil, err } + partnumbersJSON, err := collectPartnumbersJSON(items) + if err != nil { + return nil, err + } + // 5. Persist inside a transaction. var book models.PartnumberBook err = s.db.Transaction(func(tx *gorm.DB) error { @@ -136,28 +145,17 @@ func (s *PartnumberBookService) CreateSnapshot(createdBy string, onProgress func // Create header. book = models.PartnumberBook{ - Version: version, - CreatedBy: createdBy, - IsActive: true, + Version: version, + CreatedBy: createdBy, + IsActive: true, + PartnumbersJSON: partnumbersJSON, } if err := tx.Create(&book).Error; err != nil { return fmt.Errorf("creating partnumber book: %w", err) } - // Attach book_id and bulk-insert items. - for i := range items { - items[i].BookID = book.ID - } - - const batchSize = 500 - for i := 0; i < len(items); i += batchSize { - end := i + batchSize - if end > len(items) { - end = len(items) - } - if err := tx.Create(items[i:end]).Error; err != nil { - return fmt.Errorf("inserting partnumber book items (batch %d): %w", i/batchSize, err) - } + if err := s.upsertCatalogItems(tx, items); err != nil { + return err } return nil @@ -213,10 +211,6 @@ func (s *PartnumberBookService) applyRetention() (int, error) { } return len(toDelete), s.db.Transaction(func(tx *gorm.DB) error { - // Delete items explicitly — do not rely on FK CASCADE. - if err := tx.Where("book_id IN ?", toDelete).Delete(&models.PartnumberBookItem{}).Error; err != nil { - return fmt.Errorf("deleting book items: %w", err) - } if err := tx.Where("id IN ?", toDelete).Delete(&models.PartnumberBook{}).Error; err != nil { return fmt.Errorf("deleting old books: %w", err) } @@ -224,6 +218,78 @@ func (s *PartnumberBookService) applyRetention() (int, error) { }) } +func (s *PartnumberBookService) upsertCatalogItems(tx *gorm.DB, items []models.PartnumberBookItem) error { + if len(items) == 0 { + return nil + } + + partnumbers := make([]string, 0, len(items)) + byPN := make(map[string]models.PartnumberBookItem) + for _, item := range items { + existing, ok := byPN[item.Partnumber] + if ok { + if existing.LotsJSON != item.LotsJSON || existing.IsPrimaryPN != item.IsPrimaryPN || normalizeDescription(existing.Description) != normalizeDescription(item.Description) { + return fmt.Errorf("conflicting snapshot items for partnumber %s", item.Partnumber) + } + continue + } + byPN[item.Partnumber] = item + partnumbers = append(partnumbers, item.Partnumber) + } + + var existing []models.PartnumberBookItem + if err := tx.Where("partnumber IN ?", partnumbers).Find(&existing).Error; err != nil { + return fmt.Errorf("loading existing partnumber book items: %w", err) + } + existingByPN := make(map[string]models.PartnumberBookItem, len(existing)) + for _, item := range existing { + existingByPN[item.Partnumber] = item + } + + toCreate := make([]models.PartnumberBookItem, 0, len(byPN)) + for _, pn := range partnumbers { + if _, ok := existingByPN[pn]; ok { + continue + } + toCreate = append(toCreate, byPN[pn]) + } + if len(toCreate) > 0 { + const batchSize = 500 + for i := 0; i < len(toCreate); i += batchSize { + end := i + batchSize + if end > len(toCreate) { + end = len(toCreate) + } + if err := tx.Create(toCreate[i:end]).Error; err != nil { + return fmt.Errorf("inserting partnumber book items (batch %d): %w", i/batchSize, err) + } + } + } + + for _, item := range items { + existingItem, ok := existingByPN[item.Partnumber] + if !ok { + continue + } + if existingItem.LotsJSON == item.LotsJSON && + existingItem.IsPrimaryPN == item.IsPrimaryPN && + normalizeDescription(existingItem.Description) == normalizeDescription(item.Description) { + continue + } + updates := map[string]any{ + "lots_json": item.LotsJSON, + "is_primary_pn": item.IsPrimaryPN, + "description": item.Description, + } + if err := tx.Model(&models.PartnumberBookItem{}). + Where("id = ?", existingItem.ID). + Updates(updates).Error; err != nil { + return fmt.Errorf("updating partnumber book item for %s: %w", item.Partnumber, err) + } + } + return nil +} + // retentionKeepSet returns the set of book IDs to keep according to GFS policy. // Books are expected to be sorted newest-first. func retentionKeepSet(books []models.PartnumberBook) map[uint64]bool { @@ -288,7 +354,8 @@ func (s *PartnumberBookService) loadBundleSet() (map[string]bool, error) { return set, nil } -// expandMappings converts lot_partnumbers rows into book items, expanding bundles. +// expandMappings converts lot_partnumbers rows into snapshot items. +// Each PN produces one row with lots_json = [{lot_name, qty}, ...]. // Rows with empty lot_name and ignored partnumbers are excluded. func (s *PartnumberBookService) expandMappings(mappings []models.LotPartnumber, bundleSet map[string]bool) ([]models.PartnumberBookItem, error) { // Pre-load all bundle items keyed by bundle_lot_name. @@ -314,6 +381,7 @@ func (s *PartnumberBookService) expandMappings(mappings []models.LotPartnumber, } var result []models.PartnumberBookItem + byPN := make(map[string]models.PartnumberBookItem) for _, m := range mappings { // Skip ignored partnumbers. if ignoredSet[m.Partnumber] { @@ -324,35 +392,80 @@ func (s *PartnumberBookService) expandMappings(mappings []models.LotPartnumber, continue } + var lots []models.PartnumberBookLot if bundleSet[m.LotName] { - // Expand bundle: emit one item per component LOT. - // Description from lot_partnumbers is carried to all expanded rows. + // Bundle composition is stored directly in JSON. components := bundleItems[m.LotName] if len(components) == 0 { - // Bundle with no items — skip silently. continue } + sort.Slice(components, func(i, j int) bool { + return components[i].LotName < components[j].LotName + }) for _, c := range components { - result = append(result, models.PartnumberBookItem{ - Partnumber: m.Partnumber, - LotName: c.LotName, - IsPrimaryPN: m.IsPrimaryPN, - Description: m.Description, + if strings.TrimSpace(c.LotName) == "" || c.Qty <= 0 { + continue + } + lots = append(lots, models.PartnumberBookLot{ + LotName: c.LotName, + Qty: c.Qty, }) } } else { - // Direct mapping. - result = append(result, models.PartnumberBookItem{ - Partnumber: m.Partnumber, - LotName: m.LotName, - IsPrimaryPN: m.IsPrimaryPN, - Description: m.Description, + lots = append(lots, models.PartnumberBookLot{ + LotName: m.LotName, + Qty: 1, }) } + if len(lots) == 0 { + continue + } + + lotsJSON, err := json.Marshal(lots) + if err != nil { + return nil, fmt.Errorf("marshal lots_json for partnumber %s: %w", m.Partnumber, err) + } + item := models.PartnumberBookItem{ + Partnumber: m.Partnumber, + LotsJSON: string(lotsJSON), + IsPrimaryPN: m.IsPrimaryPN, + Description: m.Description, + } + if prev, ok := byPN[item.Partnumber]; ok { + if prev.LotsJSON != item.LotsJSON || prev.IsPrimaryPN != item.IsPrimaryPN || normalizeDescription(prev.Description) != normalizeDescription(item.Description) { + return nil, fmt.Errorf("multiple distinct mappings for partnumber %s in partnumber book snapshot", item.Partnumber) + } + continue + } + byPN[item.Partnumber] = item + result = append(result, item) } return result, nil } +func collectPartnumbersJSON(items []models.PartnumberBookItem) (string, error) { + partnumbers := make([]string, 0, len(items)) + for _, item := range items { + if strings.TrimSpace(item.Partnumber) == "" { + continue + } + partnumbers = append(partnumbers, item.Partnumber) + } + sort.Strings(partnumbers) + b, err := json.Marshal(partnumbers) + if err != nil { + return "", fmt.Errorf("marshal partnumbers_json: %w", err) + } + return string(b), nil +} + +func normalizeDescription(v *string) string { + if v == nil { + return "" + } + return strings.TrimSpace(*v) +} + // generateVersion produces a version string in format PNBOOK-YYYY-MM-DD-NNN. func (s *PartnumberBookService) generateVersion() (string, error) { today := time.Now().Format("2006-01-02")