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_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'@'%';

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`.
### 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 |
---

View File

@@ -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

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).
---
## 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 |

View File

@@ -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)

View File

@@ -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)})
}

View File

@@ -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
}

View File

@@ -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
}

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>
<!-- 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,'&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});
}
// ==================== 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}}

View File

@@ -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" .}}