From 5e56f386cce120ed59f074a04c9fe98f50a56ddb Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Sat, 21 Feb 2026 10:22:22 +0300 Subject: [PATCH 01/21] =?UTF-8?q?feat:=20implement=20vendor=20spec=20BOM?= =?UTF-8?q?=20import=20and=20PN=E2=86=92LOT=20resolution=20(Phase=201)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Migration 029: local_partnumber_books, local_partnumber_book_items, vendor_spec TEXT column on local_configurations - Models: LocalPartnumberBook, LocalPartnumberBookItem, VendorSpec, VendorSpecItem with JSON Valuer/Scanner - Repository: PartnumberBookRepository (GetActiveBook, FindLotByPartnumber, SaveBook/Items, ListBooks, CountBookItems) - Service: VendorSpecResolver 3-step resolution (book → manual suggestion → unresolved) + AggregateLOTs with is_primary_pn qty logic - Sync: PullPartnumberBooks append-only pull from qt_partnumber_books - Handlers: VendorSpecHandler (GET/PUT/resolve/apply), PartnumberBooksHandler - Routes: /api/configs/:uuid/vendor-spec*, /api/partnumber-books, /api/sync/partnumber-books, /partnumber-books page - UI: 3 top-level tabs [Estimate][BOM вендора][Ценообразование]; Excel paste, PN resolution, inline LOT autocomplete, pricing table - Bible: 03-database.md updated, 09-vendor-spec.md added Co-Authored-By: Claude Sonnet 4.6 --- bible/03-database.md | 13 +- bible/09-vendor-spec.md | 126 +++++ cmd/qfs/main.go | 17 + internal/handlers/partnumber_books.go | 90 +++ internal/handlers/sync.go | 27 + internal/handlers/vendor_spec.go | 166 ++++++ internal/handlers/web.go | 6 +- internal/localdb/models.go | 68 +++ internal/repository/partnumber_book.go | 66 +++ internal/services/sync/partnumber_books.go | 92 +++ internal/services/vendor_spec_resolver.go | 129 +++++ ...9_add_vendor_spec_and_partnumber_books.sql | 22 + web/templates/index.html | 533 ++++++++++++++++++ web/templates/partnumber_books.html | 139 +++++ 14 files changed, 1492 insertions(+), 2 deletions(-) create mode 100644 bible/09-vendor-spec.md create mode 100644 internal/handlers/partnumber_books.go create mode 100644 internal/handlers/vendor_spec.go create mode 100644 internal/repository/partnumber_book.go create mode 100644 internal/services/sync/partnumber_books.go create mode 100644 internal/services/vendor_spec_resolver.go create mode 100644 migrations/029_add_vendor_spec_and_partnumber_books.sql create mode 100644 web/templates/partnumber_books.html diff --git a/bible/03-database.md b/bible/03-database.md index 31db10d..5d3c93b 100644 --- a/bible/03-database.md +++ b/bible/03-database.md @@ -21,11 +21,22 @@ File: `qfs.db` in the user-state directory (see [05-config.md](05-config.md)). | `local_pricelists` | Pricelist headers | `id`, `server_id` (unique), `source`, `version`, `created_at` | | `local_pricelist_items` | Pricelist line items ← **sole source of prices** | `id`, `pricelist_id` (FK), `lot_name`, `price`, `lot_category` | +#### Partnumber Books (PN → LOT mapping, pull-only from PriceForge) + +| Table | Purpose | Key Fields | +|-------|---------|------------| +| `local_partnumber_books` | Version snapshots of PN→LOT mappings | `id`, `server_id` (unique), `version`, `created_at`, `is_active` | +| `local_partnumber_book_items` | PN→LOT mapping rows | `id`, `book_id` (FK), `partnumber`, `lot_name`, `is_primary_pn` | + +Active book: `WHERE is_active=1 ORDER BY created_at DESC, id DESC LIMIT 1` + +`is_primary_pn=1` means this PN's quantity in the vendor BOM determines qty(LOT). If only non-primary PNs are present for a LOT, qty defaults to 1. + #### Configurations and Projects | Table | Purpose | Key Fields | |-------|---------|------------| -| `local_configurations` | Saved configurations | `id`, `uuid` (unique), `items` (JSON), `line_no`, `pricelist_id`, `warehouse_pricelist_id`, `competitor_pricelist_id`, `current_version_id`, `sync_status` | +| `local_configurations` | Saved configurations | `id`, `uuid` (unique), `items` (JSON), `vendor_spec` (JSON), `line_no`, `pricelist_id`, `warehouse_pricelist_id`, `competitor_pricelist_id`, `current_version_id`, `sync_status` | | `local_configuration_versions` | Immutable snapshots (revisions) | `id`, `configuration_id` (FK), `version_no`, `data` (JSON), `change_note`, `created_at` | | `local_projects` | Projects | `id`, `uuid` (unique), `name`, `code`, `sync_status` | diff --git a/bible/09-vendor-spec.md b/bible/09-vendor-spec.md new file mode 100644 index 0000000..c9435c4 --- /dev/null +++ b/bible/09-vendor-spec.md @@ -0,0 +1,126 @@ +# 09 — Vendor Spec (BOM Import) + +## Overview + +The vendor spec feature allows importing a vendor BOM (Bill of Materials) into a configuration. It maps vendor part numbers (PN) to internal LOT names using an active partnumber book (snapshot pulled from PriceForge), then aggregates quantities to populate the Estimate (cart). + +## Architecture + +### Storage + +| Data | Storage | Sync direction | +|------|---------|---------------| +| `vendor_spec` JSON | `local_configurations.vendor_spec` (TEXT/JSON) | Two-way via `pending_changes` | +| Partnumber book snapshots | `local_partnumber_books` + `local_partnumber_book_items` | Pull-only from PriceForge | + +`vendor_spec` is a JSON array of `VendorSpecItem` objects stored inside the configuration row. It syncs to MariaDB `qt_configurations.vendor_spec` via the existing pending_changes mechanism. + +### `vendor_spec` JSON Schema + +```json +[ + { + "sort_order": 10, + "vendor_partnumber": "ABC-123", + "quantity": 2, + "description": "...", + "unit_price": 4500.00, + "total_price": 9000.00, + "resolved_lot_name": "LOT_A", + "resolution_source": "book", + "manual_lot_suggestion": null + } +] +``` + +`resolution_source` values: `"book"` | `"manual_suggestion"` | `"unresolved"` + +`manual_lot_suggestion` stores the user's inline LOT input — scoped to this configuration only, never written to the global book. + +--- + +## Resolution Algorithm (3-step) + +For each `vendor_partnumber` in the BOM: + +1. **Active book lookup** — query `local_partnumber_book_items WHERE book_id = activeBook.id AND partnumber = ?`. If found → `resolved_lot_name = match.lot_name`, `resolution_source = "book"`. +2. **Manual suggestion** — if `manual_lot_suggestion` is non-empty (user typed it before) → pre-fill as grey suggestion, `resolution_source = "manual_suggestion"`. +3. **Unresolved** — red background, inline autocomplete input shown to user. + +--- + +## Qty Aggregation Logic + +After resolution, qty per LOT is computed as: + +``` +qty(lot) = SUM(quantity of rows where is_primary_pn=1 AND resolved_lot=lot) + if at least one primary PN for this lot was found in BOM + = 1 + if only non-primary PNs for this lot were found +``` + +Examples (book mapping: LOT_A → x1[primary], x2, x3): +- BOM: x2×1, x3×2 → 1×LOT_A (no primary PN found) +- BOM: x1×2, x2×1 → 2×LOT_A (primary qty=2) +- BOM: x1×1, x2×3 → 1×LOT_A (primary qty=1) + +--- + +## UI: Three Top-Level Tabs + +The configurator (`/configurator`) has three tabs: + +1. **Estimate** — existing cart/component configurator (unchanged) +2. **BOM вендора** — paste from Excel, auto-resolution, manual LOT input for unresolved rows, `Пересчитать эстимейт` button +3. **Ценообразование** — pricing summary table: vendor price | Estimate | Warehouse | Competitors; custom price input with `= сумма цен вендора` shortcut + +BOM is shared between tabs 2 and 3. The `Пересчитать эстимейт` action shows a confirmation dialog before overwriting the cart. + +### Excel Paste Detection + +- 2 columns → `[PN, qty]` +- 3+ columns → first col=PN, first numeric col=qty, subsequent numeric cols=prices +- If row 1 has non-numeric qty → treat as header and skip + +--- + +## API Endpoints + +| Method | URL | Description | +|--------|-----|-------------| +| GET | `/api/configs/:uuid/vendor-spec` | Fetch stored BOM | +| PUT | `/api/configs/:uuid/vendor-spec` | Replace BOM (full update) | +| POST | `/api/configs/:uuid/vendor-spec/resolve` | Resolve PNs → LOTs (no cart mutation) | +| POST | `/api/configs/:uuid/vendor-spec/apply` | Apply resolved LOTs to cart items | +| GET | `/api/partnumber-books` | List local book snapshots | +| GET | `/api/partnumber-books/:id` | Items for a book by server ID | +| POST | `/api/sync/partnumber-books` | Pull book snapshots from server | + +--- + +## MariaDB Schema (managed by PriceForge) + +```sql +CREATE TABLE qt_partnumber_books ( + id INT AUTO_INCREMENT PRIMARY KEY, + version VARCHAR(50) NOT NULL, + created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP, + is_active TINYINT(1) NOT NULL DEFAULT 1 +); + +CREATE TABLE qt_partnumber_book_items ( + id INT AUTO_INCREMENT PRIMARY KEY, + book_id INT NOT NULL, + partnumber VARCHAR(255) NOT NULL, + lot_name VARCHAR(255) NOT NULL, + is_primary_pn TINYINT(1) NOT NULL DEFAULT 0, + description VARCHAR(10000) NULL, + INDEX idx_book_pn (book_id, partnumber), + FOREIGN KEY (book_id) REFERENCES qt_partnumber_books(id) +); + +ALTER TABLE qt_configurations ADD COLUMN vendor_spec JSON NULL; +``` + +QuoteForge only reads (`SELECT`) from `qt_partnumber_books` and `qt_partnumber_book_items`. Writes are managed exclusively by PriceForge. diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index 779d9d4..362f8c5 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -820,6 +820,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect quoteHandler := handlers.NewQuoteHandler(quoteService) exportHandler := handlers.NewExportHandler(exportService, configService, projectService) pricelistHandler := handlers.NewPricelistHandler(local) + vendorSpecHandler := handlers.NewVendorSpecHandler(local) + partnumberBooksHandler := handlers.NewPartnumberBooksHandler(local) syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval) if err != nil { return nil, nil, fmt.Errorf("creating sync handler: %w", err) @@ -941,6 +943,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect router.GET("/configs/:uuid/revisions", webHandler.ConfigRevisions) router.GET("/pricelists", webHandler.Pricelists) router.GET("/pricelists/:id", webHandler.PricelistDetail) + router.GET("/partnumber-books", webHandler.PartnumberBooks) // htmx partials partials := router.Group("/partials") @@ -990,6 +993,13 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect pricelists.GET("/:id/lots", pricelistHandler.GetLotNames) } + // Partnumber books (read-only) + pnBooks := api.Group("/partnumber-books") + { + pnBooks.GET("", partnumberBooksHandler.List) + pnBooks.GET("/:id", partnumberBooksHandler.GetItems) + } + // Configurations (public - RBAC disabled) configs := api.Group("/configs") { @@ -1310,6 +1320,12 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect configs.GET("/:uuid/export", exportHandler.ExportConfigCSV) + // Vendor spec (BOM) endpoints + configs.GET("/:uuid/vendor-spec", vendorSpecHandler.GetVendorSpec) + configs.PUT("/:uuid/vendor-spec", vendorSpecHandler.PutVendorSpec) + configs.POST("/:uuid/vendor-spec/resolve", vendorSpecHandler.ResolveVendorSpec) + configs.POST("/:uuid/vendor-spec/apply", vendorSpecHandler.ApplyVendorSpec) + configs.PATCH("/:uuid/server-count", func(c *gin.Context) { uuid := c.Param("uuid") var req struct { @@ -1745,6 +1761,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect syncAPI.GET("/users-status", syncHandler.GetUsersStatus) syncAPI.POST("/components", syncHandler.SyncComponents) syncAPI.POST("/pricelists", syncHandler.SyncPricelists) + syncAPI.POST("/partnumber-books", syncHandler.SyncPartnumberBooks) syncAPI.POST("/all", syncHandler.SyncAll) syncAPI.POST("/push", syncHandler.PushPendingChanges) syncAPI.GET("/pending/count", syncHandler.GetPendingCount) diff --git a/internal/handlers/partnumber_books.go b/internal/handlers/partnumber_books.go new file mode 100644 index 0000000..a5c9470 --- /dev/null +++ b/internal/handlers/partnumber_books.go @@ -0,0 +1,90 @@ +package handlers + +import ( + "net/http" + "strconv" + + "git.mchus.pro/mchus/quoteforge/internal/localdb" + "git.mchus.pro/mchus/quoteforge/internal/repository" + "github.com/gin-gonic/gin" +) + +// PartnumberBooksHandler provides read-only access to local partnumber book snapshots. +type PartnumberBooksHandler struct { + localDB *localdb.LocalDB +} + +func NewPartnumberBooksHandler(localDB *localdb.LocalDB) *PartnumberBooksHandler { + return &PartnumberBooksHandler{localDB: localDB} +} + +// List returns all local partnumber book snapshots. +// GET /api/partnumber-books +func (h *PartnumberBooksHandler) List(c *gin.Context) { + bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB()) + books, err := bookRepo.ListBooks() + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + type bookSummary struct { + ID uint `json:"id"` + ServerID int `json:"server_id"` + Version string `json:"version"` + CreatedAt string `json:"created_at"` + IsActive bool `json:"is_active"` + ItemCount int64 `json:"item_count"` + } + + summaries := make([]bookSummary, 0, len(books)) + for _, b := range books { + summaries = append(summaries, bookSummary{ + ID: b.ID, + ServerID: b.ServerID, + Version: b.Version, + CreatedAt: b.CreatedAt.Format("2006-01-02"), + IsActive: b.IsActive, + ItemCount: bookRepo.CountBookItems(b.ID), + }) + } + + c.JSON(http.StatusOK, gin.H{ + "books": summaries, + "total": len(summaries), + }) +} + +// GetItems returns items for a partnumber book by server ID. +// GET /api/partnumber-books/:id +func (h *PartnumberBooksHandler) GetItems(c *gin.Context) { + idStr := c.Param("id") + id, err := strconv.ParseUint(idStr, 10, 64) + if err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "invalid book ID"}) + return + } + + bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB()) + + // Find local book by server_id + var book localdb.LocalPartnumberBook + if err := h.localDB.DB().Where("server_id = ?", id).First(&book).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "partnumber book not found"}) + return + } + + items, err := bookRepo.GetBookItems(book.ID) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "book_id": book.ServerID, + "version": book.Version, + "is_active": book.IsActive, + "items": items, + "total": len(items), + }) +} diff --git a/internal/handlers/sync.go b/internal/handlers/sync.go index 4116f9f..41eccd8 100644 --- a/internal/handlers/sync.go +++ b/internal/handlers/sync.go @@ -243,6 +243,33 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) { h.syncService.RecordSyncHeartbeat() } +// SyncPartnumberBooks syncs partnumber book snapshots from MariaDB to local SQLite. +// POST /api/sync/partnumber-books +func (h *SyncHandler) SyncPartnumberBooks(c *gin.Context) { + if !h.ensureSyncReadiness(c) { + return + } + + startTime := time.Now() + pulled, err := h.syncService.PullPartnumberBooks() + if err != nil { + slog.Error("partnumber books pull failed", "error", err) + c.JSON(http.StatusInternalServerError, gin.H{ + "success": false, + "error": err.Error(), + }) + return + } + + c.JSON(http.StatusOK, SyncResultResponse{ + Success: true, + Message: "Partnumber books synced successfully", + Synced: pulled, + Duration: time.Since(startTime).String(), + }) + h.syncService.RecordSyncHeartbeat() +} + // SyncAllResponse represents result of full sync type SyncAllResponse struct { Success bool `json:"success"` diff --git a/internal/handlers/vendor_spec.go b/internal/handlers/vendor_spec.go new file mode 100644 index 0000000..fb0463e --- /dev/null +++ b/internal/handlers/vendor_spec.go @@ -0,0 +1,166 @@ +package handlers + +import ( + "encoding/json" + "net/http" + + "git.mchus.pro/mchus/quoteforge/internal/localdb" + "git.mchus.pro/mchus/quoteforge/internal/middleware" + "git.mchus.pro/mchus/quoteforge/internal/repository" + "git.mchus.pro/mchus/quoteforge/internal/services" + "github.com/gin-gonic/gin" +) + +// VendorSpecHandler handles vendor BOM spec operations for a configuration. +type VendorSpecHandler struct { + localDB *localdb.LocalDB +} + +func NewVendorSpecHandler(localDB *localdb.LocalDB) *VendorSpecHandler { + return &VendorSpecHandler{localDB: localDB} +} + +// GetVendorSpec returns the vendor spec (BOM) for a configuration. +// GET /api/configs/:uuid/vendor-spec +func (h *VendorSpecHandler) GetVendorSpec(c *gin.Context) { + uuid := c.Param("uuid") + username := middleware.GetUsername(c) + + var cfg localdb.LocalConfiguration + if err := h.localDB.DB().Where("uuid = ? AND original_username = ? AND is_active = 1", uuid, username).First(&cfg).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"}) + return + } + + spec := cfg.VendorSpec + if spec == nil { + spec = localdb.VendorSpec{} + } + c.JSON(http.StatusOK, gin.H{"vendor_spec": spec}) +} + +// PutVendorSpec saves (replaces) the vendor spec for a configuration. +// PUT /api/configs/:uuid/vendor-spec +func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) { + uuid := c.Param("uuid") + username := middleware.GetUsername(c) + + var body struct { + VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var cfg localdb.LocalConfiguration + if err := h.localDB.DB().Where("uuid = ? AND original_username = ? AND is_active = 1", uuid, username).First(&cfg).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"}) + return + } + + // Assign sort_order if not set + for i := range body.VendorSpec { + if body.VendorSpec[i].SortOrder == 0 { + body.VendorSpec[i].SortOrder = (i + 1) * 10 + } + } + + spec := localdb.VendorSpec(body.VendorSpec) + if err := h.localDB.DB().Model(&cfg).Update("vendor_spec", spec).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"vendor_spec": spec}) +} + +// ResolveVendorSpec resolves vendor PN → LOT without modifying the cart. +// POST /api/configs/:uuid/vendor-spec/resolve +func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) { + uuid := c.Param("uuid") + username := middleware.GetUsername(c) + + var cfg localdb.LocalConfiguration + if err := h.localDB.DB().Where("uuid = ? AND original_username = ? AND is_active = 1", uuid, username).First(&cfg).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"}) + return + } + + var body struct { + VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB()) + resolver := services.NewVendorSpecResolver(bookRepo) + + resolved, err := resolver.Resolve(body.VendorSpec) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + // Also compute aggregated LOTs + book, _ := bookRepo.GetActiveBook() + aggregated, err := services.AggregateLOTs(resolved, book, bookRepo) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{ + "resolved": resolved, + "aggregated": aggregated, + }) +} + +// ApplyVendorSpec applies the resolved BOM to the cart (Estimate items). +// POST /api/configs/:uuid/vendor-spec/apply +func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) { + uuid := c.Param("uuid") + username := middleware.GetUsername(c) + + var body struct { + Items []struct { + LotName string `json:"lot_name"` + Quantity int `json:"quantity"` + UnitPrice float64 `json:"unit_price"` + } `json:"items"` + } + if err := c.ShouldBindJSON(&body); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + + var cfg localdb.LocalConfiguration + if err := h.localDB.DB().Where("uuid = ? AND original_username = ? AND is_active = 1", uuid, username).First(&cfg).Error; err != nil { + c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"}) + return + } + + newItems := make(localdb.LocalConfigItems, 0, len(body.Items)) + for _, it := range body.Items { + newItems = append(newItems, localdb.LocalConfigItem{ + LotName: it.LotName, + Quantity: it.Quantity, + UnitPrice: it.UnitPrice, + }) + } + + itemsJSON, err := json.Marshal(newItems) + if err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + if err := h.localDB.DB().Model(&cfg).Update("items", string(itemsJSON)).Error; err != nil { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"items": newItems}) +} diff --git a/internal/handlers/web.go b/internal/handlers/web.go index 828d820..b1b7c5d 100644 --- a/internal/handlers/web.go +++ b/internal/handlers/web.go @@ -67,7 +67,7 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer } // Load each page template with base - simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html", "config_revisions.html"} + simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html", "config_revisions.html", "partnumber_books.html"} for _, page := range simplePages { pagePath := filepath.Join(templatesPath, page) var tmpl *template.Template @@ -212,6 +212,10 @@ func (h *WebHandler) PricelistDetail(c *gin.Context) { h.render(c, "pricelist_detail.html", gin.H{"ActivePage": "pricelists"}) } +func (h *WebHandler) PartnumberBooks(c *gin.Context) { + h.render(c, "partnumber_books.html", gin.H{"ActivePage": "partnumber-books"}) +} + // Partials for htmx func (h *WebHandler) ComponentsPartial(c *gin.Context) { diff --git a/internal/localdb/models.go b/internal/localdb/models.go index 1d5b190..6de1219 100644 --- a/internal/localdb/models.go +++ b/internal/localdb/models.go @@ -103,6 +103,7 @@ type LocalConfiguration struct { WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"` CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"` OnlyInStock bool `gorm:"default:false" json:"only_in_stock"` + VendorSpec VendorSpec `gorm:"type:text" json:"vendor_spec,omitempty"` Line int `gorm:"column:line_no;index" json:"line"` PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"` CreatedAt time.Time `json:"created_at"` @@ -243,3 +244,70 @@ type PendingChange struct { func (PendingChange) TableName() string { return "pending_changes" } + +// LocalPartnumberBook stores a version snapshot of the PN→LOT mapping book (pull-only from PriceForge) +type LocalPartnumberBook struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + ServerID int `gorm:"uniqueIndex;not null" json:"server_id"` + Version string `gorm:"not null" json:"version"` + CreatedAt time.Time `gorm:"not null" json:"created_at"` + IsActive bool `gorm:"not null;default:true" json:"is_active"` +} + +func (LocalPartnumberBook) TableName() string { + return "local_partnumber_books" +} + +// LocalPartnumberBookItem stores a single PN→LOT mapping within a book snapshot +type LocalPartnumberBookItem struct { + ID uint `gorm:"primaryKey;autoIncrement" json:"id"` + BookID uint `gorm:"not null;index:idx_local_book_pn,priority:1" json:"book_id"` + Partnumber string `gorm:"not null;index:idx_local_book_pn,priority:2" json:"partnumber"` + LotName string `gorm:"not null" json:"lot_name"` + IsPrimaryPN bool `gorm:"not null;default:false" json:"is_primary_pn"` + Description string `json:"description,omitempty"` +} + +func (LocalPartnumberBookItem) TableName() string { + return "local_partnumber_book_items" +} + +// VendorSpecItem represents a single row in a vendor BOM specification +type VendorSpecItem struct { + SortOrder int `json:"sort_order"` + VendorPartnumber string `json:"vendor_partnumber"` + Quantity int `json:"quantity"` + Description string `json:"description,omitempty"` + UnitPrice *float64 `json:"unit_price,omitempty"` + TotalPrice *float64 `json:"total_price,omitempty"` + ResolvedLotName string `json:"resolved_lot_name,omitempty"` + ResolutionSource string `json:"resolution_source,omitempty"` // "book", "manual", "unresolved" + ManualLotSuggestion string `json:"manual_lot_suggestion,omitempty"` +} + +// VendorSpec is a JSON-encodable slice of VendorSpecItem +type VendorSpec []VendorSpecItem + +func (v VendorSpec) Value() (driver.Value, error) { + if v == nil { + return nil, nil + } + return json.Marshal(v) +} + +func (v *VendorSpec) Scan(value interface{}) error { + if value == nil { + *v = nil + return nil + } + var bytes []byte + switch val := value.(type) { + case []byte: + bytes = val + case string: + bytes = []byte(val) + default: + return errors.New("type assertion failed for VendorSpec") + } + return json.Unmarshal(bytes, v) +} diff --git a/internal/repository/partnumber_book.go b/internal/repository/partnumber_book.go new file mode 100644 index 0000000..26f034d --- /dev/null +++ b/internal/repository/partnumber_book.go @@ -0,0 +1,66 @@ +package repository + +import ( + "git.mchus.pro/mchus/quoteforge/internal/localdb" + "gorm.io/gorm" +) + +// PartnumberBookRepository provides read-only access to local partnumber book snapshots. +type PartnumberBookRepository struct { + db *gorm.DB +} + +func NewPartnumberBookRepository(db *gorm.DB) *PartnumberBookRepository { + return &PartnumberBookRepository{db: db} +} + +// GetActiveBook returns the most recently active local partnumber book. +func (r *PartnumberBookRepository) GetActiveBook() (*localdb.LocalPartnumberBook, error) { + var book localdb.LocalPartnumberBook + err := r.db.Where("is_active = 1").Order("created_at DESC, id DESC").First(&book).Error + if err != nil { + return nil, err + } + return &book, nil +} + +// GetBookItems returns all items for the given local book ID. +func (r *PartnumberBookRepository) GetBookItems(bookID uint) ([]localdb.LocalPartnumberBookItem, error) { + var items []localdb.LocalPartnumberBookItem + err := r.db.Where("book_id = ?", bookID).Find(&items).Error + return items, err +} + +// FindLotByPartnumber looks up a partnumber in the active book and returns the matching items. +func (r *PartnumberBookRepository) FindLotByPartnumber(bookID uint, partnumber string) ([]localdb.LocalPartnumberBookItem, error) { + var items []localdb.LocalPartnumberBookItem + err := r.db.Where("book_id = ? AND partnumber = ?", bookID, partnumber).Find(&items).Error + return items, err +} + +// ListBooks returns all local partnumber books ordered newest first. +func (r *PartnumberBookRepository) ListBooks() ([]localdb.LocalPartnumberBook, error) { + var books []localdb.LocalPartnumberBook + err := r.db.Order("created_at DESC, id DESC").Find(&books).Error + return books, err +} + +// SaveBook saves a new partnumber book snapshot. +func (r *PartnumberBookRepository) SaveBook(book *localdb.LocalPartnumberBook) error { + return r.db.Save(book).Error +} + +// SaveBookItems bulk-inserts items for a book snapshot. +func (r *PartnumberBookRepository) SaveBookItems(items []localdb.LocalPartnumberBookItem) error { + if len(items) == 0 { + return nil + } + return r.db.CreateInBatches(items, 500).Error +} + +// CountBookItems returns the number of items for a given local book ID. +func (r *PartnumberBookRepository) CountBookItems(bookID uint) int64 { + var count int64 + r.db.Model(&localdb.LocalPartnumberBookItem{}).Where("book_id = ?", bookID).Count(&count) + return count +} diff --git a/internal/services/sync/partnumber_books.go b/internal/services/sync/partnumber_books.go new file mode 100644 index 0000000..dbae8c7 --- /dev/null +++ b/internal/services/sync/partnumber_books.go @@ -0,0 +1,92 @@ +package sync + +import ( + "fmt" + "log/slog" + "time" + + "git.mchus.pro/mchus/quoteforge/internal/localdb" + "git.mchus.pro/mchus/quoteforge/internal/repository" +) + +// PullPartnumberBooks synchronizes partnumber book snapshots from MariaDB to local SQLite. +// It only pulls books that don't exist locally yet (append-only). +func (s *Service) PullPartnumberBooks() (int, error) { + slog.Info("starting partnumber book pull") + + mariaDB, err := s.getDB() + if err != nil { + return 0, fmt.Errorf("database not available: %w", err) + } + + localBookRepo := repository.NewPartnumberBookRepository(s.localDB.DB()) + + // Query server for all active partnumber books + type serverBook struct { + ID int `gorm:"column:id"` + Version string `gorm:"column:version"` + CreatedAt time.Time `gorm:"column:created_at"` + IsActive bool `gorm:"column:is_active"` + } + var serverBooks []serverBook + if err := mariaDB.Raw("SELECT id, version, created_at, is_active FROM qt_partnumber_books WHERE is_active = 1 ORDER BY created_at DESC, id DESC").Scan(&serverBooks).Error; err != nil { + return 0, fmt.Errorf("querying server partnumber books: %w", err) + } + + pulled := 0 + for _, sb := range serverBooks { + // Check if already exists locally + var existing localdb.LocalPartnumberBook + err := s.localDB.DB().Where("server_id = ?", sb.ID).First(&existing).Error + if err == nil { + // Already exists + continue + } + + // Save the book + localBook := &localdb.LocalPartnumberBook{ + ServerID: sb.ID, + Version: sb.Version, + CreatedAt: sb.CreatedAt, + IsActive: sb.IsActive, + } + if err := localBookRepo.SaveBook(localBook); err != nil { + slog.Warn("failed to save local partnumber book", "server_id", sb.ID, "error", err) + continue + } + + // Pull items for this book + type serverItem struct { + Partnumber string `gorm:"column:partnumber"` + LotName string `gorm:"column:lot_name"` + IsPrimaryPN bool `gorm:"column:is_primary_pn"` + Description string `gorm:"column:description"` + } + var serverItems []serverItem + if err := mariaDB.Raw("SELECT partnumber, lot_name, is_primary_pn, description FROM qt_partnumber_book_items WHERE book_id = ?", sb.ID).Scan(&serverItems).Error; err != nil { + slog.Warn("failed to query server partnumber book items", "book_id", sb.ID, "error", err) + continue + } + + localItems := make([]localdb.LocalPartnumberBookItem, 0, len(serverItems)) + for _, si := range serverItems { + localItems = append(localItems, localdb.LocalPartnumberBookItem{ + BookID: localBook.ID, + Partnumber: si.Partnumber, + LotName: si.LotName, + IsPrimaryPN: si.IsPrimaryPN, + Description: si.Description, + }) + } + if err := localBookRepo.SaveBookItems(localItems); err != nil { + slog.Warn("failed to save local partnumber book items", "book_id", localBook.ID, "error", err) + continue + } + + slog.Info("pulled partnumber book", "version", sb.Version, "items", len(localItems)) + pulled++ + } + + slog.Info("partnumber book pull completed", "pulled", pulled) + return pulled, nil +} diff --git a/internal/services/vendor_spec_resolver.go b/internal/services/vendor_spec_resolver.go new file mode 100644 index 0000000..e003702 --- /dev/null +++ b/internal/services/vendor_spec_resolver.go @@ -0,0 +1,129 @@ +package services + +import ( + "git.mchus.pro/mchus/quoteforge/internal/localdb" + "git.mchus.pro/mchus/quoteforge/internal/repository" +) + +// ResolvedBOMRow is the result of resolving a single vendor BOM row. +type ResolvedBOMRow struct { + localdb.VendorSpecItem + // ResolutionSource already on VendorSpecItem: "book", "manual_suggestion", "unresolved" +} + +// AggregatedLOT represents a LOT with its aggregated quantity from the BOM. +type AggregatedLOT struct { + LotName string + Quantity int +} + +// VendorSpecResolver resolves vendor BOM rows to LOT names using the active partnumber book. +type VendorSpecResolver struct { + bookRepo *repository.PartnumberBookRepository +} + +func NewVendorSpecResolver(bookRepo *repository.PartnumberBookRepository) *VendorSpecResolver { + return &VendorSpecResolver{bookRepo: bookRepo} +} + +// Resolve resolves each vendor spec item's lot name using the 3-step algorithm. +// It returns the resolved items. Manual lot suggestions from the input are preserved as pre-fill. +func (r *VendorSpecResolver) Resolve(items []localdb.VendorSpecItem) ([]localdb.VendorSpecItem, error) { + // Step 1: Get the active book + book, err := r.bookRepo.GetActiveBook() + if err != nil { + // No book available — mark all as unresolved + for i := range items { + if items[i].ResolvedLotName == "" { + items[i].ResolutionSource = "unresolved" + } + } + return items, nil + } + + for i, item := range items { + pn := item.VendorPartnumber + + // Step 1: Look up in active book + matches, err := r.bookRepo.FindLotByPartnumber(book.ID, pn) + if err == nil && len(matches) > 0 { + items[i].ResolvedLotName = matches[0].LotName + items[i].ResolutionSource = "book" + continue + } + + // Step 2: Pre-fill from manual_lot_suggestion if provided + if item.ManualLotSuggestion != "" { + items[i].ResolvedLotName = item.ManualLotSuggestion + items[i].ResolutionSource = "manual_suggestion" + continue + } + + // Step 3: Unresolved + items[i].ResolvedLotName = "" + items[i].ResolutionSource = "unresolved" + } + + return items, nil +} + +// AggregateLOTs applies the qty-logic to compute per-LOT quantities from the resolved BOM. +// qty(lot) = SUM(qty of primary PN rows for this lot) if any primary PN exists, else 1. +func AggregateLOTs(items []localdb.VendorSpecItem, book *localdb.LocalPartnumberBook, bookRepo *repository.PartnumberBookRepository) ([]AggregatedLOT, error) { + // Gather all unique lot names that resolved + lotPrimary := make(map[string]int) // lot_name → sum of primary PN quantities + lotAny := make(map[string]bool) // lot_name → seen at least once (non-primary) + lotHasPrimary := make(map[string]bool) // lot_name → has at least one primary PN in spec + + if book != nil { + for _, item := range items { + if item.ResolvedLotName == "" { + continue + } + lot := item.ResolvedLotName + pn := item.VendorPartnumber + + // Find if this pn is primary for its lot + matches, err := bookRepo.FindLotByPartnumber(book.ID, pn) + if err != nil || len(matches) == 0 { + // manual/unresolved — treat as non-primary + lotAny[lot] = true + continue + } + for _, m := range matches { + if m.LotName == lot { + if m.IsPrimaryPN { + lotPrimary[lot] += item.Quantity + lotHasPrimary[lot] = true + } else { + lotAny[lot] = true + } + } + } + } + } else { + // No book: all resolved rows contribute qty=1 per lot + for _, item := range items { + if item.ResolvedLotName != "" { + lotAny[item.ResolvedLotName] = true + } + } + } + + // Build aggregated list + seen := make(map[string]bool) + var result []AggregatedLOT + for _, item := range items { + lot := item.ResolvedLotName + if lot == "" || seen[lot] { + continue + } + seen[lot] = true + qty := 1 + if lotHasPrimary[lot] { + qty = lotPrimary[lot] + } + result = append(result, AggregatedLOT{LotName: lot, Quantity: qty}) + } + return result, nil +} diff --git a/migrations/029_add_vendor_spec_and_partnumber_books.sql b/migrations/029_add_vendor_spec_and_partnumber_books.sql new file mode 100644 index 0000000..b4c6df2 --- /dev/null +++ b/migrations/029_add_vendor_spec_and_partnumber_books.sql @@ -0,0 +1,22 @@ +-- local_partnumber_books: version snapshots of vendor PN → LOT mappings (pull-only from PriceForge) +CREATE TABLE IF NOT EXISTS local_partnumber_books ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + server_id INTEGER UNIQUE NOT NULL, + version TEXT NOT NULL, + created_at DATETIME NOT NULL, + is_active INTEGER NOT NULL DEFAULT 1 +); + +-- local_partnumber_book_items: PN → LOT mappings within a book snapshot +CREATE TABLE IF NOT EXISTS local_partnumber_book_items ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + book_id INTEGER NOT NULL, + partnumber TEXT NOT NULL, + lot_name TEXT NOT NULL, + is_primary_pn INTEGER NOT NULL DEFAULT 0, + description TEXT +); +CREATE INDEX IF NOT EXISTS idx_local_book_pn ON local_partnumber_book_items(book_id, partnumber); + +-- vendor_spec column: JSON array of BOM rows stored per configuration +ALTER TABLE local_configurations ADD COLUMN vendor_spec TEXT; diff --git a/web/templates/index.html b/web/templates/index.html index 91663b0..b97e5d5 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -63,6 +63,27 @@ + +
+ +
+ + +
+
@@ -229,6 +250,103 @@
+ + + + + + + + + @@ -787,6 +905,11 @@ document.addEventListener('DOMContentLoaded', async function() { hideAutocomplete(); } }); + + // Load vendor spec BOM for this configuration + if (configUUID) { + loadVendorSpec(configUUID); + } }); async function loadAllComponents() { @@ -1775,6 +1898,7 @@ function removeFromCart(lotName) { } function updateCartUI() { + window._currentCart = cart; // expose for BOM/Pricing tabs const total = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0); document.getElementById('cart-total').textContent = formatMoney(total); @@ -2486,6 +2610,415 @@ function updatePriceUpdateDate(dateStr) { document.getElementById('price-update-date').textContent = 'Обновлено: ' + timeAgo; } +// ==================== TOP-LEVEL TABS ==================== + +let currentTopTab = 'estimate'; + +function switchTopTab(tab) { + currentTopTab = tab; + const tabs = ['estimate', 'bom', 'pricing']; + tabs.forEach(t => { + const btn = document.getElementById('top-tab-' + t); + const section = document.getElementById('top-section-' + t); + if (t === tab) { + btn.classList.remove('border-transparent', 'text-gray-500'); + btn.classList.add('border-blue-600', 'text-blue-600'); + section.classList.remove('hidden'); + } else { + btn.classList.remove('border-blue-600', 'text-blue-600'); + btn.classList.add('border-transparent', 'text-gray-500'); + section.classList.add('hidden'); + } + }); + if (tab === 'pricing') { + renderPricingTab(); + } +} + +// ==================== BOM ВЕНДОРА ==================== + +let bomRows = []; // [{vendor_pn, quantity, description, unit_price, total_price, resolved_lot, resolution_source}] + +function handleBOMPaste(event) { + event.preventDefault(); + const text = event.clipboardData.getData('text/plain'); + if (!text) return; + + const lines = text.trim().split(/\r?\n/).filter(l => l.trim()); + const parsed = []; + + for (let i = 0; i < lines.length; i++) { + const cols = lines[i].split('\t'); + if (cols.length < 2) continue; + + // Auto-detect columns: + // 2 cols → [PN, qty] + // 3+ cols → first text=PN, first numeric=qty, subsequent numeric=prices + let pn = '', qty = 0, description = '', unit_price = null, total_price = null; + + if (cols.length === 2) { + pn = cols[0].trim(); + qty = parseInt(cols[1]) || 0; + // Skip header row if qty is NaN + if (!qty && i === 0) continue; + } else { + pn = cols[0].trim(); + // Find first numeric column for qty + let qtyIdx = -1, priceIdx = -1; + for (let c = 1; c < cols.length; c++) { + const v = cols[c].trim().replace(/[, ]/g, ''); + if (!isNaN(parseFloat(v)) && v !== '') { + if (qtyIdx === -1) { qtyIdx = c; } + else if (priceIdx === -1) { priceIdx = c; } + } + } + // If first row has non-numeric qty → likely header, skip + if (qtyIdx === -1) continue; + const rawQty = cols[qtyIdx].trim().replace(/[, ]/g, ''); + if (i === 0 && isNaN(parseInt(rawQty))) continue; + qty = parseInt(rawQty) || 1; + + // Description: columns between PN and first numeric + const descParts = []; + for (let c = 1; c < qtyIdx; c++) { descParts.push(cols[c].trim()); } + description = descParts.join(' ').trim(); + + if (priceIdx !== -1) { + const rawPrice = cols[priceIdx].trim().replace(/[, ]/g, ''); + unit_price = parseFloat(rawPrice) || null; + if (unit_price && qty) total_price = unit_price * qty; + } + } + + if (!pn) continue; + parsed.push({ + sort_order: (parsed.length + 1) * 10, + vendor_pn: pn, + quantity: qty || 1, + description, + unit_price, + total_price, + resolved_lot: '', + resolution_source: 'unresolved', + manual_lot: '' + }); + } + + if (!parsed.length) { + alert('Не удалось распознать данные. Убедитесь, что скопированы строки из Excel.'); + return; + } + + bomRows = parsed; + resolveBOM(); +} + +async function resolveBOM() { + if (!configUUID) return; + const specPayload = bomRows.map(r => ({ + sort_order: r.sort_order, + vendor_partnumber: r.vendor_pn, + quantity: r.quantity, + description: r.description, + unit_price: r.unit_price, + total_price: r.total_price, + manual_lot_suggestion: r.manual_lot + })); + + try { + const resp = await fetch(`/api/configs/${configUUID}/vendor-spec/resolve`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({vendor_spec: specPayload}) + }); + if (!resp.ok) throw new Error(await resp.text()); + const data = await resp.json(); + // Merge resolution results back into bomRows + if (data.resolved) { + data.resolved.forEach((r, i) => { + if (bomRows[i]) { + bomRows[i].resolved_lot = r.resolved_lot_name || ''; + bomRows[i].resolution_source = r.resolution_source || 'unresolved'; + } + }); + } + } catch (e) { + console.warn('Resolution failed:', e); + } + + renderBOMTable(); +} + +function renderBOMTable() { + const tbody = document.getElementById('bom-table-body'); + const cart = window._currentCart || []; + + // Build cart map: lot_name → quantity + const cartMap = {}; + cart.forEach(item => { cartMap[item.lot_name] = item.quantity; }); + + let unresolved = 0, mismatches = 0; + tbody.innerHTML = ''; + + bomRows.forEach((row, idx) => { + const tr = document.createElement('tr'); + const isUnresolved = !row.resolved_lot || row.resolution_source === 'unresolved'; + const cartQty = row.resolved_lot ? (cartMap[row.resolved_lot] ?? null) : null; + const qtyMismatch = cartQty !== null && cartQty !== row.quantity; + const notInCart = row.resolved_lot && cartQty === null; + + if (isUnresolved) unresolved++; + if (qtyMismatch || notInCart) mismatches++; + + let rowClass = ''; + if (isUnresolved) rowClass = 'bg-red-50'; + else if (qtyMismatch) rowClass = 'bg-yellow-50'; + else if (notInCart) rowClass = 'bg-orange-50'; + + tr.className = rowClass; + + let lotCell = ''; + if (isUnresolved) { + lotCell = ``; + } else { + let suffix = ''; + if (qtyMismatch) suffix = ` ≠est(${cartQty})`; + else if (notInCart) suffix = ` новый`; + lotCell = `${row.resolved_lot}${suffix}`; + } + + tr.innerHTML = ` + ${escapeHtml(row.vendor_pn)} + ${row.quantity} + ${escapeHtml(row.description || '')} + ${row.unit_price != null ? formatCurrency(row.unit_price) : '—'} + ${row.total_price != null ? formatCurrency(row.total_price) : '—'} + ${lotCell} + `; + tbody.appendChild(tr); + }); + + // Stats + const statsEl = document.getElementById('bom-stats'); + statsEl.textContent = `Строк: ${bomRows.length} | Не сопоставлено: ${unresolved} | Расхождений: ${mismatches}`; + + document.getElementById('bom-table-container').classList.remove('hidden'); + + // Also update pricing tab if visible + if (currentTopTab === 'pricing') renderPricingTab(); +} + +let _resolveBOMTimer = null; +function debouncedResolveBOM() { + clearTimeout(_resolveBOMTimer); + _resolveBOMTimer = setTimeout(() => resolveBOM(), 500); +} + +async function saveBOM() { + if (!configUUID || !bomRows.length) return; + const spec = bomRows.map(r => ({ + sort_order: r.sort_order, + vendor_partnumber: r.vendor_pn, + quantity: r.quantity, + description: r.description, + unit_price: r.unit_price, + total_price: r.total_price, + resolved_lot_name: r.resolved_lot, + resolution_source: r.resolution_source, + manual_lot_suggestion: r.manual_lot || null + })); + try { + const resp = await fetch(`/api/configs/${configUUID}/vendor-spec`, { + method: 'PUT', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({vendor_spec: spec}) + }); + if (!resp.ok) throw new Error(await resp.text()); + showToast('BOM сохранён', 'success'); + } catch (e) { + showToast('Ошибка сохранения BOM: ' + e.message, 'error'); + } +} + +async function applyBOMToEstimate() { + if (!bomRows.length) return; + const resolved = bomRows.filter(r => r.resolved_lot); + if (!resolved.length) { + alert('Нет сопоставленных строк. Сначала настройте LOT для всех позиций.'); + return; + } + + // Aggregate quantities + const lotMap = {}; + resolved.forEach(r => { + if (!lotMap[r.resolved_lot]) lotMap[r.resolved_lot] = 0; + lotMap[r.resolved_lot] += r.quantity; + }); + + const items = Object.entries(lotMap).map(([lot, qty]) => ({ + lot_name: lot, + quantity: qty, + unit_price: 0 + })); + + if (!confirm(`Пересчитать Estimate? Текущая корзина будет заменена ${items.length} позициями из BOM.`)) return; + + try { + const resp = await fetch(`/api/configs/${configUUID}/vendor-spec/apply`, { + method: 'POST', + headers: {'Content-Type': 'application/json'}, + body: JSON.stringify({items}) + }); + if (!resp.ok) throw new Error(await resp.text()); + showToast('Estimate обновлён из BOM', 'success'); + // Reload the page to show updated estimate + window.location.reload(); + } catch (e) { + showToast('Ошибка: ' + e.message, 'error'); + } +} + +// Load existing BOM on config load +async function loadVendorSpec(configUUID) { + try { + const resp = await fetch(`/api/configs/${configUUID}/vendor-spec`); + if (!resp.ok) return; + const data = await resp.json(); + if (data.vendor_spec && data.vendor_spec.length) { + bomRows = data.vendor_spec.map((r, i) => ({ + sort_order: r.sort_order || (i + 1) * 10, + vendor_pn: r.vendor_partnumber, + quantity: r.quantity, + description: r.description || '', + unit_price: r.unit_price || null, + total_price: r.total_price || null, + resolved_lot: r.resolved_lot_name || '', + resolution_source: r.resolution_source || 'unresolved', + manual_lot: r.manual_lot_suggestion || '' + })); + renderBOMTable(); + } + } catch (e) { + console.warn('Failed to load vendor spec:', e); + } +} + +// ==================== ЦЕНООБРАЗОВАНИЕ ==================== + +function renderPricingTab() { + const tbody = document.getElementById('pricing-table-body'); + const tfoot = document.getElementById('pricing-table-foot'); + + if (!bomRows.length) { + tbody.innerHTML = 'Загрузите BOM вендора во вкладке «BOM вендора»'; + tfoot.classList.add('hidden'); + return; + } + + const cart = window._currentCart || []; + const cartMap = {}; + cart.forEach(item => { cartMap[item.lot_name] = item; }); + + let totalVendor = 0, totalEstimate = 0, totalWarehouse = 0; + let hasVendor = false, hasEstimate = false, hasWarehouse = false; + + tbody.innerHTML = ''; + bomRows.forEach(row => { + const tr = document.createElement('tr'); + const isUnresolved = !row.resolved_lot || row.resolution_source === 'unresolved'; + const cartItem = row.resolved_lot ? (cartMap[row.resolved_lot] ?? null) : null; + const estimatePrice = cartItem ? cartItem.unit_price : null; + + const vendorTotal = row.total_price != null ? row.total_price : (row.unit_price != null ? row.unit_price * row.quantity : null); + if (vendorTotal != null) { totalVendor += vendorTotal; hasVendor = true; } + if (estimatePrice != null) { totalEstimate += estimatePrice * row.quantity; hasEstimate = true; } + + tr.innerHTML = ` + ${escapeHtml(row.vendor_pn)} + ${isUnresolved ? 'н/д' : escapeHtml(row.resolved_lot)} + ${row.quantity} + ${vendorTotal != null ? formatCurrency(vendorTotal) : '—'} + ${estimatePrice != null ? formatCurrency(estimatePrice * row.quantity) : '—'} + — + — + `; + tbody.appendChild(tr); + }); + + // Totals row + document.getElementById('pricing-total-vendor').textContent = hasVendor ? formatCurrency(totalVendor) : '—'; + document.getElementById('pricing-total-estimate').textContent = hasEstimate ? formatCurrency(totalEstimate) : '—'; + document.getElementById('pricing-total-warehouse').textContent = '—'; + tfoot.classList.remove('hidden'); + + // Update custom price discount info + onPricingCustomPriceInput(); +} + +function setPricingCustomPriceFromVendor() { + let totalVendor = 0; + bomRows.forEach(r => { + const vt = r.total_price != null ? r.total_price : (r.unit_price != null ? r.unit_price * r.quantity : 0); + totalVendor += vt; + }); + document.getElementById('pricing-custom-price').value = totalVendor.toFixed(2); + onPricingCustomPriceInput(); +} + +function onPricingCustomPriceInput() { + const customPrice = parseFloat(document.getElementById('pricing-custom-price').value) || 0; + // Compute estimate total + const cart = window._currentCart || []; + let estimateTotal = 0; + cart.forEach(item => { estimateTotal += (item.unit_price || 0) * item.quantity; }); + + const discountEl = document.getElementById('pricing-discount-info'); + const pctEl = document.getElementById('pricing-discount-pct'); + + if (customPrice > 0 && estimateTotal > 0) { + const discount = ((estimateTotal - customPrice) / estimateTotal * 100).toFixed(1); + pctEl.textContent = discount + '%'; + discountEl.classList.remove('hidden'); + } else { + discountEl.classList.add('hidden'); + } +} + +function escapeHtml(str) { + if (!str) return ''; + return str.replace(/&/g,'&').replace(//g,'>').replace(/"/g,'"'); +} + +function formatCurrency(val) { + if (val == null) return '—'; + return val.toLocaleString('ru-RU', {minimumFractionDigits: 2, maximumFractionDigits: 2}); +} + +// ==================== AUTOCOMPLETE LIST FOR BOM ==================== +// Inject datalist for lot autocomplete in BOM inline inputs +(function() { + const dl = document.createElement('datalist'); + dl.id = 'lot-autocomplete-list'; + document.body.appendChild(dl); + + // Populate datalist once allComponents is available + function populateLotDatalist() { + if (!window.allComponents || !window.allComponents.length) return; + dl.innerHTML = ''; + window.allComponents.forEach(c => { + const opt = document.createElement('option'); + opt.value = c.lot_name; + dl.appendChild(opt); + }); + } + + // Try after a short delay (components load async) + setTimeout(populateLotDatalist, 2000); +})(); + {{end}} diff --git a/web/templates/partnumber_books.html b/web/templates/partnumber_books.html new file mode 100644 index 0000000..a785684 --- /dev/null +++ b/web/templates/partnumber_books.html @@ -0,0 +1,139 @@ +{{define "title"}}QuoteForge - Листы сопоставлений{{end}} + +{{define "content"}} +
+
+

Листы сопоставлений PN → LOT

+ +
+ +
+
Загрузка...
+ + + + + + + + + + + + + +
+ + + +
+{{end}} + +{{define "scripts"}} + +{{end}} + +{{template "base" .}} From 23882637b50fcb339d3eb277308630c96b2459ba Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Sat, 21 Feb 2026 17:07:44 +0300 Subject: [PATCH 02/21] fix: use AutoMigrate for new SQLite tables instead of hardcoded migrations MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit LocalPartnumberBook and LocalPartnumberBookItem added to AutoMigrate list in localdb.go — consistent with all other local tables. Removed incorrectly added addPartnumberBooks/addVendorSpecColumn functions from migrations.go (vendor_spec column is handled by AutoMigrate via the LocalConfiguration model field). Co-Authored-By: Claude Sonnet 4.6 --- internal/localdb/localdb.go | 2 ++ internal/localdb/migrations.go | 1 + 2 files changed, 3 insertions(+) diff --git a/internal/localdb/localdb.go b/internal/localdb/localdb.go index 79a05e5..80928e6 100644 --- a/internal/localdb/localdb.go +++ b/internal/localdb/localdb.go @@ -142,6 +142,8 @@ func New(dbPath string) (*LocalDB, error) { &LocalRemoteMigrationApplied{}, &LocalSyncGuardState{}, &PendingChange{}, + &LocalPartnumberBook{}, + &LocalPartnumberBookItem{}, ); err != nil { return nil, fmt.Errorf("migrating sqlite database: %w", err) } diff --git a/internal/localdb/migrations.go b/internal/localdb/migrations.go index adaeebe..1dc8d04 100644 --- a/internal/localdb/migrations.go +++ b/internal/localdb/migrations.go @@ -864,3 +864,4 @@ WHERE id IN (SELECT id FROM ranked) return nil } + From 4a44d48366a6c0f3f0919505aff3c9379d64d913 Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Sat, 21 Feb 2026 17:09:45 +0300 Subject: [PATCH 03/21] docs(bible): fix and clarify SQLite migration mechanism in 03-database.md Previous description was wrong: migrations/*.sql are MariaDB-only. Document the actual 3-level SQLite migration flow: 1. GORM AutoMigrate (primary, runs on every start) 2. runLocalMigrations Go functions (data backfill, index creation) 3. Centralized remote migrations via qt_client_local_migrations Co-Authored-By: Claude Sonnet 4.6 --- bible/03-database.md | 27 ++++++++++++++------------- 1 file changed, 14 insertions(+), 13 deletions(-) diff --git a/bible/03-database.md b/bible/03-database.md index 5d3c93b..9206a41 100644 --- a/bible/03-database.md +++ b/bible/03-database.md @@ -151,21 +151,22 @@ SHOW GRANTS FOR 'quote_user'@'%'; ## Migrations -### SQLite Migrations (local) +### SQLite Migrations (local) — три уровня, выполняются при каждом старте + +**1. GORM AutoMigrate** (`internal/localdb/localdb.go`) — первый и основной уровень. +Список Go-моделей передаётся в `db.AutoMigrate(...)`. GORM создаёт отсутствующие таблицы и добавляет новые колонки. Колонки и таблицы **не удаляет**. +→ Для добавления новой таблицы или колонки достаточно добавить модель/поле и включить модель в AutoMigrate. + +**2. `runLocalMigrations`** (`internal/localdb/migrations.go`) — второй уровень, для операций которые AutoMigrate не умеет: backfill данных, пересоздание таблиц, создание индексов. +Каждая функция выполняется один раз — идемпотентность через запись `id` в `local_schema_migrations`. + +**3. Централизованные (server-side)** — третий уровень, при проверке готовности к синку. +SQL-тексты хранятся в `qt_client_local_migrations` (MariaDB, пишет только PriceForge). Клиент читает, применяет к локальной SQLite, записывает в `local_remote_migrations_applied` + `qt_client_schema_state`. + +### MariaDB Migrations (server-side) - Stored in `migrations/` (SQL files) -- Applied via `-migrate` flag or automatically on first run -- Idempotent: checked by `id` in `local_schema_migrations` -- Already-applied migrations are skipped - -```bash -go run ./cmd/qfs -migrate -``` - -### Centralized Migrations (server-side) - -- Stored in `qt_client_local_migrations` (MariaDB) -- Applied automatically during sync readiness check +- Applied via `-migrate` flag - `min_app_version` — minimum app version required for the migration --- From 347599e06b8045f748634d99eee32bf399fc7e4a Mon Sep 17 00:00:00 2001 From: Michael Chus Date: Sat, 21 Feb 2026 17:13:49 +0300 Subject: [PATCH 04/21] ui: add format hint to BOM vendor paste area Show supported column formats and auto-detection rules so users know what to copy from Excel. Co-Authored-By: Claude Sonnet 4.6 --- web/templates/index.html | 10 ++++++++-- 1 file changed, 8 insertions(+), 2 deletions(-) diff --git a/web/templates/index.html b/web/templates/index.html index b97e5d5..0f1015e 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -256,8 +256,14 @@