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:
@@ -105,6 +105,8 @@ Database: `RFQ_LOG`
|
||||
| `qt_client_local_migrations` | Migration catalog | SELECT only |
|
||||
| `qt_client_schema_state` | Applied migrations state | 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
|
||||
|
||||
@@ -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_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;
|
||||
```
|
||||
|
||||
@@ -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, 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 ON RFQ_LOG.qt_partnumber_books TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO 'quote_user'@'%';
|
||||
|
||||
FLUSH PRIVILEGES;
|
||||
SHOW GRANTS FOR 'quote_user'@'%';
|
||||
|
||||
@@ -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`.
|
||||
|
||||
### 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
|
||||
|
||||
| Method | Endpoint | Purpose |
|
||||
@@ -114,6 +133,7 @@
|
||||
| `/projects/:uuid` | Project details |
|
||||
| `/pricelists` | Pricelist list |
|
||||
| `/pricelists/:id` | Pricelist details |
|
||||
| `/partnumber-books` | Partnumber books (active book summary + snapshot history) |
|
||||
| `/setup` | Connection settings |
|
||||
|
||||
---
|
||||
|
||||
@@ -34,7 +34,7 @@ make help # All available commands
|
||||
## Code Style
|
||||
|
||||
- **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)`)
|
||||
- **Style:** no unnecessary abstractions; minimum code for the task
|
||||
|
||||
|
||||
@@ -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).
|
||||
|
||||
---
|
||||
|
||||
## Architecture
|
||||
|
||||
### Storage
|
||||
|
||||
| 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 |
|
||||
|
||||
`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"`.
|
||||
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.
|
||||
### SQLite (local mirror)
|
||||
|
||||
---
|
||||
```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
|
||||
|
||||
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
|
||||
CREATE TABLE local_partnumber_book_items (
|
||||
id INTEGER PRIMARY KEY AUTOINCREMENT,
|
||||
book_id INTEGER NOT NULL, -- FK → local_partnumber_books.id
|
||||
partnumber TEXT NOT NULL,
|
||||
lot_name TEXT NOT NULL,
|
||||
is_primary_pn INTEGER NOT NULL DEFAULT 0,
|
||||
description TEXT
|
||||
);
|
||||
CREATE INDEX idx_local_book_pn ON local_partnumber_book_items(book_id, partnumber);
|
||||
```
|
||||
|
||||
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)
|
||||
**Active book query:** `WHERE is_active=1 ORDER BY created_at DESC, id DESC LIMIT 1`
|
||||
|
||||
---
|
||||
**Schema creation:** GORM AutoMigrate (not `runLocalMigrations`).
|
||||
|
||||
## 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)
|
||||
### MariaDB (managed exclusively by PriceForge)
|
||||
|
||||
```sql
|
||||
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;
|
||||
```
|
||||
|
||||
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 |
|
||||
|
||||
@@ -1762,6 +1762,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
syncAPI.POST("/components", syncHandler.SyncComponents)
|
||||
syncAPI.POST("/pricelists", syncHandler.SyncPricelists)
|
||||
syncAPI.POST("/partnumber-books", syncHandler.SyncPartnumberBooks)
|
||||
syncAPI.POST("/partnumber-seen", syncHandler.ReportPartnumberSeen)
|
||||
syncAPI.POST("/all", syncHandler.SyncAll)
|
||||
syncAPI.POST("/push", syncHandler.PushPendingChanges)
|
||||
syncAPI.GET("/pending/count", syncHandler.GetPendingCount)
|
||||
|
||||
@@ -679,3 +679,35 @@ func (h *SyncHandler) getReadinessCached(maxAge time.Duration) *sync.SyncReadine
|
||||
h.readinessMu.Unlock()
|
||||
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)})
|
||||
}
|
||||
|
||||
@@ -2,10 +2,10 @@ package handlers
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"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"
|
||||
@@ -20,14 +20,23 @@ func NewVendorSpecHandler(localDB *localdb.LocalDB) *VendorSpecHandler {
|
||||
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.
|
||||
// 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 {
|
||||
cfg, err := h.lookupConfig(c.Param("uuid"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
|
||||
return
|
||||
}
|
||||
@@ -42,8 +51,11 @@ func (h *VendorSpecHandler) GetVendorSpec(c *gin.Context) {
|
||||
// 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)
|
||||
cfg, err := h.lookupConfig(c.Param("uuid"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
|
||||
@@ -53,13 +65,6 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
|
||||
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
|
||||
@@ -67,7 +72,12 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
|
||||
}
|
||||
|
||||
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()})
|
||||
return
|
||||
}
|
||||
@@ -78,11 +88,7 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
|
||||
// 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 {
|
||||
if _, err := h.lookupConfig(c.Param("uuid")); err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
|
||||
return
|
||||
}
|
||||
@@ -104,7 +110,6 @@ func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Also compute aggregated LOTs
|
||||
book, _ := bookRepo.GetActiveBook()
|
||||
aggregated, err := services.AggregateLOTs(resolved, book, bookRepo)
|
||||
if err != nil {
|
||||
@@ -121,8 +126,11 @@ func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
|
||||
// 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)
|
||||
cfg, err := h.lookupConfig(c.Param("uuid"))
|
||||
if err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
|
||||
return
|
||||
}
|
||||
|
||||
var body struct {
|
||||
Items []struct {
|
||||
@@ -136,12 +144,6 @@ func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) {
|
||||
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{
|
||||
@@ -157,7 +159,7 @@ func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) {
|
||||
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()})
|
||||
return
|
||||
}
|
||||
|
||||
@@ -7,10 +7,11 @@ import (
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// 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) {
|
||||
slog.Info("starting partnumber book pull")
|
||||
|
||||
@@ -21,7 +22,6 @@ func (s *Service) PullPartnumberBooks() (int, error) {
|
||||
|
||||
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"`
|
||||
@@ -29,21 +29,36 @@ func (s *Service) PullPartnumberBooks() (int, error) {
|
||||
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 {
|
||||
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)
|
||||
}
|
||||
slog.Info("partnumber books found on server", "count", len(serverBooks))
|
||||
|
||||
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
|
||||
// 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
|
||||
}
|
||||
|
||||
// Save the book
|
||||
slog.Info("pulling new partnumber book", "server_id", sb.ID, "version", sb.Version, "is_active", sb.IsActive)
|
||||
|
||||
localBook := &localdb.LocalPartnumberBook{
|
||||
ServerID: sb.ID,
|
||||
Version: sb.Version,
|
||||
@@ -51,42 +66,62 @@ func (s *Service) PullPartnumberBooks() (int, error) {
|
||||
IsActive: sb.IsActive,
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
// 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)
|
||||
n, err := pullBookItems(mariaDB, localBookRepo, sb.ID, localBook.ID)
|
||||
if err != nil {
|
||||
slog.Error("failed to pull items for new book", "server_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))
|
||||
slog.Info("partnumber book saved locally", "server_id", sb.ID, "version", sb.Version, "items_saved", n)
|
||||
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
|
||||
}
|
||||
|
||||
// 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
|
||||
}
|
||||
|
||||
49
internal/services/sync/partnumber_seen.go
Normal file
49
internal/services/sync/partnumber_seen.go
Normal 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
|
||||
}
|
||||
@@ -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;
|
||||
@@ -143,113 +143,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom price section -->
|
||||
<div id="custom-price-section" class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<button type="button"
|
||||
onclick="toggleCustomPriceSection()"
|
||||
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="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>
|
||||
<!-- hidden inputs kept for JS compatibility -->
|
||||
<input type="hidden" id="custom-price-input" value="">
|
||||
<div id="adjusted-prices" class="hidden"></div>
|
||||
<div id="discount-info" class="hidden"></div>
|
||||
<div id="sale-prices" class="hidden"></div>
|
||||
|
||||
</div><!-- end top-section-estimate -->
|
||||
|
||||
@@ -259,20 +157,9 @@
|
||||
<div class="mb-3">
|
||||
<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">
|
||||
<p class="font-medium text-gray-600">Колонки строго по порядку:</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="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>
|
||||
<p class="font-medium text-gray-600">Колонки определяются автоматически по содержимому. Обязательны: PN и Кол-во. Лишние колонки (секция, код и т.п.) игнорируются.</p>
|
||||
<p>Цена поддерживает форматы: <span class="font-mono">$5114,00</span> · <span class="font-mono">5 114.00</span> · <span class="font-mono">5114</span></p>
|
||||
<p class="text-gray-400">Строка-заголовок пропускается автоматически.</p>
|
||||
</div>
|
||||
</div>
|
||||
<div id="bom-paste-area"
|
||||
@@ -327,23 +214,24 @@
|
||||
<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-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">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>
|
||||
</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>
|
||||
<tr><td colspan="8" 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 colspan="4" class="px-3 py-2 text-right">Итого:</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">—</td>
|
||||
</tr>
|
||||
@@ -356,7 +244,10 @@
|
||||
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">
|
||||
= Сумма цен вендора
|
||||
Проставить цены BOM
|
||||
</button>
|
||||
<button onclick="exportPricingCSV()" class="px-3 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm">
|
||||
Экспорт CSV
|
||||
</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>
|
||||
@@ -936,11 +827,19 @@ async function loadAllComponents() {
|
||||
const resp = await fetch('/api/components?per_page=5000');
|
||||
const data = await resp.json();
|
||||
allComponents = data.components || [];
|
||||
window._bomAllComponents = allComponents;
|
||||
} catch(e) {
|
||||
console.error('Failed to load components', e);
|
||||
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() {
|
||||
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}]
|
||||
|
||||
// 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) {
|
||||
event.preventDefault();
|
||||
const text = event.clipboardData.getData('text/plain');
|
||||
if (!text) return;
|
||||
|
||||
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 = [];
|
||||
for (const cols of rows) {
|
||||
const pn = pnCol !== -1 ? (cols[pnCol] || '').trim() : '';
|
||||
if (!pn) continue;
|
||||
|
||||
// Fixed positional format: PN | qty | [description] | [price]
|
||||
// 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 rawQty = qtyCol !== -1 ? (cols[qtyCol] || '').trim() : '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;
|
||||
if (cols.length >= 4) {
|
||||
const rawPrice = cols[3].replace(/[, ]/g, '');
|
||||
unit_price = parseFloat(rawPrice) || null;
|
||||
if (unit_price) total_price = unit_price * qty;
|
||||
if (priceCol !== -1) {
|
||||
unit_price = parsePastePrice(cols[priceCol] || '');
|
||||
if (unit_price !== null) total_price = unit_price * qty;
|
||||
}
|
||||
|
||||
if (!pn) continue;
|
||||
parsed.push({
|
||||
sort_order: (parsed.length + 1) * 10,
|
||||
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) {
|
||||
console.warn('Resolution failed:', e);
|
||||
}
|
||||
@@ -2747,6 +2719,11 @@ async function resolveBOM() {
|
||||
}
|
||||
|
||||
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 cart = window._currentCart || [];
|
||||
|
||||
@@ -2776,9 +2753,10 @@ function renderBOMTable() {
|
||||
|
||||
let lotCell = '';
|
||||
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"
|
||||
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">`;
|
||||
} else {
|
||||
let suffix = '';
|
||||
@@ -2919,45 +2897,114 @@ async function loadVendorSpec(configUUID) {
|
||||
|
||||
// ==================== ЦЕНООБРАЗОВАНИЕ ====================
|
||||
|
||||
function renderPricingTab() {
|
||||
async 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 compMap = {};
|
||||
(window._bomAllComponents || allComponents).forEach(c => { compMap[c.lot_name] = c; });
|
||||
|
||||
// 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 || [];
|
||||
const cartMap = {};
|
||||
cart.forEach(item => { cartMap[item.lot_name] = item; });
|
||||
// Fetch fresh price levels for these LOTs
|
||||
const priceMap = {}; // lot_name → {estimate_price, ...}
|
||||
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 hasVendor = false, hasEstimate = false, hasWarehouse = false;
|
||||
let totalVendor = 0, totalEstimate = 0;
|
||||
let hasVendor = false, hasEstimate = 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; }
|
||||
if (!bomRows.length) {
|
||||
if (!cart.length) {
|
||||
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 = `
|
||||
<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);
|
||||
});
|
||||
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 (estimateUnit != null) { totalEstimate += rowEst; hasEstimate = true; }
|
||||
|
||||
tr.dataset.est = rowEst;
|
||||
tr.dataset.vendorOrig = vendorTotal != null ? vendorTotal : '';
|
||||
const desc = row.description || (row.resolved_lot ? ((compMap[row.resolved_lot] || {}).description || '') : '');
|
||||
tr.innerHTML = `
|
||||
<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 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
|
||||
document.getElementById('pricing-total-vendor').textContent = hasVendor ? formatCurrency(totalVendor) : '—';
|
||||
@@ -2965,30 +3012,104 @@ function renderPricingTab() {
|
||||
document.getElementById('pricing-total-warehouse').textContent = '—';
|
||||
tfoot.classList.remove('hidden');
|
||||
|
||||
// Update custom price discount info
|
||||
// Update custom price proportional breakdown
|
||||
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;
|
||||
// Apply per-row BOM prices directly (not proportional redistribution)
|
||||
const rows = document.querySelectorAll('#pricing-table-body tr.pricing-row');
|
||||
const vendorCells = document.querySelectorAll('#pricing-table-body .pricing-vendor-price');
|
||||
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() {
|
||||
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 pctEl = document.getElementById('pricing-discount-pct');
|
||||
|
||||
if (customPrice > 0 && estimateTotal > 0) {
|
||||
const discount = ((estimateTotal - customPrice) / estimateTotal * 100).toFixed(1);
|
||||
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) {
|
||||
if (!str) return '';
|
||||
return str.replace(/&/g,'&').replace(/</g,'<').replace(/>/g,'>').replace(/"/g,'"');
|
||||
@@ -3008,27 +3162,6 @@ function formatCurrency(val) {
|
||||
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}}
|
||||
|
||||
@@ -2,12 +2,7 @@
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
<div class="flex items-center justify-between">
|
||||
<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>
|
||||
<h1 class="text-2xl font-bold text-gray-900">Партномера</h1>
|
||||
|
||||
<!-- Summary cards -->
|
||||
<div id="summary-cards" class="grid grid-cols-2 md:grid-cols-4 gap-4 hidden">
|
||||
@@ -59,50 +54,96 @@
|
||||
</div>
|
||||
|
||||
<!-- All books list (collapsed by default) -->
|
||||
<details 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">
|
||||
История снимков
|
||||
</summary>
|
||||
<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 class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<!-- Header row — always visible -->
|
||||
<div class="px-4 py-3 flex items-center justify-between">
|
||||
<button onclick="toggleBooksSection()" class="flex items-center gap-2 text-sm font-semibold text-gray-800 hover:text-gray-600 select-none">
|
||||
<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>
|
||||
Снимки сопоставлений (Partnumber Books)
|
||||
</button>
|
||||
<button onclick="syncPartnumberBooks()" class="px-4 py-2 bg-orange-500 text-white rounded hover:bg-orange-600 text-sm font-medium">
|
||||
Синхронизировать
|
||||
</button>
|
||||
</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>
|
||||
{{end}}
|
||||
|
||||
{{define "scripts"}}
|
||||
|
||||
<script>
|
||||
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() {
|
||||
const resp = await fetch('/api/partnumber-books');
|
||||
const data = await resp.json();
|
||||
const books = data.books || [];
|
||||
let resp, data;
|
||||
try {
|
||||
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');
|
||||
|
||||
if (!books.length) {
|
||||
if (!allBooks.length) {
|
||||
document.getElementById('books-empty').classList.remove('hidden');
|
||||
document.getElementById('summary-empty').classList.remove('hidden');
|
||||
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');
|
||||
tbody.innerHTML = '';
|
||||
books.forEach(b => {
|
||||
pageBooks.forEach(b => {
|
||||
const tr = document.createElement('tr');
|
||||
tr.className = 'border-b hover:bg-gray-50';
|
||||
tr.innerHTML = `
|
||||
@@ -119,17 +160,36 @@ async function loadBooks() {
|
||||
});
|
||||
document.getElementById('books-table').classList.remove('hidden');
|
||||
|
||||
// Load active book detail
|
||||
const active = books.find(b => b.is_active) || books[0];
|
||||
await loadActiveBookItems(active);
|
||||
// Pagination controls
|
||||
if (total > BOOKS_PER_PAGE) {
|
||||
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) {
|
||||
const resp = await fetch(`/api/partnumber-books/${book.server_id}`);
|
||||
const data = await resp.json();
|
||||
let resp, data;
|
||||
try {
|
||||
resp = await fetch(`/api/partnumber-books/${book.server_id}`);
|
||||
data = await resp.json();
|
||||
} catch (e) {
|
||||
return;
|
||||
}
|
||||
if (!resp.ok) return;
|
||||
|
||||
allItems = data.items || [];
|
||||
|
||||
// Compute stats
|
||||
const lots = new Set(allItems.map(i => i.lot_name));
|
||||
const primaryCount = allItems.filter(i => i.is_primary_pn).length;
|
||||
|
||||
@@ -170,17 +230,21 @@ function filterItems(query) {
|
||||
}
|
||||
|
||||
async function syncPartnumberBooks() {
|
||||
let resp, data;
|
||||
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');
|
||||
}
|
||||
resp = await fetch('/api/sync/partnumber-books', {method: 'POST'});
|
||||
data = await resp.json();
|
||||
} catch (e) {
|
||||
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}}
|
||||
|
||||
{{template "base" .}}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user