feat(vendor-spec): BOM import, LOT autocomplete, pricing, partnumber_seen push

- BOM paste: auto-detect columns by content (price, qty, PN, description);
  handles $5,114.00 and European comma-decimal formats
- LOT input: HTML5 datalist rebuilt on each renderBOMTable from allComponents;
  oninput updates data only (no re-render), onchange validates+resolves
- BOM persistence: PUT handler explicitly marshals VendorSpec to JSON string
  (GORM Update does not reliably call driver.Valuer for custom types)
- BOM autosave after every resolveBOM() call
- Pricing tab: async renderPricingTab() calls /api/quote/price-levels for all
  resolved LOTs directly — Estimate prices shown even before cart apply
- Unresolved PNs pushed to qt_vendor_partnumber_seen via POST
  /api/sync/partnumber-seen (fire-and-forget from JS)
- sync.PushPartnumberSeen(): upsert with ON DUPLICATE KEY UPDATE last_seen_at
- partnumber_books: pull ALL books (not only is_active=1); re-pull items when
  header exists but item count is 0; fallback for missing description column
- partnumber_books UI: collapsible snapshot section (collapsed by default),
  pagination (10/page), sync button always visible in header
- vendorSpec handlers: use GetConfigurationByUUID + IsActive check (removed
  original_username from WHERE — GetUsername returns "" without JWT)
- bible/09-vendor-spec.md: updated with all architectural decisions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-21 22:21:13 +03:00
parent d3f1a838eb
commit d0400b18a3
12 changed files with 829 additions and 399 deletions

View File

@@ -105,6 +105,8 @@ Database: `RFQ_LOG`
| `qt_client_local_migrations` | Migration catalog | SELECT only | | `qt_client_local_migrations` | Migration catalog | SELECT only |
| `qt_client_schema_state` | Applied migrations state | SELECT, INSERT, UPDATE | | `qt_client_schema_state` | Applied migrations state | SELECT, INSERT, UPDATE |
| `qt_pricelist_sync_status` | Pricelist sync status | SELECT, INSERT, UPDATE | | `qt_pricelist_sync_status` | Pricelist sync status | SELECT, INSERT, UPDATE |
| `qt_partnumber_books` | Partnumber book snapshots (written by PriceForge) | SELECT |
| `qt_partnumber_book_items` | PN→LOT mapping rows (written by PriceForge) | SELECT |
### Grant Permissions to Existing User ### Grant Permissions to Existing User
@@ -122,6 +124,9 @@ GRANT SELECT ON RFQ_LOG.qt_client_local_migrations TO '<DB_USER>'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO '<DB_USER>'@'%'; GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO '<DB_USER>'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO '<DB_USER>'@'%'; GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO '<DB_USER>'@'%';
FLUSH PRIVILEGES; FLUSH PRIVILEGES;
``` ```
@@ -140,6 +145,8 @@ GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_client_local_migrations TO 'quote_user'@'%'; GRANT SELECT ON RFQ_LOG.qt_client_local_migrations TO 'quote_user'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO 'quote_user'@'%'; GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO 'quote_user'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO 'quote_user'@'%'; GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO 'quote_user'@'%';
FLUSH PRIVILEGES; FLUSH PRIVILEGES;
SHOW GRANTS FOR 'quote_user'@'%'; SHOW GRANTS FOR 'quote_user'@'%';

View File

@@ -92,6 +92,25 @@
**If sync is blocked by the readiness guard:** all POST sync methods return `423 Locked` with `reason_code` and `reason_text`. **If sync is blocked by the readiness guard:** all POST sync methods return `423 Locked` with `reason_code` and `reason_text`.
### Vendor Spec (BOM)
| Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/api/configs/:uuid/vendor-spec` | Fetch stored vendor BOM |
| PUT | `/api/configs/:uuid/vendor-spec` | Replace vendor BOM (full update) |
| POST | `/api/configs/:uuid/vendor-spec/resolve` | Resolve PNs → LOTs (read-only) |
| POST | `/api/configs/:uuid/vendor-spec/apply` | Apply resolved LOTs to cart |
### Partnumber Books (read-only)
| Method | Endpoint | Purpose |
|--------|----------|---------|
| 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 MariaDB |
See [09-vendor-spec.md](09-vendor-spec.md) for schema and pull logic.
### Export ### Export
| Method | Endpoint | Purpose | | Method | Endpoint | Purpose |
@@ -114,6 +133,7 @@
| `/projects/:uuid` | Project details | | `/projects/:uuid` | Project details |
| `/pricelists` | Pricelist list | | `/pricelists` | Pricelist list |
| `/pricelists/:id` | Pricelist details | | `/pricelists/:id` | Pricelist details |
| `/partnumber-books` | Partnumber books (active book summary + snapshot history) |
| `/setup` | Connection settings | | `/setup` | Connection settings |
--- ---

View File

@@ -34,7 +34,7 @@ make help # All available commands
## Code Style ## Code Style
- **Formatting:** `gofmt` (mandatory) - **Formatting:** `gofmt` (mandatory)
- **Logging:** `slog` only (structured logging) - **Logging:** `slog` only (structured logging to the binary's stdout/stderr). No `console.log` or any other logging in browser-side JS — the browser console is never used for diagnostics.
- **Errors:** explicit wrapping with context (`fmt.Errorf("context: %w", err)`) - **Errors:** explicit wrapping with context (`fmt.Errorf("context: %w", err)`)
- **Style:** no unnecessary abstractions; minimum code for the task - **Style:** no unnecessary abstractions; minimum code for the task

View File

@@ -4,13 +4,15 @@
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). 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 ## Architecture
### Storage ### Storage
| Data | Storage | Sync direction | | Data | Storage | Sync direction |
|------|---------|---------------| |------|---------|---------------|
| `vendor_spec` JSON | `local_configurations.vendor_spec` (TEXT/JSON) | Two-way via `pending_changes` | | `vendor_spec` JSON | `local_configurations.vendor_spec` (TEXT, JSON-encoded) | Two-way via `pending_changes` |
| Partnumber book snapshots | `local_partnumber_books` + `local_partnumber_book_items` | Pull-only from PriceForge | | 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` 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.
@@ -39,67 +41,37 @@ The vendor spec feature allows importing a vendor BOM (Bill of Materials) into a
--- ---
## Resolution Algorithm (3-step) ## Partnumber Books (Snapshots)
For each `vendor_partnumber` in the BOM: Partnumber books are immutable versioned snapshots of the global PN→LOT mapping table, analogous to pricelists. PriceForge creates new snapshots; QuoteForge only pulls and reads them.
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"`. ### SQLite (local mirror)
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.
--- ```sql
CREATE TABLE local_partnumber_books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER UNIQUE NOT NULL, -- id from qt_partnumber_books
version TEXT NOT NULL, -- format YYYY-MM-DD-NNN
created_at DATETIME NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1
);
## Qty Aggregation Logic CREATE TABLE local_partnumber_book_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
After resolution, qty per LOT is computed as: book_id INTEGER NOT NULL, -- FK → local_partnumber_books.id
partnumber TEXT NOT NULL,
``` lot_name TEXT NOT NULL,
qty(lot) = SUM(quantity of rows where is_primary_pn=1 AND resolved_lot=lot) is_primary_pn INTEGER NOT NULL DEFAULT 0,
if at least one primary PN for this lot was found in BOM description TEXT
= 1 );
if only non-primary PNs for this lot were found CREATE INDEX idx_local_book_pn ON local_partnumber_book_items(book_id, partnumber);
``` ```
Examples (book mapping: LOT_A → x1[primary], x2, x3): **Active book query:** `WHERE is_active=1 ORDER BY created_at DESC, id DESC LIMIT 1`
- 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)
--- **Schema creation:** GORM AutoMigrate (not `runLocalMigrations`).
## UI: Three Top-Level Tabs ### MariaDB (managed exclusively by PriceForge)
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 ```sql
CREATE TABLE qt_partnumber_books ( CREATE TABLE qt_partnumber_books (
@@ -123,4 +95,140 @@ CREATE TABLE qt_partnumber_book_items (
ALTER TABLE qt_configurations ADD COLUMN vendor_spec JSON NULL; 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. QuoteForge has `SELECT` permission only on `qt_partnumber_books` and `qt_partnumber_book_items`. All writes are managed by PriceForge.
**Grant (add to existing user setup):**
```sql
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO '<DB_USER>'@'%';
```
### `is_primary_pn` semantics
- `1` = primary PN for this `lot_name`. Its quantity in the vendor BOM determines `qty(LOT)`.
- `0` = non-primary (trigger) PN. If no primary PN for the LOT is found in BOM, contributes `qty=1`.
---
## 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: LOT_A → x1[primary], x2, x3):
- BOM: x2×1, x3×2 → 1×LOT_A (no primary PN)
- 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 (confirmation dialog before overwriting cart), "Очистить" button.
3. **Ценообразование** — pricing summary table + custom price input.
BOM data is shared between tabs 2 and 3.
### BOM Paste Format (auto-detected, tab-separated from Excel)
Columns are detected automatically by content analysis — any number of columns is supported:
- **price column** — last column where ≥70% of values are parseable numbers (handles `$5,114.00` and `5 114,00` formats)
- **qty column** — first column where all values are integers
- **PN column** — last text column before qty
- **description column** — longest text column after qty
If the second column of the first row is non-numeric → treat as header and skip.
Price parsing handles: `$` prefix, spaces as thousands separator, comma-as-decimal (European format).
### Pricing Tab: column order
```
LOT | PN вендора | Описание | Кол-во | Estimate | Цена вендора | Склад | Конк.
```
**If BOM is empty** — the pricing tab still renders, using cart items as rows (PN вендора = "—", Цена вендора = "—").
**Description source priority:** BOM row description → LOT description from `local_components`.
### Inline LOT input for unresolved rows
Unresolved rows show a red background with an `<input list="lot-autocomplete-list">` (HTML5 datalist). The datalist is rebuilt from `allComponents` on every `renderBOMTable()` call.
- `oninput` — updates `bomRows[idx].manual_lot` only; no table re-render (prevents focus loss).
- `onchange` — validates via `_bomLotValid(v)` against `allComponents`; rejects free-text not matching a known LOT (field reset). If valid, calls `resolveBOM()`.
### Pricing Tab: "Своя цена" input
- Manual entry → proportionally redistributes custom price into "Цена вендора" cells (proportional to each row's Estimate share). Last row absorbs rounding remainder.
- "Проставить цены BOM" button → restores per-row original BOM prices directly (no proportional redistribution). Sets "Своя цена" to their sum.
- Both paths show "Скидка от Estimate: X%" info.
- "Экспорт CSV" button → downloads `pricing_<uuid>.csv` with UTF-8 BOM, same column order as table, plus Итого row.
---
## 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 |
| 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 MariaDB |
| POST | `/api/sync/partnumber-seen` | Push unresolved PNs to `qt_vendor_partnumber_seen` on MariaDB |
## Unresolved PN Tracking (`qt_vendor_partnumber_seen`)
After each `resolveBOM()` call, all unresolved PNs (rows where `resolution_source === 'unresolved'`) are pushed to the server via `POST /api/sync/partnumber-seen` (fire-and-forget from JS — errors silently ignored).
The handler calls `sync.PushPartnumberSeen()` which upserts into `qt_vendor_partnumber_seen`:
```sql
INSERT INTO qt_vendor_partnumber_seen (source_type, vendor, partnumber, description, last_seen_at)
VALUES ('manual', '', ?, ?, NOW())
ON DUPLICATE KEY UPDATE
last_seen_at = VALUES(last_seen_at),
description = COALESCE(NULLIF(VALUES(description), ''), description)
```
Uniqueness key: `partnumber` only (after PriceForge migration 025). PriceForge uses this table for populating the partnumber book.
## BOM Persistence
- `vendor_spec` is saved to server via `PUT /api/configs/:uuid/vendor-spec`.
- The PUT handler explicitly marshals `VendorSpec` to JSON string before passing to GORM `Update` (GORM does not reliably call `driver.Valuer` for custom types in `Update(column, value)`).
- BOM is saved automatically after every `resolveBOM()` (which fires on paste and on manual LOT selection).
- "Сохранить BOM" button triggers explicit save.
## Pricing Tab: Estimate Price Source
`renderPricingTab()` is async. It calls `POST /api/quote/price-levels` with all resolved LOTs from BOM rows (regardless of whether those LOTs are in the cart). This ensures Estimate prices appear even for manually-resolved LOTs that have not yet been applied to the cart via "Пересчитать эстимейт".
## Web Route
| Route | Page |
|-------|------|
| `/partnumber-books` | Partnumber books — active book summary (unique LOTs, total PN, primary PN count), searchable items table, collapsible snapshot history |

View File

@@ -1762,6 +1762,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
syncAPI.POST("/components", syncHandler.SyncComponents) syncAPI.POST("/components", syncHandler.SyncComponents)
syncAPI.POST("/pricelists", syncHandler.SyncPricelists) syncAPI.POST("/pricelists", syncHandler.SyncPricelists)
syncAPI.POST("/partnumber-books", syncHandler.SyncPartnumberBooks) syncAPI.POST("/partnumber-books", syncHandler.SyncPartnumberBooks)
syncAPI.POST("/partnumber-seen", syncHandler.ReportPartnumberSeen)
syncAPI.POST("/all", syncHandler.SyncAll) syncAPI.POST("/all", syncHandler.SyncAll)
syncAPI.POST("/push", syncHandler.PushPendingChanges) syncAPI.POST("/push", syncHandler.PushPendingChanges)
syncAPI.GET("/pending/count", syncHandler.GetPendingCount) syncAPI.GET("/pending/count", syncHandler.GetPendingCount)

View File

@@ -679,3 +679,35 @@ func (h *SyncHandler) getReadinessCached(maxAge time.Duration) *sync.SyncReadine
h.readinessMu.Unlock() h.readinessMu.Unlock()
return readiness return readiness
} }
// ReportPartnumberSeen pushes unresolved vendor partnumbers to qt_vendor_partnumber_seen on MariaDB.
// POST /api/sync/partnumber-seen
func (h *SyncHandler) ReportPartnumberSeen(c *gin.Context) {
var body struct {
Items []struct {
Partnumber string `json:"partnumber"`
Description string `json:"description"`
} `json:"items"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
items := make([]sync.SeenPartnumber, 0, len(body.Items))
for _, it := range body.Items {
if it.Partnumber != "" {
items = append(items, sync.SeenPartnumber{
Partnumber: it.Partnumber,
Description: it.Description,
})
}
}
if err := h.syncService.PushPartnumberSeen(items); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"reported": len(items)})
}

View File

@@ -2,10 +2,10 @@ package handlers
import ( import (
"encoding/json" "encoding/json"
"errors"
"net/http" "net/http"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "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/repository"
"git.mchus.pro/mchus/quoteforge/internal/services" "git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -20,14 +20,23 @@ func NewVendorSpecHandler(localDB *localdb.LocalDB) *VendorSpecHandler {
return &VendorSpecHandler{localDB: localDB} return &VendorSpecHandler{localDB: localDB}
} }
// lookupConfig finds an active configuration by UUID using the standard localDB method.
func (h *VendorSpecHandler) lookupConfig(uuid string) (*localdb.LocalConfiguration, error) {
cfg, err := h.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return nil, err
}
if !cfg.IsActive {
return nil, errors.New("not active")
}
return cfg, nil
}
// GetVendorSpec returns the vendor spec (BOM) for a configuration. // GetVendorSpec returns the vendor spec (BOM) for a configuration.
// GET /api/configs/:uuid/vendor-spec // GET /api/configs/:uuid/vendor-spec
func (h *VendorSpecHandler) GetVendorSpec(c *gin.Context) { func (h *VendorSpecHandler) GetVendorSpec(c *gin.Context) {
uuid := c.Param("uuid") cfg, err := h.lookupConfig(c.Param("uuid"))
username := middleware.GetUsername(c) if err != nil {
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"}) c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
return return
} }
@@ -42,8 +51,11 @@ func (h *VendorSpecHandler) GetVendorSpec(c *gin.Context) {
// PutVendorSpec saves (replaces) the vendor spec for a configuration. // PutVendorSpec saves (replaces) the vendor spec for a configuration.
// PUT /api/configs/:uuid/vendor-spec // PUT /api/configs/:uuid/vendor-spec
func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) { func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
uuid := c.Param("uuid") cfg, err := h.lookupConfig(c.Param("uuid"))
username := middleware.GetUsername(c) if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
return
}
var body struct { var body struct {
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"` VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
@@ -53,13 +65,6 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
return 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 { for i := range body.VendorSpec {
if body.VendorSpec[i].SortOrder == 0 { if body.VendorSpec[i].SortOrder == 0 {
body.VendorSpec[i].SortOrder = (i + 1) * 10 body.VendorSpec[i].SortOrder = (i + 1) * 10
@@ -67,7 +72,12 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
} }
spec := localdb.VendorSpec(body.VendorSpec) spec := localdb.VendorSpec(body.VendorSpec)
if err := h.localDB.DB().Model(&cfg).Update("vendor_spec", spec).Error; err != nil { specJSON, err := json.Marshal(spec)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := h.localDB.DB().Model(cfg).Update("vendor_spec", string(specJSON)).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }
@@ -78,11 +88,7 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
// ResolveVendorSpec resolves vendor PN → LOT without modifying the cart. // ResolveVendorSpec resolves vendor PN → LOT without modifying the cart.
// POST /api/configs/:uuid/vendor-spec/resolve // POST /api/configs/:uuid/vendor-spec/resolve
func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) { func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
uuid := c.Param("uuid") if _, err := h.lookupConfig(c.Param("uuid")); err != nil {
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"}) c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
return return
} }
@@ -104,7 +110,6 @@ func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
return return
} }
// Also compute aggregated LOTs
book, _ := bookRepo.GetActiveBook() book, _ := bookRepo.GetActiveBook()
aggregated, err := services.AggregateLOTs(resolved, book, bookRepo) aggregated, err := services.AggregateLOTs(resolved, book, bookRepo)
if err != nil { if err != nil {
@@ -121,8 +126,11 @@ func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
// ApplyVendorSpec applies the resolved BOM to the cart (Estimate items). // ApplyVendorSpec applies the resolved BOM to the cart (Estimate items).
// POST /api/configs/:uuid/vendor-spec/apply // POST /api/configs/:uuid/vendor-spec/apply
func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) { func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) {
uuid := c.Param("uuid") cfg, err := h.lookupConfig(c.Param("uuid"))
username := middleware.GetUsername(c) if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
return
}
var body struct { var body struct {
Items []struct { Items []struct {
@@ -136,12 +144,6 @@ func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) {
return 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)) newItems := make(localdb.LocalConfigItems, 0, len(body.Items))
for _, it := range body.Items { for _, it := range body.Items {
newItems = append(newItems, localdb.LocalConfigItem{ newItems = append(newItems, localdb.LocalConfigItem{
@@ -157,7 +159,7 @@ func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) {
return return
} }
if err := h.localDB.DB().Model(&cfg).Update("items", string(itemsJSON)).Error; err != nil { if err := h.localDB.DB().Model(cfg).Update("items", string(itemsJSON)).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return return
} }

View File

@@ -7,10 +7,11 @@ import (
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/repository"
"gorm.io/gorm"
) )
// PullPartnumberBooks synchronizes partnumber book snapshots from MariaDB to local SQLite. // PullPartnumberBooks synchronizes partnumber book snapshots from MariaDB to local SQLite.
// It only pulls books that don't exist locally yet (append-only). // Append-only for headers; re-pulls items if a book header exists but has 0 items.
func (s *Service) PullPartnumberBooks() (int, error) { func (s *Service) PullPartnumberBooks() (int, error) {
slog.Info("starting partnumber book pull") slog.Info("starting partnumber book pull")
@@ -21,7 +22,6 @@ func (s *Service) PullPartnumberBooks() (int, error) {
localBookRepo := repository.NewPartnumberBookRepository(s.localDB.DB()) localBookRepo := repository.NewPartnumberBookRepository(s.localDB.DB())
// Query server for all active partnumber books
type serverBook struct { type serverBook struct {
ID int `gorm:"column:id"` ID int `gorm:"column:id"`
Version string `gorm:"column:version"` Version string `gorm:"column:version"`
@@ -29,21 +29,36 @@ func (s *Service) PullPartnumberBooks() (int, error) {
IsActive bool `gorm:"column:is_active"` IsActive bool `gorm:"column:is_active"`
} }
var serverBooks []serverBook 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 { if err := mariaDB.Raw("SELECT id, version, created_at, is_active FROM qt_partnumber_books ORDER BY created_at DESC, id DESC").Scan(&serverBooks).Error; err != nil {
return 0, fmt.Errorf("querying server partnumber books: %w", err) return 0, fmt.Errorf("querying server partnumber books: %w", err)
} }
slog.Info("partnumber books found on server", "count", len(serverBooks))
pulled := 0 pulled := 0
for _, sb := range serverBooks { for _, sb := range serverBooks {
// Check if already exists locally
var existing localdb.LocalPartnumberBook var existing localdb.LocalPartnumberBook
err := s.localDB.DB().Where("server_id = ?", sb.ID).First(&existing).Error err := s.localDB.DB().Where("server_id = ?", sb.ID).First(&existing).Error
if err == nil { if err == nil {
// Already exists // Header exists — check whether items were saved
localItemCount := localBookRepo.CountBookItems(existing.ID)
if localItemCount > 0 {
slog.Debug("partnumber book already synced, skipping", "server_id", sb.ID, "version", sb.Version, "items", localItemCount)
continue
}
// Items missing — re-pull them
slog.Info("partnumber book header exists but has no items, re-pulling items", "server_id", sb.ID, "version", sb.Version)
n, err := pullBookItems(mariaDB, localBookRepo, sb.ID, existing.ID)
if err != nil {
slog.Error("failed to re-pull items for existing book", "server_id", sb.ID, "error", err)
} else {
slog.Info("re-pulled items for existing book", "server_id", sb.ID, "version", sb.Version, "items_saved", n)
pulled++
}
continue continue
} }
// Save the book slog.Info("pulling new partnumber book", "server_id", sb.ID, "version", sb.Version, "is_active", sb.IsActive)
localBook := &localdb.LocalPartnumberBook{ localBook := &localdb.LocalPartnumberBook{
ServerID: sb.ID, ServerID: sb.ID,
Version: sb.Version, Version: sb.Version,
@@ -51,42 +66,62 @@ func (s *Service) PullPartnumberBooks() (int, error) {
IsActive: sb.IsActive, IsActive: sb.IsActive,
} }
if err := localBookRepo.SaveBook(localBook); err != nil { if err := localBookRepo.SaveBook(localBook); err != nil {
slog.Warn("failed to save local partnumber book", "server_id", sb.ID, "error", err) slog.Error("failed to save local partnumber book", "server_id", sb.ID, "error", err)
continue continue
} }
// Pull items for this book n, err := pullBookItems(mariaDB, localBookRepo, sb.ID, localBook.ID)
type serverItem struct { if err != nil {
Partnumber string `gorm:"column:partnumber"` slog.Error("failed to pull items for new book", "server_id", sb.ID, "error", err)
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 continue
} }
localItems := make([]localdb.LocalPartnumberBookItem, 0, len(serverItems)) slog.Info("partnumber book saved locally", "server_id", sb.ID, "version", sb.Version, "items_saved", n)
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++ pulled++
} }
slog.Info("partnumber book pull completed", "pulled", pulled) slog.Info("partnumber book pull completed", "new_books_pulled", pulled, "total_on_server", len(serverBooks))
return pulled, nil return pulled, nil
} }
// pullBookItems fetches items for a single book from MariaDB and saves them to SQLite.
// Returns the number of items saved.
func pullBookItems(mariaDB *gorm.DB, repo *repository.PartnumberBookRepository, serverBookID int, localBookID uint) (int, error) {
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"`
}
// description column may not exist yet on older server schemas — query without it first,
// then retry with it to populate descriptions if available.
var serverItems []serverItem
err := mariaDB.Raw("SELECT partnumber, lot_name, is_primary_pn, description FROM qt_partnumber_book_items WHERE book_id = ?", serverBookID).Scan(&serverItems).Error
if err != nil {
slog.Warn("description column not available on server, retrying without it", "server_book_id", serverBookID, "error", err)
if err2 := mariaDB.Raw("SELECT partnumber, lot_name, is_primary_pn FROM qt_partnumber_book_items WHERE book_id = ?", serverBookID).Scan(&serverItems).Error; err2 != nil {
return 0, fmt.Errorf("querying items from server: %w", err2)
}
}
slog.Info("partnumber book items fetched from server", "server_book_id", serverBookID, "count", len(serverItems))
if len(serverItems) == 0 {
slog.Warn("server returned 0 items for book — check qt_partnumber_book_items on server", "server_book_id", serverBookID)
return 0, nil
}
localItems := make([]localdb.LocalPartnumberBookItem, 0, len(serverItems))
for _, si := range serverItems {
localItems = append(localItems, localdb.LocalPartnumberBookItem{
BookID: localBookID,
Partnumber: si.Partnumber,
LotName: si.LotName,
IsPrimaryPN: si.IsPrimaryPN,
Description: si.Description,
})
}
if err := repo.SaveBookItems(localItems); err != nil {
return 0, fmt.Errorf("saving items to local db: %w", err)
}
return len(localItems), nil
}

View File

@@ -0,0 +1,49 @@
package sync
import (
"fmt"
"log/slog"
"time"
)
// SeenPartnumber represents an unresolved vendor partnumber to report.
type SeenPartnumber struct {
Partnumber string
Description string
}
// PushPartnumberSeen inserts unresolved vendor partnumbers into qt_vendor_partnumber_seen on MariaDB.
// Uses INSERT ... ON DUPLICATE KEY UPDATE so existing rows are updated (last_seen_at) without error.
func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
if len(items) == 0 {
return nil
}
mariaDB, err := s.getDB()
if err != nil {
return fmt.Errorf("database not available: %w", err)
}
now := time.Now().UTC()
for _, item := range items {
if item.Partnumber == "" {
continue
}
err := mariaDB.Exec(`
INSERT INTO qt_vendor_partnumber_seen
(source_type, vendor, partnumber, description, last_seen_at)
VALUES
('manual', '', ?, ?, ?)
ON DUPLICATE KEY UPDATE
last_seen_at = VALUES(last_seen_at),
description = COALESCE(NULLIF(VALUES(description), ''), description)
`, item.Partnumber, item.Description, now).Error
if err != nil {
slog.Error("failed to upsert partnumber_seen", "partnumber", item.Partnumber, "error", err)
// Continue with remaining items
}
}
slog.Info("partnumber_seen pushed to server", "count", len(items))
return nil
}

View File

@@ -1,22 +0,0 @@
-- 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;

View File

@@ -143,113 +143,11 @@
</div> </div>
</div> </div>
<!-- Custom price section --> <!-- hidden inputs kept for JS compatibility -->
<div id="custom-price-section" class="bg-white rounded-lg shadow overflow-hidden"> <input type="hidden" id="custom-price-input" value="">
<button type="button" <div id="adjusted-prices" class="hidden"></div>
onclick="toggleCustomPriceSection()" <div id="discount-info" class="hidden"></div>
class="w-full px-4 py-3 flex items-center justify-between text-blue-900 bg-gradient-to-r from-blue-100 to-blue-50 hover:from-blue-200 hover:to-blue-100 border-b border-blue-200"> <div id="sale-prices" class="hidden"></div>
<span class="font-semibold">Своя цена</span>
<svg id="custom-price-toggle-icon" class="w-5 h-5 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<div id="custom-price-content" class="p-4">
<div class="flex items-center gap-4 mb-4">
<div class="flex-1">
<label class="block text-sm text-gray-600 mb-1">Введите целевую цену</label>
<div class="flex items-center gap-2">
<span class="text-gray-500">$</span>
<input type="number" id="custom-price-input" step="0.01" min="0"
placeholder="0.00"
class="flex-1 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
oninput="calculateCustomPrice(); triggerAutoSave();">
<button onclick="clearCustomPrice()" class="px-3 py-2 text-gray-500 hover:text-gray-700">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
</div>
<div id="discount-info" class="text-right hidden">
<div class="text-sm text-gray-600">Скидка</div>
<div class="text-2xl font-bold text-green-600" id="discount-percent">0%</div>
</div>
</div>
<!-- Adjusted prices table -->
<div id="adjusted-prices" class="hidden">
<div class="border-t pt-3">
<h4 class="text-sm font-medium text-gray-700 mb-2">Скорректированные цены</h4>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Компонент</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Кол-во</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Было</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Стало</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Итого</th>
</tr>
</thead>
<tbody id="adjusted-prices-body" class="divide-y"></tbody>
<tfoot class="bg-gray-50 font-medium">
<tr>
<td class="px-3 py-2" colspan="2">Итого</td>
<td class="px-3 py-2 text-right" id="adjusted-total-original">$0.00</td>
<td class="px-3 py-2 text-right text-green-600" id="adjusted-total-new">$0.00</td>
<td class="px-3 py-2 text-right text-green-600" id="adjusted-total-final">$0.00</td>
</tr>
</tfoot>
</table>
</div>
</div>
<div class="mt-3 flex justify-end">
<button onclick="exportCSVWithCustomPrice()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
Экспорт CSV со скидкой
</button>
</div>
</div>
</div>
</div>
<!-- Sale price section -->
<div id="sale-price-section" class="bg-white rounded-lg shadow overflow-hidden">
<button type="button"
onclick="toggleSalePriceSection()"
class="w-full px-4 py-3 flex items-center justify-between text-blue-900 bg-gradient-to-r from-blue-100 to-blue-50 hover:from-blue-200 hover:to-blue-100 border-b border-blue-200">
<span class="font-semibold">Цена продажи</span>
<svg id="sale-price-toggle-icon" class="w-5 h-5 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
</svg>
</button>
<div id="sale-price-content" class="p-4">
<div id="sale-prices" class="border-t pt-3">
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50">
<tr>
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Артикул</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Кол-во</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Est. Price</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Склад</th>
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Конкуренты</th>
</tr>
</thead>
<tbody id="sale-prices-body" class="divide-y"></tbody>
<tfoot class="bg-gray-50 font-medium">
<tr>
<td class="px-3 py-2">Итого</td>
<td class="px-3 py-2 text-right"></td>
<td class="px-3 py-2 text-right" id="sale-total-est">$0.00</td>
<td class="px-3 py-2 text-right" id="sale-total-warehouse">$0.00</td>
<td class="px-3 py-2 text-right" id="sale-total-competitor">$0.00</td>
</tr>
</tfoot>
</table>
</div>
</div>
</div>
</div>
</div><!-- end top-section-estimate --> </div><!-- end top-section-estimate -->
@@ -259,20 +157,9 @@
<div class="mb-3"> <div class="mb-3">
<p class="text-sm font-medium text-gray-700 mb-2">Вставьте таблицу из Excel (Ctrl+V в область ниже)</p> <p class="text-sm font-medium text-gray-700 mb-2">Вставьте таблицу из Excel (Ctrl+V в область ниже)</p>
<div class="bg-gray-50 border border-gray-200 rounded p-3 text-xs text-gray-500 space-y-1"> <div class="bg-gray-50 border border-gray-200 rounded p-3 text-xs text-gray-500 space-y-1">
<p class="font-medium text-gray-600">Колонки строго по порядку:</p> <p class="font-medium text-gray-600">Колонки определяются автоматически по содержимому. Обязательны: PN и Кол-во. Лишние колонки (секция, код и т.п.) игнорируются.</p>
<p> <p>Цена поддерживает форматы: <span class="font-mono">$5114,00</span> · <span class="font-mono">5 114.00</span> · <span class="font-mono">5114</span></p>
<span class="font-mono bg-white border border-gray-200 rounded px-1">PN</span> <p class="text-gray-400">Строка-заголовок пропускается автоматически.</p>
<span class="font-mono bg-white border border-gray-200 rounded px-1">Кол-во</span>
<span class="text-gray-400 italic"> — минимальный</span>
</p>
<p>
<span class="font-mono bg-white border border-gray-200 rounded px-1">PN</span>
<span class="font-mono bg-white border border-gray-200 rounded px-1">Кол-во</span>
<span class="font-mono bg-white border border-gray-200 rounded px-1">Описание</span>
<span class="font-mono bg-white border border-gray-200 rounded px-1">Цена ед.</span>
<span class="text-gray-400 italic"> — полный</span>
</p>
<p class="text-gray-400">Строка-заголовок пропускается автоматически если во 2-й колонке не число.</p>
</div> </div>
</div> </div>
<div id="bom-paste-area" <div id="bom-paste-area"
@@ -327,23 +214,24 @@
<table class="w-full text-sm border-collapse"> <table class="w-full text-sm border-collapse">
<thead class="bg-gray-50 text-gray-700"> <thead class="bg-gray-50 text-gray-700">
<tr> <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-left border-b">LOT</th>
<th class="px-3 py-2 text-left border-b">PN вендора</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-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">Estimate</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">Склад</th>
<th class="px-3 py-2 text-right border-b">Конк.</th> <th class="px-3 py-2 text-right border-b">Конк.</th>
</tr> </tr>
</thead> </thead>
<tbody id="pricing-table-body"> <tbody id="pricing-table-body">
<tr><td colspan="7" class="px-3 py-8 text-center text-gray-400">Загрузите BOM вендора во вкладке «BOM вендора»</td></tr> <tr><td colspan="8" class="px-3 py-8 text-center text-gray-400">Загрузите BOM вендора во вкладке «BOM вендора»</td></tr>
</tbody> </tbody>
<tfoot id="pricing-table-foot" class="hidden bg-gray-50 font-semibold"> <tfoot id="pricing-table-foot" class="hidden bg-gray-50 font-semibold">
<tr> <tr>
<td colspan="3" class="px-3 py-2 text-right">Итого:</td> <td colspan="4" 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-estimate"></td>
<td class="px-3 py-2 text-right font-bold" id="pricing-total-vendor"></td>
<td class="px-3 py-2 text-right" id="pricing-total-warehouse"></td> <td class="px-3 py-2 text-right" id="pricing-total-warehouse"></td>
<td class="px-3 py-2 text-right"></td> <td class="px-3 py-2 text-right"></td>
</tr> </tr>
@@ -356,7 +244,10 @@
class="w-40 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500" class="w-40 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
oninput="onPricingCustomPriceInput()"> 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 onclick="setPricingCustomPriceFromVendor()" class="px-3 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 border border-gray-300 text-sm">
= Сумма цен вендора Проставить цены BOM
</button>
<button onclick="exportPricingCSV()" class="px-3 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm">
Экспорт CSV
</button> </button>
<span id="pricing-discount-info" class="text-sm text-gray-500 hidden"> <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> Скидка от Estimate: <span id="pricing-discount-pct" class="font-semibold text-green-600"></span>
@@ -936,11 +827,19 @@ async function loadAllComponents() {
const resp = await fetch('/api/components?per_page=5000'); const resp = await fetch('/api/components?per_page=5000');
const data = await resp.json(); const data = await resp.json();
allComponents = data.components || []; allComponents = data.components || [];
window._bomAllComponents = allComponents;
} catch(e) { } catch(e) {
console.error('Failed to load components', e); console.error('Failed to load components', e);
allComponents = []; allComponents = [];
window._bomAllComponents = [];
} }
} }
function _bomLots() {
return [...new Set((window._bomAllComponents || allComponents).map(c => c.lot_name).filter(Boolean))].sort();
}
function _bomLotValid(v) {
return (window._bomAllComponents || allComponents).some(c => c.lot_name === v);
}
function updateServerCount() { function updateServerCount() {
const serverCountInput = document.getElementById('server-count'); const serverCountInput = document.getElementById('server-count');
@@ -2658,36 +2557,97 @@ function switchTopTab(tab) {
let bomRows = []; // [{vendor_pn, quantity, description, unit_price, total_price, resolved_lot, resolution_source}] let bomRows = []; // [{vendor_pn, quantity, description, unit_price, total_price, resolved_lot, resolution_source}]
// Parse a price string handling $, spaces, comma-as-decimal and comma-as-thousands.
function parsePastePrice(s) {
if (!s) return null;
let v = s.replace(/[$\s]/g, '');
// Determine decimal separator: if ends with ",dd" treat comma as decimal
if (/,\d{1,2}$/.test(v)) {
v = v.replace(/\./g, '').replace(',', '.');
} else {
v = v.replace(/,/g, '');
}
const n = parseFloat(v);
return isNaN(n) ? null : n;
}
// Auto-detect which column index serves as PN, qty, description, price.
function detectBOMColumns(rows) {
const ncols = Math.max(...rows.map(r => r.length));
let qtyCol = -1, pnCol = -1, descCol = -1, priceCol = -1;
// Price: last column where ≥70% of values parse as non-null price
for (let c = ncols - 1; c >= 0; c--) {
const hits = rows.filter(r => parsePastePrice(r[c] || '') !== null).length;
if (hits >= rows.length * 0.7) { priceCol = c; break; }
}
// Qty: first column (before price) where all values are integers 1..9999
for (let c = 0; c < ncols; c++) {
if (c === priceCol) continue;
const allInt = rows.every(r => /^\d{1,4}$/.test((r[c] || '').trim()));
if (allInt) { qtyCol = c; break; }
}
// PN: last column before qty that contains no spaces and looks like a product code
if (qtyCol > 0) {
for (let c = qtyCol - 1; c >= 0; c--) {
const looksPN = rows.every(r => {
const v = (r[c] || '').trim();
return v.length > 0 && v.length <= 60 && !/\s{2,}/.test(v);
});
if (looksPN) { pnCol = c; break; }
}
}
if (pnCol === -1) pnCol = 0;
// Description: column after qty with longest average text (excluding price col)
let bestLen = -1;
for (let c = (qtyCol !== -1 ? qtyCol + 1 : 1); c < ncols; c++) {
if (c === priceCol) continue;
const avg = rows.reduce((s, r) => s + (r[c] || '').length, 0) / rows.length;
if (avg > bestLen) { bestLen = avg; descCol = c; }
}
return { pnCol, qtyCol, descCol, priceCol };
}
function handleBOMPaste(event) { function handleBOMPaste(event) {
event.preventDefault(); event.preventDefault();
const text = event.clipboardData.getData('text/plain'); const text = event.clipboardData.getData('text/plain');
if (!text) return; if (!text) return;
const lines = text.trim().split(/\r?\n/).filter(l => l.trim()); const lines = text.trim().split(/\r?\n/).filter(l => l.trim());
if (!lines.length) return;
// Split all rows
let rows = lines.map(l => l.split('\t').map(c => c.trim()));
// Skip header row: if qty column candidate on row 0 is not a number
// We detect columns on all rows first (without header), then recheck row 0
const { pnCol, qtyCol, descCol, priceCol } = detectBOMColumns(rows);
// Drop header if row[0][qtyCol] is not numeric
if (qtyCol !== -1 && rows.length > 1 && !/^\d+$/.test((rows[0][qtyCol] || '').trim())) {
rows = rows.slice(1);
}
const parsed = []; const parsed = [];
for (const cols of rows) {
const pn = pnCol !== -1 ? (cols[pnCol] || '').trim() : '';
if (!pn) continue;
// Fixed positional format: PN | qty | [description] | [price] const rawQty = qtyCol !== -1 ? (cols[qtyCol] || '').trim() : '1';
// Skip header: if col[1] is not numeric on the first row → skip that row
for (let i = 0; i < lines.length; i++) {
const cols = lines[i].split('\t').map(c => c.trim());
if (cols.length < 2) continue;
const pn = cols[0];
const rawQty = cols[1].replace(/[, ]/g, '');
// Skip header row
if (i === 0 && isNaN(parseInt(rawQty))) continue;
const qty = parseInt(rawQty) || 1; const qty = parseInt(rawQty) || 1;
const description = cols.length >= 3 ? cols[2] : '';
const description = descCol !== -1 ? (cols[descCol] || '').trim() : '';
let unit_price = null, total_price = null; let unit_price = null, total_price = null;
if (cols.length >= 4) { if (priceCol !== -1) {
const rawPrice = cols[3].replace(/[, ]/g, ''); unit_price = parsePastePrice(cols[priceCol] || '');
unit_price = parseFloat(rawPrice) || null; if (unit_price !== null) total_price = unit_price * qty;
if (unit_price) total_price = unit_price * qty;
} }
if (!pn) continue;
parsed.push({ parsed.push({
sort_order: (parsed.length + 1) * 10, sort_order: (parsed.length + 1) * 10,
vendor_pn: pn, vendor_pn: pn,
@@ -2739,6 +2699,18 @@ async function resolveBOM() {
} }
}); });
} }
// Push unresolved PNs to server partnumber_seen registry (fire-and-forget)
const unseen = bomRows
.filter(r => !r.resolved_lot || r.resolution_source === 'unresolved')
.map(r => ({ partnumber: r.vendor_pn, description: r.description || '' }));
if (unseen.length) {
fetch('/api/sync/partnumber-seen', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({items: unseen})
}).catch(() => {});
}
} catch (e) { } catch (e) {
console.warn('Resolution failed:', e); console.warn('Resolution failed:', e);
} }
@@ -2747,6 +2719,11 @@ async function resolveBOM() {
} }
function renderBOMTable() { function renderBOMTable() {
// Rebuild datalist for LOT autocomplete from current allComponents
let dl = document.getElementById('lot-autocomplete-list');
if (!dl) { dl = document.createElement('datalist'); dl.id = 'lot-autocomplete-list'; document.body.appendChild(dl); }
dl.innerHTML = _bomLots().map(l => `<option value="${escapeHtml(l)}">`).join('');
const tbody = document.getElementById('bom-table-body'); const tbody = document.getElementById('bom-table-body');
const cart = window._currentCart || []; const cart = window._currentCart || [];
@@ -2776,9 +2753,10 @@ function renderBOMTable() {
let lotCell = ''; let lotCell = '';
if (isUnresolved) { if (isUnresolved) {
lotCell = `<input type="text" placeholder="Введите LOT..." value="${row.manual_lot || ''}" lotCell = `<input type="text" placeholder="Введите LOT..." value="${escapeHtml(row.manual_lot || '')}"
class="w-full px-2 py-1 border rounded text-sm focus:ring-1 focus:ring-blue-400" 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();" oninput="bomRows[${idx}].manual_lot = this.value;"
onchange="if(_bomLotValid(this.value)){bomRows[${idx}].manual_lot=this.value;resolveBOM();}else{this.value=bomRows[${idx}].manual_lot||'';}"
list="lot-autocomplete-list">`; list="lot-autocomplete-list">`;
} else { } else {
let suffix = ''; let suffix = '';
@@ -2919,45 +2897,114 @@ async function loadVendorSpec(configUUID) {
// ==================== ЦЕНООБРАЗОВАНИЕ ==================== // ==================== ЦЕНООБРАЗОВАНИЕ ====================
function renderPricingTab() { async function renderPricingTab() {
const tbody = document.getElementById('pricing-table-body'); const tbody = document.getElementById('pricing-table-body');
const tfoot = document.getElementById('pricing-table-foot'); const tfoot = document.getElementById('pricing-table-foot');
if (!bomRows.length) { const cart = window._currentCart || [];
tbody.innerHTML = '<tr><td colspan="7" class="px-3 py-8 text-center text-gray-400">Загрузите BOM вендора во вкладке «BOM вендора»</td></tr>'; const compMap = {};
tfoot.classList.add('hidden'); (window._bomAllComponents || allComponents).forEach(c => { compMap[c.lot_name] = c; });
return;
// Collect LOTs to price: from BOM rows (resolved) or from cart
let itemsForPriceLevels = [];
if (bomRows.length) {
const seen = new Set();
bomRows.forEach(row => {
const lot = row.resolved_lot;
if (lot && row.resolution_source !== 'unresolved' && !seen.has(lot)) {
seen.add(lot);
itemsForPriceLevels.push({ lot_name: lot, quantity: row.quantity });
}
});
} else {
itemsForPriceLevels = cart.map(item => ({ lot_name: item.lot_name, quantity: item.quantity }));
} }
const cart = window._currentCart || []; // Fetch fresh price levels for these LOTs
const cartMap = {}; const priceMap = {}; // lot_name → {estimate_price, ...}
cart.forEach(item => { cartMap[item.lot_name] = item; }); if (itemsForPriceLevels.length) {
try {
const payload = {
items: itemsForPriceLevels,
pricelist_ids: Object.fromEntries(
Object.entries(selectedPricelistIds)
.filter(([, id]) => typeof id === 'number' && id > 0)
)
};
const resp = await fetch('/api/quote/price-levels', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(payload)
});
if (resp.ok) {
const data = await resp.json();
(data.items || []).forEach(i => { priceMap[i.lot_name] = i; });
}
} catch(e) { /* silent — pricing tab renders with available data */ }
}
let totalVendor = 0, totalEstimate = 0, totalWarehouse = 0; let totalVendor = 0, totalEstimate = 0;
let hasVendor = false, hasEstimate = false, hasWarehouse = false; let hasVendor = false, hasEstimate = false;
tbody.innerHTML = ''; 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 (!bomRows.length) {
if (vendorTotal != null) { totalVendor += vendorTotal; hasVendor = true; } if (!cart.length) {
if (estimatePrice != null) { totalEstimate += estimatePrice * row.quantity; hasEstimate = true; } tbody.innerHTML = '<tr><td colspan="9" class="px-3 py-8 text-center text-gray-400">Нет данных для отображения</td></tr>';
tfoot.classList.add('hidden');
return;
}
cart.forEach(item => {
const tr = document.createElement('tr');
tr.classList.add('pricing-row');
const pl = priceMap[item.lot_name];
const estUnit = (pl && pl.estimate_price > 0) ? pl.estimate_price : (item.unit_price || 0);
const estimateTotal = estUnit * item.quantity;
if (estimateTotal > 0) { totalEstimate += estimateTotal; hasEstimate = true; }
tr.dataset.est = estimateTotal;
const desc = (compMap[item.lot_name] || {}).description || '';
tr.dataset.vendorOrig = '';
tr.innerHTML = `
<td class="px-3 py-1.5 text-xs">${escapeHtml(item.lot_name)}</td>
<td class="px-3 py-1.5 text-xs text-gray-400">—</td>
<td class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs">${escapeHtml(desc)}</td>
<td class="px-3 py-1.5 text-right">${item.quantity}</td>
<td class="px-3 py-1.5 text-right text-xs">${estimateTotal > 0 ? formatCurrency(estimateTotal) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs text-gray-400 pricing-vendor-price">—</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);
});
} else {
bomRows.forEach(row => {
const tr = document.createElement('tr');
tr.classList.add('pricing-row');
const isUnresolved = !row.resolved_lot || row.resolution_source === 'unresolved';
const pl = row.resolved_lot ? priceMap[row.resolved_lot] : null;
const estimateUnit = (pl && pl.estimate_price > 0) ? pl.estimate_price : null;
const rowEst = estimateUnit != null ? estimateUnit * row.quantity : 0;
tr.innerHTML = ` const vendorTotal = row.total_price != null ? row.total_price : (row.unit_price != null ? row.unit_price * row.quantity : null);
<td class="px-3 py-1.5 font-mono text-xs">${escapeHtml(row.vendor_pn)}</td> if (vendorTotal != null) { totalVendor += vendorTotal; hasVendor = true; }
<td class="px-3 py-1.5 text-xs">${isUnresolved ? '<span class="text-red-500">н/д</span>' : escapeHtml(row.resolved_lot)}</td> if (estimateUnit != null) { totalEstimate += rowEst; hasEstimate = true; }
<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> tr.dataset.est = rowEst;
<td class="px-3 py-1.5 text-right text-xs">${estimatePrice != null ? formatCurrency(estimatePrice * row.quantity) : ''}</td> tr.dataset.vendorOrig = vendorTotal != null ? vendorTotal : '';
<td class="px-3 py-1.5 text-right text-xs text-gray-400">—</td> const desc = row.description || (row.resolved_lot ? ((compMap[row.resolved_lot] || {}).description || '') : '');
<td class="px-3 py-1.5 text-right text-xs text-gray-400">—</td> tr.innerHTML = `
`; <td class="px-3 py-1.5 text-xs">${isUnresolved ? '<span class="text-red-500">н/д</span>' : escapeHtml(row.resolved_lot)}</td>
tbody.appendChild(tr); <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 text-gray-500 truncate max-w-xs">${escapeHtml(desc)}</td>
<td class="px-3 py-1.5 text-right">${row.quantity}</td>
<td class="px-3 py-1.5 text-right text-xs">${estimateUnit != null ? formatCurrency(rowEst) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs pricing-vendor-price">${vendorTotal != null ? formatCurrency(vendorTotal) : '—'}</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 // Totals row
document.getElementById('pricing-total-vendor').textContent = hasVendor ? formatCurrency(totalVendor) : '—'; document.getElementById('pricing-total-vendor').textContent = hasVendor ? formatCurrency(totalVendor) : '—';
@@ -2965,30 +3012,104 @@ function renderPricingTab() {
document.getElementById('pricing-total-warehouse').textContent = '—'; document.getElementById('pricing-total-warehouse').textContent = '—';
tfoot.classList.remove('hidden'); tfoot.classList.remove('hidden');
// Update custom price discount info // Update custom price proportional breakdown
onPricingCustomPriceInput(); onPricingCustomPriceInput();
} }
function setPricingCustomPriceFromVendor() { function setPricingCustomPriceFromVendor() {
let totalVendor = 0; // Apply per-row BOM prices directly (not proportional redistribution)
bomRows.forEach(r => { const rows = document.querySelectorAll('#pricing-table-body tr.pricing-row');
const vt = r.total_price != null ? r.total_price : (r.unit_price != null ? r.unit_price * r.quantity : 0); const vendorCells = document.querySelectorAll('#pricing-table-body .pricing-vendor-price');
totalVendor += vt; let total = 0;
let hasAny = false;
rows.forEach((tr, i) => {
const cell = vendorCells[i];
if (!cell) return;
const orig = tr.dataset.vendorOrig;
if (orig !== '') {
const v = parseFloat(orig);
cell.textContent = formatCurrency(v);
cell.classList.remove('text-blue-700', 'text-gray-400');
total += v;
hasAny = true;
} else {
cell.textContent = '—';
cell.classList.add('text-gray-400');
cell.classList.remove('text-blue-700');
}
}); });
document.getElementById('pricing-custom-price').value = totalVendor.toFixed(2);
onPricingCustomPriceInput(); document.getElementById('pricing-total-vendor').textContent = hasAny ? formatCurrency(total) : '—';
document.getElementById('pricing-custom-price').value = hasAny ? total.toFixed(2) : '';
// Update discount info only
const rows2 = document.querySelectorAll('#pricing-table-body tr.pricing-row');
let estimateTotal = 0;
rows2.forEach(tr => { estimateTotal += parseFloat(tr.dataset.est) || 0; });
const discountEl = document.getElementById('pricing-discount-info');
const pctEl = document.getElementById('pricing-discount-pct');
if (hasAny && total > 0 && estimateTotal > 0) {
pctEl.textContent = ((estimateTotal - total) / estimateTotal * 100).toFixed(1) + '%';
discountEl.classList.remove('hidden');
} else {
discountEl.classList.add('hidden');
}
} }
function onPricingCustomPriceInput() { function onPricingCustomPriceInput() {
const customPrice = parseFloat(document.getElementById('pricing-custom-price').value) || 0; 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 rows = document.querySelectorAll('#pricing-table-body tr.pricing-row');
let estimateTotal = 0;
rows.forEach(tr => { estimateTotal += parseFloat(tr.dataset.est) || 0; });
const vendorCells = document.querySelectorAll('#pricing-table-body .pricing-vendor-price');
const totalVendorEl = document.getElementById('pricing-total-vendor');
if (customPrice > 0 && estimateTotal > 0) {
// Proportionally redistribute custom price → Цена вендора cells
let assigned = 0;
rows.forEach((tr, i) => {
const est = parseFloat(tr.dataset.est) || 0;
const cell = vendorCells[i];
if (!cell) return;
let share;
if (i === rows.length - 1) {
share = customPrice - assigned;
} else {
share = Math.round((est / estimateTotal) * customPrice * 100) / 100;
assigned += share;
}
cell.textContent = formatCurrency(share);
cell.classList.add('text-blue-700');
cell.classList.remove('text-gray-400');
});
totalVendorEl.textContent = formatCurrency(customPrice);
} else {
// Restore original vendor prices from BOM
rows.forEach((tr, i) => {
const cell = vendorCells[i];
if (!cell) return;
const orig = tr.dataset.vendorOrig;
if (orig !== '') {
cell.textContent = formatCurrency(parseFloat(orig));
cell.classList.remove('text-blue-700', 'text-gray-400');
} else {
cell.textContent = '—';
cell.classList.add('text-gray-400');
cell.classList.remove('text-blue-700');
}
});
// Recompute vendor total from originals
let origTotal = 0; let hasOrig = false;
rows.forEach(tr => { if (tr.dataset.vendorOrig !== '') { origTotal += parseFloat(tr.dataset.vendorOrig) || 0; hasOrig = true; } });
totalVendorEl.textContent = hasOrig ? formatCurrency(origTotal) : '—';
}
// Discount info
const discountEl = document.getElementById('pricing-discount-info'); const discountEl = document.getElementById('pricing-discount-info');
const pctEl = document.getElementById('pricing-discount-pct'); const pctEl = document.getElementById('pricing-discount-pct');
if (customPrice > 0 && estimateTotal > 0) { if (customPrice > 0 && estimateTotal > 0) {
const discount = ((estimateTotal - customPrice) / estimateTotal * 100).toFixed(1); const discount = ((estimateTotal - customPrice) / estimateTotal * 100).toFixed(1);
pctEl.textContent = discount + '%'; pctEl.textContent = discount + '%';
@@ -2998,6 +3119,39 @@ function onPricingCustomPriceInput() {
} }
} }
function exportPricingCSV() {
const rows = document.querySelectorAll('#pricing-table-body tr.pricing-row');
if (!rows.length) { showToast('Нет данных для экспорта', 'error'); return; }
const csvEscape = v => {
if (v == null) return '';
const s = String(v).replace(/"/g, '""');
return /[,"\n]/.test(s) ? `"${s}"` : s;
};
const headers = ['LOT', 'PN вендора', 'Описание', 'Кол-во', 'Estimate', 'Цена вендора', 'Склад', 'Конкуренты'];
const lines = [headers.map(csvEscape).join(',')];
rows.forEach(tr => {
const cells = tr.querySelectorAll('td');
const rowData = Array.from(cells).map(td => td.textContent.trim());
lines.push(rowData.map(csvEscape).join(','));
});
// Totals row
const estTotal = document.getElementById('pricing-total-estimate').textContent.trim();
const vendorTotal = document.getElementById('pricing-total-vendor').textContent.trim();
lines.push(['', '', '', 'Итого', estTotal, vendorTotal, '', ''].map(csvEscape).join(','));
const blob = new Blob(['\uFEFF' + lines.join('\r\n')], {type: 'text/csv;charset=utf-8;'});
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `pricing_${configUUID || 'export'}.csv`;
a.click();
URL.revokeObjectURL(url);
}
function escapeHtml(str) { function escapeHtml(str) {
if (!str) return ''; if (!str) return '';
return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;'); return str.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;').replace(/"/g,'&quot;');
@@ -3008,27 +3162,6 @@ function formatCurrency(val) {
return val.toLocaleString('ru-RU', {minimumFractionDigits: 2, maximumFractionDigits: 2}); 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> </script>
{{end}} {{end}}

View File

@@ -2,12 +2,7 @@
{{define "content"}} {{define "content"}}
<div class="space-y-4"> <div class="space-y-4">
<div class="flex items-center justify-between"> <h1 class="text-2xl font-bold text-gray-900">Партномера</h1>
<h1 class="text-2xl font-bold text-gray-900">Партномера</h1>
<button onclick="syncPartnumberBooks()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700 text-sm">
Синхронизировать
</button>
</div>
<!-- Summary cards --> <!-- Summary cards -->
<div id="summary-cards" class="grid grid-cols-2 md:grid-cols-4 gap-4 hidden"> <div id="summary-cards" class="grid grid-cols-2 md:grid-cols-4 gap-4 hidden">
@@ -59,50 +54,96 @@
</div> </div>
<!-- All books list (collapsed by default) --> <!-- All books list (collapsed by default) -->
<details class="bg-white rounded-lg shadow overflow-hidden"> <div class="bg-white rounded-lg shadow overflow-hidden">
<summary class="px-4 py-3 cursor-pointer text-sm font-medium text-gray-700 hover:bg-gray-50 select-none"> <!-- Header row — always visible -->
История снимков <div class="px-4 py-3 flex items-center justify-between">
</summary> <button onclick="toggleBooksSection()" class="flex items-center gap-2 text-sm font-semibold text-gray-800 hover:text-gray-600 select-none">
<div id="books-list-loading" class="p-6 text-center text-gray-400 text-sm">Загрузка...</div> <svg id="books-chevron" class="w-4 h-4 text-gray-400 transition-transform duration-150" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
<table id="books-table" class="w-full text-sm hidden"> Снимки сопоставлений (Partnumber Books)
<thead class="bg-gray-50 text-gray-600"> </button>
<tr> <button onclick="syncPartnumberBooks()" class="px-4 py-2 bg-orange-500 text-white rounded hover:bg-orange-600 text-sm font-medium">
<th class="px-4 py-2 text-left">Версия</th> Синхронизировать
<th class="px-4 py-2 text-left">Дата</th> </button>
<th class="px-4 py-2 text-right">Позиций</th>
<th class="px-4 py-2 text-center">Статус</th>
</tr>
</thead>
<tbody id="books-table-body"></tbody>
</table>
<div id="books-empty" class="hidden p-6 text-center text-gray-400 text-sm">
Нет загруженных снимков.
</div> </div>
</details> <!-- Collapsible body -->
<div id="books-section-body" class="hidden border-t">
<div id="books-list-loading" class="p-6 text-center text-gray-400 text-sm">Загрузка...</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-2 text-left">Версия</th>
<th class="px-4 py-2 text-left">Дата</th>
<th class="px-4 py-2 text-right">Позиций</th>
<th class="px-4 py-2 text-center">Статус</th>
</tr>
</thead>
<tbody id="books-table-body"></tbody>
</table>
<div id="books-empty" class="hidden p-6 text-center text-gray-400 text-sm">
Нет загруженных снимков.
</div>
<!-- Pagination -->
<div id="books-pagination" class="hidden px-4 py-3 border-t flex items-center justify-between text-sm text-gray-600">
<span id="books-page-info"></span>
<div class="flex gap-2">
<button id="books-prev" onclick="changeBooksPage(-1)" class="px-3 py-1 rounded border hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed">← Назад</button>
<button id="books-next" onclick="changeBooksPage(1)" class="px-3 py-1 rounded border hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed">Вперёд →</button>
</div>
</div>
</div>
</div>
</div> </div>
{{end}}
{{define "scripts"}}
<script> <script>
let allItems = []; let allItems = [];
let allBooks = [];
let booksPage = 1;
const BOOKS_PER_PAGE = 10;
function toggleBooksSection() {
const body = document.getElementById('books-section-body');
const chevron = document.getElementById('books-chevron');
const collapsed = body.classList.toggle('hidden');
chevron.style.transform = collapsed ? '' : 'rotate(90deg)';
}
async function loadBooks() { async function loadBooks() {
const resp = await fetch('/api/partnumber-books'); let resp, data;
const data = await resp.json(); try {
const books = data.books || []; resp = await fetch('/api/partnumber-books');
data = await resp.json();
} catch (e) {
document.getElementById('books-list-loading').classList.add('hidden');
document.getElementById('books-empty').classList.remove('hidden');
return;
}
allBooks = data.books || [];
document.getElementById('books-list-loading').classList.add('hidden'); document.getElementById('books-list-loading').classList.add('hidden');
if (!books.length) { if (!allBooks.length) {
document.getElementById('books-empty').classList.remove('hidden'); document.getElementById('books-empty').classList.remove('hidden');
document.getElementById('summary-empty').classList.remove('hidden'); document.getElementById('summary-empty').classList.remove('hidden');
return; return;
} }
// Fill history table booksPage = 1;
renderBooksPage();
const active = allBooks.find(b => b.is_active) || allBooks[0];
await loadActiveBookItems(active);
}
function renderBooksPage() {
const total = allBooks.length;
const totalPages = Math.ceil(total / BOOKS_PER_PAGE);
const start = (booksPage - 1) * BOOKS_PER_PAGE;
const pageBooks = allBooks.slice(start, start + BOOKS_PER_PAGE);
const tbody = document.getElementById('books-table-body'); const tbody = document.getElementById('books-table-body');
tbody.innerHTML = ''; tbody.innerHTML = '';
books.forEach(b => { pageBooks.forEach(b => {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.className = 'border-b hover:bg-gray-50'; tr.className = 'border-b hover:bg-gray-50';
tr.innerHTML = ` tr.innerHTML = `
@@ -119,17 +160,36 @@ async function loadBooks() {
}); });
document.getElementById('books-table').classList.remove('hidden'); document.getElementById('books-table').classList.remove('hidden');
// Load active book detail // Pagination controls
const active = books.find(b => b.is_active) || books[0]; if (total > BOOKS_PER_PAGE) {
await loadActiveBookItems(active); document.getElementById('books-pagination').classList.remove('hidden');
document.getElementById('books-page-info').textContent =
`Снимки ${start + 1}${Math.min(start + BOOKS_PER_PAGE, total)} из ${total}`;
document.getElementById('books-prev').disabled = booksPage === 1;
document.getElementById('books-next').disabled = booksPage === totalPages;
} else {
document.getElementById('books-pagination').classList.add('hidden');
}
}
function changeBooksPage(delta) {
const totalPages = Math.ceil(allBooks.length / BOOKS_PER_PAGE);
booksPage = Math.max(1, Math.min(totalPages, booksPage + delta));
renderBooksPage();
} }
async function loadActiveBookItems(book) { async function loadActiveBookItems(book) {
const resp = await fetch(`/api/partnumber-books/${book.server_id}`); let resp, data;
const data = await resp.json(); try {
resp = await fetch(`/api/partnumber-books/${book.server_id}`);
data = await resp.json();
} catch (e) {
return;
}
if (!resp.ok) return;
allItems = data.items || []; allItems = data.items || [];
// Compute stats
const lots = new Set(allItems.map(i => i.lot_name)); const lots = new Set(allItems.map(i => i.lot_name));
const primaryCount = allItems.filter(i => i.is_primary_pn).length; const primaryCount = allItems.filter(i => i.is_primary_pn).length;
@@ -170,17 +230,21 @@ function filterItems(query) {
} }
async function syncPartnumberBooks() { async function syncPartnumberBooks() {
let resp, data;
try { try {
const resp = await fetch('/api/sync/partnumber-books', {method: 'POST'}); resp = await fetch('/api/sync/partnumber-books', {method: 'POST'});
const data = await resp.json(); data = await resp.json();
if (data.success) {
showToast(`Синхронизировано: ${data.synced} листов`, 'success');
loadBooks();
} else {
showToast('Ошибка: ' + data.error, 'error');
}
} catch (e) { } catch (e) {
showToast('Ошибка синхронизации', 'error'); showToast('Ошибка синхронизации', 'error');
return;
}
if (data.success) {
showToast(`Синхронизировано: ${data.synced} листов`, 'success');
loadBooks();
} else if (data.blocked) {
showToast(`Синк заблокирован: ${data.reason_text}`, 'error');
} else {
showToast('Ошибка: ' + (data.error || 'неизвестная ошибка'), 'error');
} }
} }
@@ -189,3 +253,4 @@ document.addEventListener('DOMContentLoaded', loadBooks);
{{end}} {{end}}
{{template "base" .}} {{template "base" .}}