feat: implement vendor spec BOM import and PN→LOT resolution (Phase 1)
- 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 <noreply@anthropic.com>
This commit is contained in:
@@ -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` |
|
||||
|
||||
|
||||
126
bible/09-vendor-spec.md
Normal file
126
bible/09-vendor-spec.md
Normal file
@@ -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.
|
||||
@@ -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)
|
||||
|
||||
90
internal/handlers/partnumber_books.go
Normal file
90
internal/handlers/partnumber_books.go
Normal file
@@ -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),
|
||||
})
|
||||
}
|
||||
@@ -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"`
|
||||
|
||||
166
internal/handlers/vendor_spec.go
Normal file
166
internal/handlers/vendor_spec.go
Normal file
@@ -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})
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
66
internal/repository/partnumber_book.go
Normal file
66
internal/repository/partnumber_book.go
Normal file
@@ -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
|
||||
}
|
||||
92
internal/services/sync/partnumber_books.go
Normal file
92
internal/services/sync/partnumber_books.go
Normal file
@@ -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
|
||||
}
|
||||
129
internal/services/vendor_spec_resolver.go
Normal file
129
internal/services/vendor_spec_resolver.go
Normal file
@@ -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
|
||||
}
|
||||
22
migrations/029_add_vendor_spec_and_partnumber_books.sql
Normal file
22
migrations/029_add_vendor_spec_and_partnumber_books.sql
Normal file
@@ -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;
|
||||
@@ -63,6 +63,27 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Top-level tabs: Estimate | BOM вендора | Ценообразование -->
|
||||
<div class="bg-white rounded-lg shadow mb-0">
|
||||
<nav class="flex border-b">
|
||||
<button id="top-tab-estimate" onclick="switchTopTab('estimate')"
|
||||
class="px-5 py-3 text-sm font-semibold border-b-2 border-blue-600 text-blue-600">
|
||||
Estimate
|
||||
</button>
|
||||
<button id="top-tab-bom" onclick="switchTopTab('bom')"
|
||||
class="px-5 py-3 text-sm font-semibold border-b-2 border-transparent text-gray-500 hover:text-gray-700">
|
||||
BOM вендора
|
||||
</button>
|
||||
<button id="top-tab-pricing" onclick="switchTopTab('pricing')"
|
||||
class="px-5 py-3 text-sm font-semibold border-b-2 border-transparent text-gray-500 hover:text-gray-700">
|
||||
Ценообразование
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
<!-- Top-tab section: Estimate -->
|
||||
<div id="top-section-estimate">
|
||||
|
||||
<!-- Category Tabs -->
|
||||
<div class="bg-white rounded-lg shadow">
|
||||
<div class="border-b">
|
||||
@@ -229,6 +250,103 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div><!-- end top-section-estimate -->
|
||||
|
||||
<!-- Top-tab section: BOM вендора -->
|
||||
<div id="top-section-bom" class="hidden">
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<div class="mb-4 flex items-center gap-3">
|
||||
<span class="text-sm text-gray-600">Вставьте таблицу из Excel (Ctrl+V в область ниже):</span>
|
||||
</div>
|
||||
<div id="bom-paste-area"
|
||||
contenteditable="true"
|
||||
tabindex="0"
|
||||
class="border-2 border-dashed border-gray-300 rounded-lg p-4 min-h-16 text-gray-400 focus:outline-none focus:border-blue-400 cursor-text mb-4"
|
||||
onpaste="handleBOMPaste(event)"
|
||||
placeholder="Нажмите сюда и вставьте из Excel (Ctrl+V)...">
|
||||
Нажмите сюда и вставьте из Excel (Ctrl+V)...
|
||||
</div>
|
||||
|
||||
<!-- BOM table -->
|
||||
<div id="bom-table-container" class="hidden">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm border-collapse">
|
||||
<thead class="bg-gray-50 text-gray-700">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left border-b">PN вендора</th>
|
||||
<th class="px-3 py-2 text-right border-b">Кол-во</th>
|
||||
<th class="px-3 py-2 text-left border-b">Описание</th>
|
||||
<th class="px-3 py-2 text-right border-b">Цена ед.</th>
|
||||
<th class="px-3 py-2 text-right border-b">Итого</th>
|
||||
<th class="px-3 py-2 text-left border-b">LOT</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="bom-table-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-3 flex items-center justify-between text-sm text-gray-600">
|
||||
<div id="bom-stats"></div>
|
||||
<div class="flex gap-2">
|
||||
<button onclick="saveBOM()" class="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
Сохранить BOM
|
||||
</button>
|
||||
<button onclick="applyBOMToEstimate()" class="px-3 py-1 bg-orange-600 text-white rounded hover:bg-orange-700">
|
||||
Пересчитать эстимейт
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- end top-section-bom -->
|
||||
|
||||
<!-- Top-tab section: Ценообразование -->
|
||||
<div id="top-section-pricing" class="hidden">
|
||||
<div class="bg-white rounded-lg shadow p-4">
|
||||
<div id="pricing-table-container">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm border-collapse">
|
||||
<thead class="bg-gray-50 text-gray-700">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left border-b">PN вендора</th>
|
||||
<th class="px-3 py-2 text-left border-b">LOT</th>
|
||||
<th class="px-3 py-2 text-right border-b">Кол-во</th>
|
||||
<th class="px-3 py-2 text-right border-b">Цена вендора</th>
|
||||
<th class="px-3 py-2 text-right border-b">Estimate</th>
|
||||
<th class="px-3 py-2 text-right border-b">Склад</th>
|
||||
<th class="px-3 py-2 text-right border-b">Конк.</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="pricing-table-body">
|
||||
<tr><td colspan="7" class="px-3 py-8 text-center text-gray-400">Загрузите BOM вендора во вкладке «BOM вендора»</td></tr>
|
||||
</tbody>
|
||||
<tfoot id="pricing-table-foot" class="hidden bg-gray-50 font-semibold">
|
||||
<tr>
|
||||
<td colspan="3" class="px-3 py-2 text-right">Итого:</td>
|
||||
<td class="px-3 py-2 text-right" id="pricing-total-vendor">—</td>
|
||||
<td class="px-3 py-2 text-right" id="pricing-total-estimate">—</td>
|
||||
<td class="px-3 py-2 text-right" id="pricing-total-warehouse">—</td>
|
||||
<td class="px-3 py-2 text-right">—</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
<div class="mt-4 flex items-center gap-4">
|
||||
<label class="text-sm font-medium text-gray-700">Своя цена:</label>
|
||||
<input type="number" id="pricing-custom-price" step="0.01" min="0" placeholder="0.00"
|
||||
class="w-40 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||
oninput="onPricingCustomPriceInput()">
|
||||
<button onclick="setPricingCustomPriceFromVendor()" class="px-3 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 border border-gray-300 text-sm">
|
||||
= Сумма цен вендора
|
||||
</button>
|
||||
<span id="pricing-discount-info" class="text-sm text-gray-500 hidden">
|
||||
Скидка от Estimate: <span id="pricing-discount-pct" class="font-semibold text-green-600"></span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div><!-- end top-section-pricing -->
|
||||
|
||||
</div>
|
||||
|
||||
<!-- Price settings modal -->
|
||||
@@ -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 = `<input type="text" placeholder="Введите LOT..." value="${row.manual_lot || ''}"
|
||||
class="w-full px-2 py-1 border rounded text-sm focus:ring-1 focus:ring-blue-400"
|
||||
oninput="bomRows[${idx}].manual_lot = this.value; debouncedResolveBOM();"
|
||||
list="lot-autocomplete-list">`;
|
||||
} else {
|
||||
let suffix = '';
|
||||
if (qtyMismatch) suffix = ` <span class="text-yellow-600 text-xs">≠est(${cartQty})</span>`;
|
||||
else if (notInCart) suffix = ` <span class="text-orange-500 text-xs">новый</span>`;
|
||||
lotCell = `<span class="font-mono text-xs">${row.resolved_lot}</span>${suffix}`;
|
||||
}
|
||||
|
||||
tr.innerHTML = `
|
||||
<td class="px-3 py-1.5 font-mono text-xs">${escapeHtml(row.vendor_pn)}</td>
|
||||
<td class="px-3 py-1.5 text-right">${row.quantity}</td>
|
||||
<td class="px-3 py-1.5 text-gray-600 text-xs">${escapeHtml(row.description || '')}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs">${row.unit_price != null ? formatCurrency(row.unit_price) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs">${row.total_price != null ? formatCurrency(row.total_price) : '—'}</td>
|
||||
<td class="px-3 py-1.5">${lotCell}</td>
|
||||
`;
|
||||
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 = '<tr><td colspan="7" class="px-3 py-8 text-center text-gray-400">Загрузите BOM вендора во вкладке «BOM вендора»</td></tr>';
|
||||
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 = `
|
||||
<td class="px-3 py-1.5 font-mono text-xs">${escapeHtml(row.vendor_pn)}</td>
|
||||
<td class="px-3 py-1.5 text-xs">${isUnresolved ? '<span class="text-red-500">н/д</span>' : escapeHtml(row.resolved_lot)}</td>
|
||||
<td class="px-3 py-1.5 text-right">${row.quantity}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs">${vendorTotal != null ? formatCurrency(vendorTotal) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs">${estimatePrice != null ? formatCurrency(estimatePrice * row.quantity) : '—'}</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs text-gray-400">—</td>
|
||||
<td class="px-3 py-1.5 text-right text-xs text-gray-400">—</td>
|
||||
`;
|
||||
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,'>').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);
|
||||
})();
|
||||
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
|
||||
139
web/templates/partnumber_books.html
Normal file
139
web/templates/partnumber_books.html
Normal file
@@ -0,0 +1,139 @@
|
||||
{{define "title"}}QuoteForge - Листы сопоставлений{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<h1 class="text-2xl font-bold text-gray-900">Листы сопоставлений PN → LOT</h1>
|
||||
<button onclick="syncPartnumberBooks()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm">
|
||||
Синхронизировать
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<div id="books-list-loading" class="p-8 text-center text-gray-400">Загрузка...</div>
|
||||
<table id="books-table" class="w-full text-sm hidden">
|
||||
<thead class="bg-gray-50 text-gray-600">
|
||||
<tr>
|
||||
<th class="px-4 py-3 text-left">Версия</th>
|
||||
<th class="px-4 py-3 text-left">Дата</th>
|
||||
<th class="px-4 py-3 text-right">Позиций</th>
|
||||
<th class="px-4 py-3 text-center">Статус</th>
|
||||
<th class="px-4 py-3 text-center">Действия</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="books-table-body"></tbody>
|
||||
</table>
|
||||
<div id="books-empty" class="hidden p-8 text-center text-gray-400">
|
||||
Нет листов сопоставлений. Синхронизируйте с сервером.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Book detail -->
|
||||
<div id="book-detail" class="hidden bg-white rounded-lg shadow overflow-hidden">
|
||||
<div class="px-4 py-3 border-b flex items-center justify-between">
|
||||
<h2 class="font-semibold text-gray-800">Позиции листа: <span id="detail-version"></span></h2>
|
||||
<button onclick="closeBookDetail()" class="text-gray-400 hover:text-gray-700">✕</button>
|
||||
</div>
|
||||
<div class="p-4">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50 text-gray-600">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left">Partnumber</th>
|
||||
<th class="px-3 py-2 text-left">LOT</th>
|
||||
<th class="px-3 py-2 text-center">Primary PN</th>
|
||||
<th class="px-3 py-2 text-left">Описание</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="detail-items-body"></tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
<script>
|
||||
async function loadBooks() {
|
||||
const resp = await fetch('/api/partnumber-books');
|
||||
const data = await resp.json();
|
||||
const books = data.books || [];
|
||||
|
||||
document.getElementById('books-list-loading').classList.add('hidden');
|
||||
|
||||
if (!books.length) {
|
||||
document.getElementById('books-empty').classList.remove('hidden');
|
||||
return;
|
||||
}
|
||||
|
||||
const tbody = document.getElementById('books-table-body');
|
||||
tbody.innerHTML = '';
|
||||
books.forEach(b => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'border-b hover:bg-gray-50';
|
||||
tr.innerHTML = `
|
||||
<td class="px-4 py-3 font-mono text-sm">${b.version}</td>
|
||||
<td class="px-4 py-3 text-gray-600">${b.created_at}</td>
|
||||
<td class="px-4 py-3 text-right">${b.item_count}</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
${b.is_active ? '<span class="px-2 py-1 bg-green-100 text-green-700 rounded text-xs">Активный</span>' : '<span class="px-2 py-1 bg-gray-100 text-gray-500 rounded text-xs">Архив</span>'}
|
||||
</td>
|
||||
<td class="px-4 py-3 text-center">
|
||||
<button onclick="viewBookItems(${b.server_id}, '${b.version}')" class="text-blue-600 hover:text-blue-800 text-sm underline">
|
||||
Просмотр
|
||||
</button>
|
||||
</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
document.getElementById('books-table').classList.remove('hidden');
|
||||
}
|
||||
|
||||
async function viewBookItems(serverId, version) {
|
||||
const resp = await fetch(`/api/partnumber-books/${serverId}`);
|
||||
const data = await resp.json();
|
||||
const items = data.items || [];
|
||||
|
||||
document.getElementById('detail-version').textContent = version;
|
||||
const tbody = document.getElementById('detail-items-body');
|
||||
tbody.innerHTML = '';
|
||||
items.forEach(item => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'border-b';
|
||||
tr.innerHTML = `
|
||||
<td class="px-3 py-1.5 font-mono text-xs">${item.partnumber}</td>
|
||||
<td class="px-3 py-1.5 text-xs">${item.lot_name}</td>
|
||||
<td class="px-3 py-1.5 text-center">${item.is_primary_pn ? '✓' : ''}</td>
|
||||
<td class="px-3 py-1.5 text-xs text-gray-500">${item.description || ''}</td>
|
||||
`;
|
||||
tbody.appendChild(tr);
|
||||
});
|
||||
document.getElementById('book-detail').classList.remove('hidden');
|
||||
document.getElementById('book-detail').scrollIntoView({behavior: 'smooth'});
|
||||
}
|
||||
|
||||
function closeBookDetail() {
|
||||
document.getElementById('book-detail').classList.add('hidden');
|
||||
}
|
||||
|
||||
async function syncPartnumberBooks() {
|
||||
try {
|
||||
const resp = await fetch('/api/sync/partnumber-books', {method: 'POST'});
|
||||
const data = await resp.json();
|
||||
if (data.success) {
|
||||
showToast(`Синхронизировано: ${data.synced} листов`, 'success');
|
||||
loadBooks();
|
||||
} else {
|
||||
showToast('Ошибка: ' + data.error, 'error');
|
||||
}
|
||||
} catch (e) {
|
||||
showToast('Ошибка синхронизации', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
document.addEventListener('DOMContentLoaded', loadBooks);
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
{{template "base" .}}
|
||||
Reference in New Issue
Block a user