22 Commits

Author SHA1 Message Date
ed0ef04d10 Merge feature/vendor-spec-import into main (v1.4) 2026-03-04 12:35:40 +03:00
2e0faf4aec Rename vendor price to project price, expand pricing CSV export
- Rename "Цена вендора" to "Цена проектная" in pricing tab table header and comments
- Expand pricing CSV export to include: Lot, P/N вендора, Описание, Кол-во, Цена проектная

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 12:27:34 +03:00
4b0879779a Update bible submodule to latest
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 22:27:45 +03:00
2b175a3d1e Update bible paths kit/ → rules/
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 16:57:50 +03:00
5732c75b85 Update bible submodule to latest 2026-03-01 16:41:42 +03:00
eb7c3739ce Add shared bible submodule, rename local bible to bible-local
- Add bible.git as submodule at bible/
- Rename bible/ → bible-local/ (project-specific architecture)
- Update CLAUDE.md to reference both bible/ and bible-local/
- Add AGENTS.md for Codex with same structure

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 16:41:14 +03:00
Mikhail Chusavitin
6e0335af7c Fix pricing tab warehouse totals and guard custom price DOM access 2026-02-27 16:53:34 +03:00
Mikhail Chusavitin
a42a80beb8 fix(bom): preserve local vendor spec on config import 2026-02-27 10:11:20 +03:00
Mikhail Chusavitin
586114c79c refactor(bom): enforce canonical lot_mappings persistence 2026-02-27 09:47:46 +03:00
Mikhail Chusavitin
e9230c0e58 feat(bom): canonical lot mappings and updated vendor spec docs 2026-02-25 19:07:27 +03:00
Mikhail Chusavitin
aa65fc8156 Fix project line numbering and reorder bootstrap 2026-02-25 17:19:26 +03:00
Mikhail Chusavitin
b22e961656 feat(projects): compact table layout for dates and names 2026-02-25 17:19:26 +03:00
Mikhail Chusavitin
af83818564 fix(pricelists): tolerate restricted DB grants and use embedded assets only 2026-02-25 17:19:26 +03:00
Mikhail Chusavitin
8a138327a3 fix(sync): backfill missing items for existing local pricelists 2026-02-25 17:18:57 +03:00
d0400b18a3 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>
2026-02-21 22:21:13 +03:00
d3f1a838eb feat: add Партномера nav item and summary page
- Top nav: link to /partnumber-books
- Page: summary cards (active version, unique LOTs, total PN, primary PN)
  + searchable items table for active book
  + collapsible history of all snapshots

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 17:19:40 +03:00
c6086ac03a ui: simplify BOM paste to fixed positional column order
Format: PN | qty | [description] | [price]. Remove heuristic
column-type detection. Update hint text accordingly.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 17:16:57 +03:00
a127ebea82 ui: add clear BOM button with server-side reset
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 17:15:13 +03:00
347599e06b ui: add format hint to BOM vendor paste area
Show supported column formats and auto-detection rules so users
know what to copy from Excel.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 17:13:49 +03:00
4a44d48366 docs(bible): fix and clarify SQLite migration mechanism in 03-database.md
Previous description was wrong: migrations/*.sql are MariaDB-only.
Document the actual 3-level SQLite migration flow:
1. GORM AutoMigrate (primary, runs on every start)
2. runLocalMigrations Go functions (data backfill, index creation)
3. Centralized remote migrations via qt_client_local_migrations

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 17:09:45 +03:00
23882637b5 fix: use AutoMigrate for new SQLite tables instead of hardcoded migrations
LocalPartnumberBook and LocalPartnumberBookItem added to AutoMigrate list
in localdb.go — consistent with all other local tables. Removed incorrectly
added addPartnumberBooks/addVendorSpecColumn functions from migrations.go
(vendor_spec column is handled by AutoMigrate via the LocalConfiguration model field).

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 17:07:44 +03:00
5e56f386cc feat: implement vendor spec BOM import and PN→LOT resolution (Phase 1)
- Migration 029: local_partnumber_books, local_partnumber_book_items,
  vendor_spec TEXT column on local_configurations
- Models: LocalPartnumberBook, LocalPartnumberBookItem, VendorSpec,
  VendorSpecItem with JSON Valuer/Scanner
- Repository: PartnumberBookRepository (GetActiveBook, FindLotByPartnumber,
  SaveBook/Items, ListBooks, CountBookItems)
- Service: VendorSpecResolver 3-step resolution (book → manual suggestion
  → unresolved) + AggregateLOTs with is_primary_pn qty logic
- Sync: PullPartnumberBooks append-only pull from qt_partnumber_books
- Handlers: VendorSpecHandler (GET/PUT/resolve/apply), PartnumberBooksHandler
- Routes: /api/configs/:uuid/vendor-spec*, /api/partnumber-books,
  /api/sync/partnumber-books, /partnumber-books page
- UI: 3 top-level tabs [Estimate][BOM вендора][Ценообразование]; Excel paste,
  PN resolution, inline LOT autocomplete, pricing table
- Bible: 03-database.md updated, 09-vendor-spec.md added

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 10:22:22 +03:00
34 changed files with 3195 additions and 145 deletions

3
.gitmodules vendored Normal file
View File

@@ -0,0 +1,3 @@
[submodule "bible"]
path = bible
url = https://git.mchus.pro/mchus/bible.git

11
AGENTS.md Normal file
View File

@@ -0,0 +1,11 @@
# QuoteForge — Instructions for Codex
## Shared Engineering Rules
Read `bible/` — shared rules for all projects (CSV, logging, DB, tables, background tasks, code style).
Start with `bible/rules/patterns/` for specific contracts.
## Project Architecture
Read `bible-local/` — QuoteForge specific architecture.
Read order: `bible-local/README.md` → relevant files for the task.
Every architectural decision specific to this project must be recorded in `bible-local/`.

View File

@@ -1,24 +1,17 @@
# QuoteForge - Claude Code Instructions
# QuoteForge Instructions for Claude
## Bible
## Shared Engineering Rules
Read `bible/` — shared rules for all projects (CSV, logging, DB, tables, background tasks, code style).
Start with `bible/rules/patterns/` for specific contracts.
The **[bible/](bible/README.md)** is the single source of truth for this project's architecture, schemas, patterns, and rules. Read it before making any changes.
## Project Architecture
Read `bible-local/` — QuoteForge specific architecture.
Read order: `bible-local/README.md` → relevant files for the task.
**Rules:**
- Every architectural decision must be recorded in `bible/` in the same commit as the code.
- Bible files are written and updated in **English only**.
- Before working on the codebase, check `releases/memory/` for the latest release notes.
## Quick Reference
Every architectural decision specific to this project must be recorded in `bible-local/`.
```bash
# Verify build
go build ./cmd/qfs && go vet ./...
# Run
go run ./cmd/qfs
make run
# Build
make build-release
go build ./cmd/qfs && go vet ./... # verify
go run ./cmd/qfs # run
make build-release # release build
```

1
bible Submodule

Submodule bible added at 472c8a6918

View File

@@ -21,11 +21,22 @@ File: `qfs.db` in the user-state directory (see [05-config.md](05-config.md)).
| `local_pricelists` | Pricelist headers | `id`, `server_id` (unique), `source`, `version`, `created_at` |
| `local_pricelist_items` | Pricelist line items ← **sole source of prices** | `id`, `pricelist_id` (FK), `lot_name`, `price`, `lot_category` |
#### Partnumber Books (PN → LOT mapping, pull-only from PriceForge)
| Table | Purpose | Key Fields |
|-------|---------|------------|
| `local_partnumber_books` | Version snapshots of PN→LOT mappings | `id`, `server_id` (unique), `version`, `created_at`, `is_active` |
| `local_partnumber_book_items` | PN→LOT mapping rows | `id`, `book_id` (FK), `partnumber`, `lot_name`, `is_primary_pn` |
Active book: `WHERE is_active=1 ORDER BY created_at DESC, id DESC LIMIT 1`
`is_primary_pn=1` means this PN's quantity in the vendor BOM determines qty(LOT). If only non-primary PNs are present for a LOT, qty defaults to 1.
#### Configurations and Projects
| Table | Purpose | Key Fields |
|-------|---------|------------|
| `local_configurations` | Saved configurations | `id`, `uuid` (unique), `items` (JSON), `line_no`, `pricelist_id`, `warehouse_pricelist_id`, `competitor_pricelist_id`, `current_version_id`, `sync_status` |
| `local_configurations` | Saved configurations | `id`, `uuid` (unique), `items` (JSON), `vendor_spec` (JSON: PN/qty/description + canonical `lot_mappings[]`), `line_no`, `pricelist_id`, `warehouse_pricelist_id`, `competitor_pricelist_id`, `current_version_id`, `sync_status` |
| `local_configuration_versions` | Immutable snapshots (revisions) | `id`, `configuration_id` (FK), `version_no`, `data` (JSON), `change_note`, `created_at` |
| `local_projects` | Projects | `id`, `uuid` (unique), `name`, `code`, `sync_status` |
@@ -89,6 +100,8 @@ Database: `RFQ_LOG`
| `qt_categories` | Component categories | SELECT |
| `qt_pricelists` | Pricelists | SELECT |
| `qt_pricelist_items` | Pricelist line items | SELECT |
| `lot_partnumbers` | Partnumber → lot mapping (pricelist enrichment) | SELECT |
| `stock_log` | Latest stock qty by partnumber (pricelist enrichment) | SELECT |
| `qt_configurations` | Saved configurations (includes `line_no`) | SELECT, INSERT, UPDATE |
| `lot_partnumbers` | Partnumber → lot mapping (pricelist enrichment) | SELECT |
| `stock_log` | Latest stock qty by partnumber (pricelist enrichment) | SELECT |
@@ -96,6 +109,9 @@ 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 |
| `qt_vendor_partnumber_seen` | Vendor PN tracking for unresolved/ignored BOM rows (`is_ignored`) | INSERT, UPDATE |
### Grant Permissions to Existing User
@@ -115,6 +131,10 @@ 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>'@'%';
GRANT INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen TO '<DB_USER>'@'%';
FLUSH PRIVILEGES;
```
@@ -135,6 +155,9 @@ 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'@'%';
GRANT INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen TO 'quote_user'@'%';
FLUSH PRIVILEGES;
SHOW GRANTS FOR 'quote_user'@'%';
@@ -148,21 +171,22 @@ SHOW GRANTS FOR 'quote_user'@'%';
## Migrations
### SQLite Migrations (local)
### SQLite Migrations (local) — три уровня, выполняются при каждом старте
**1. GORM AutoMigrate** (`internal/localdb/localdb.go`) — первый и основной уровень.
Список Go-моделей передаётся в `db.AutoMigrate(...)`. GORM создаёт отсутствующие таблицы и добавляет новые колонки. Колонки и таблицы **не удаляет**.
→ Для добавления новой таблицы или колонки достаточно добавить модель/поле и включить модель в AutoMigrate.
**2. `runLocalMigrations`** (`internal/localdb/migrations.go`) — второй уровень, для операций которые AutoMigrate не умеет: backfill данных, пересоздание таблиц, создание индексов.
Каждая функция выполняется один раз — идемпотентность через запись `id` в `local_schema_migrations`.
**3. Централизованные (server-side)** — третий уровень, при проверке готовности к синку.
SQL-тексты хранятся в `qt_client_local_migrations` (MariaDB, пишет только PriceForge). Клиент читает, применяет к локальной SQLite, записывает в `local_remote_migrations_applied` + `qt_client_schema_state`.
### MariaDB Migrations (server-side)
- Stored in `migrations/` (SQL files)
- Applied via `-migrate` flag or automatically on first run
- Idempotent: checked by `id` in `local_schema_migrations`
- Already-applied migrations are skipped
```bash
go run ./cmd/qfs -migrate
```
### Centralized Migrations (server-side)
- Stored in `qt_client_local_migrations` (MariaDB)
- Applied automatically during sync readiness check
- Applied via `-migrate` flag
- `min_app_version` — minimum app version required for the migration
---

View File

@@ -89,9 +89,38 @@
| POST | `/api/sync/pricelists` | Pull pricelists | MariaDB → SQLite |
| POST | `/api/sync/all` | Full sync: push + pull + import | bidirectional |
| POST | `/api/sync/repair` | Repair broken entries in pending_changes | SQLite |
| POST | `/api/sync/partnumber-seen` | Report unresolved/ignored vendor PNs for server-side tracking | QuoteForge → MariaDB |
**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 |
Notes:
- `GET` / `PUT /api/configs/:uuid/vendor-spec` exchange normalized BOM rows (`vendor_spec`), not raw pasted Excel layout.
- BOM row contract stores canonical LOT mapping list as seen in BOM UI:
- `lot_mappings[]`
- each mapping contains `lot_name` + `quantity_per_pn`
- `POST /api/configs/:uuid/vendor-spec/apply` rebuilds cart items from explicit BOM mappings:
- all LOTs from `lot_mappings[]`
### 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.
See [09-vendor-spec.md](09-vendor-spec.md) for `vendor_spec` JSON schema and BOM UI mapping contract.
### Export
| Method | Endpoint | Purpose |
@@ -114,6 +143,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

@@ -0,0 +1,364 @@
# 09 — Vendor Spec (BOM Import)
## Overview
The vendor spec feature allows importing a vendor BOM (Bill of Materials) into a configuration. It maps vendor part numbers (PN) to internal LOT names using an active partnumber book (snapshot pulled from PriceForge), then aggregates quantities to populate the Estimate (cart).
---
## Architecture
### Storage
| Data | Storage | Sync direction |
|------|---------|---------------|
| `vendor_spec` JSON | `local_configurations.vendor_spec` (TEXT, JSON-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.
### `vendor_spec` JSON Schema
```json
[
{
"sort_order": 10,
"vendor_partnumber": "ABC-123",
"quantity": 2,
"description": "...",
"unit_price": 4500.00,
"total_price": 9000.00,
"lot_mappings": [
{ "lot_name": "LOT_A", "quantity_per_pn": 1 },
{ "lot_name": "LOT_B", "quantity_per_pn": 2 }
]
}
]
```
`lot_mappings[]` is the canonical persisted LOT mapping list for a BOM row.
Each mapping entry stores:
- `lot_name`
- `quantity_per_pn` (how many units of this LOT are included in **one vendor PN**)
### PN → LOT Mapping Contract (single LOT, multiplier, bundle)
QuoteForge expects the server to return/store BOM rows (`vendor_spec`) using a single canonical mapping list:
- `lot_mappings[]` contains **all** LOT mappings for the PN row (single LOT and bundle cases alike)
- the list stores exactly what the user sees in BOM (LOT + "LOT в 1 PN")
- the DB contract does **not** split mappings into "base LOT" vs "bundle LOTs"
#### Final quantity contribution to Estimate
For one BOM row with vendor PN quantity `pn_qty`:
- each mapping contribution:
- `lot_qty = pn_qty * lot_mappings[i].quantity_per_pn`
#### Example: one PN maps to multiple LOTs
```json
{
"vendor_partnumber": "SYS-821GE-TNHR",
"quantity": 3,
"lot_mappings": [
{ "lot_name": "CHASSIS_X13_8GPU", "quantity_per_pn": 1 },
{ "lot_name": "PS_3000W_Titanium", "quantity_per_pn": 2 },
{ "lot_name": "RAILKIT_X13", "quantity_per_pn": 1 }
]
}
```
This row contributes to Estimate:
- `CHASSIS_X13_8GPU``3 * 1 = 3`
- `PS_3000W_Titanium``3 * 2 = 6`
- `RAILKIT_X13``3 * 1 = 3`
---
## Partnumber Books (Snapshots)
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.
### 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
);
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);
```
**Active book query:** `WHERE is_active=1 ORDER BY created_at DESC, id DESC LIMIT 1`
**Schema creation:** GORM AutoMigrate (not `runLocalMigrations`).
### MariaDB (managed exclusively by PriceForge)
```sql
CREATE TABLE qt_partnumber_books (
id INT AUTO_INCREMENT PRIMARY KEY,
version VARCHAR(50) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_active TINYINT(1) NOT NULL DEFAULT 1
);
CREATE TABLE qt_partnumber_book_items (
id INT AUTO_INCREMENT PRIMARY KEY,
book_id INT NOT NULL,
partnumber VARCHAR(255) NOT NULL,
lot_name VARCHAR(255) NOT NULL,
is_primary_pn TINYINT(1) NOT NULL DEFAULT 0,
description VARCHAR(10000) NULL,
INDEX idx_book_pn (book_id, partnumber),
FOREIGN KEY (book_id) REFERENCES qt_partnumber_books(id)
);
ALTER TABLE qt_configurations ADD COLUMN vendor_spec JSON NULL;
```
QuoteForge 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, QuoteForge builds/updates UI-visible LOT mappings:
1. **Active book lookup** — query `local_partnumber_book_items WHERE book_id = activeBook.id AND partnumber = ?`.
2. **Populate BOM UI** — if a match exists, BOM row shows a LOT value (user can still edit it).
3. **Unresolved** — red row + inline LOT input with strict autocomplete.
Persistence note: the application stores the final user-visible mappings in `lot_mappings[]` (not separate "resolved/manual" persisted fields).
---
## 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/import vendor BOM, manual column mapping, LOT matching, bundle decomposition (`1 PN -> multiple LOTs`), "Пересчитать эстимейт", "Очистить".
3. **Ценообразование** — pricing summary table + custom price input.
BOM data is shared between tabs 2 and 3.
### BOM Import UI (raw table, manual column mapping)
After paste (`Ctrl+V`) QuoteForge renders an editable raw table (not auto-detected parsing).
- The pasted rows are shown **as-is** (including header rows, if present).
- The user selects a type for each column manually:
- `P/N`
- `Кол-во`
- `Цена`
- `Описание`
- `Не использовать`
- Required mapping:
- exactly one `P/N`
- exactly one `Кол-во`
- Optional mapping:
- `Цена` (0..1)
- `Описание` (0..1)
- Rows can be:
- ignored (UI-only, excluded from `vendor_spec`)
- deleted
- Raw cells are editable inline after paste.
Notes:
- There is **no auto column detection**.
- There is **no auto header-row skip**.
- Raw import layout itself is not stored on server; only normalized `vendor_spec` is stored.
### LOT matching in BOM table
The BOM table adds service columns on the right:
- `LOT`
- `LOT в 1 PN`
- actions (`+`, ignore, delete)
`LOT` behavior:
- The first LOT row shown in the BOM UI is the primary LOT mapping for the PN row.
- Additional LOT rows are added via the `+` action.
- inline LOT input is strict:
- autocomplete source = full local components list (`/api/components?per_page=5000`)
- free text that does not match an existing LOT is rejected
`LOT в 1 PN` behavior:
- quantity multiplier for each visible LOT row in BOM (`quantity_per_pn` in persisted `lot_mappings[]`)
- default = `1`
- editable inline
### Bundle mode (`1 PN -> multiple LOTs`)
The `+` action in BOM rows adds an extra LOT mapping row for the same vendor PN row.
- All visible LOT rows (first + added rows) are persisted uniformly in `lot_mappings[]`
- Each mapping row has:
- LOT
- qty (`LOT in 1 PN` = `quantity_per_pn`)
### BOM restore on config open
On config open, QuoteForge loads `vendor_spec` from server and reconstructs the editable BOM table in normalized form:
- columns restored as: `Qty | P/N | Description | Price`
- column mapping restored as:
- `qty`, `pn`, `description`, `price`
- LOT / `LOT в 1 PN` rows are restored from `vendor_spec.lot_mappings[]`
This restores the BOM editing state, but not the original raw Excel layout (extra columns, ignored rows, original headers).
### 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`.
### Pricing Tab: BOM + Estimate merge behavior
When BOM exists, the pricing tab renders:
- BOM-based rows (including rows resolved via manual LOT and bundle mappings)
- plus **Estimate-only LOTs** (rows currently in cart but not covered by BOM mappings)
Estimate-only rows are shown as separate rows with:
- `PN вендора = "—"`
- vendor price = `—`
- description from local components
### 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, QuoteForge pushes PN rows to `POST /api/sync/partnumber-seen` (fire-and-forget from JS — errors silently ignored):
- unresolved BOM rows (`ignored = false`)
- raw BOM rows explicitly marked as ignored in UI (`ignored = true`) — these rows are **not** saved to `vendor_spec`, but are reported for server-side tracking
The handler calls `sync.PushPartnumberSeen()` which upserts into `qt_vendor_partnumber_seen`:
```sql
INSERT INTO qt_vendor_partnumber_seen (source_type, vendor, partnumber, description, is_ignored, last_seen_at)
VALUES ('manual', '', ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
last_seen_at = VALUES(last_seen_at),
is_ignored = VALUES(is_ignored),
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`.
- `GET` / `PUT` `vendor_spec` must preserve row-level mapping fields used by the UI:
- `lot_mappings[]`
- each item: `lot_name`, `quantity_per_pn`
- `description` is persisted in each BOM row and is used by the Pricing tab when available.
- Ignored raw rows are **not** persisted into `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 autosaved (debounced) after BOM-changing actions, including:
- `resolveBOM()`
- LOT row qty (`LOT в 1 PN`) changes
- LOT row add/remove (`+` / delete in bundle context)
- "Сохранить BOM" button triggers explicit save.
## Pricing Tab: Estimate Price Source
`renderPricingTab()` is async. It calls `POST /api/quote/price-levels` with LOTs collected from:
- `lot_mappings[]` from BOM rows
- current Estimate/cart LOTs not covered by BOM mappings (to show estimate-only rows)
This ensures Estimate prices appear for:
- manually matched LOTs in the BOM tab
- bundle LOTs
- LOTs already present in Estimate but not mapped from BOM
### Apply to Estimate (`Пересчитать эстимейт`)
When applying BOM to Estimate, QuoteForge builds cart rows from explicit UI mappings stored in `lot_mappings[]`.
For a BOM row with PN qty = `Q`:
- each mapped LOT contributes `Q * quantity_per_pn`
Rows without any valid LOT mapping are skipped.
## 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

@@ -820,6 +820,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
quoteHandler := handlers.NewQuoteHandler(quoteService)
exportHandler := handlers.NewExportHandler(exportService, configService, projectService)
pricelistHandler := handlers.NewPricelistHandler(local)
vendorSpecHandler := handlers.NewVendorSpecHandler(local)
partnumberBooksHandler := handlers.NewPartnumberBooksHandler(local)
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
if err != nil {
return nil, nil, fmt.Errorf("creating sync handler: %w", err)
@@ -940,6 +942,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
router.GET("/configs/:uuid/revisions", webHandler.ConfigRevisions)
router.GET("/pricelists", webHandler.Pricelists)
router.GET("/pricelists/:id", webHandler.PricelistDetail)
router.GET("/partnumber-books", webHandler.PartnumberBooks)
// htmx partials
partials := router.Group("/partials")
@@ -989,6 +992,13 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
pricelists.GET("/:id/lots", pricelistHandler.GetLotNames)
}
// Partnumber books (read-only)
pnBooks := api.Group("/partnumber-books")
{
pnBooks.GET("", partnumberBooksHandler.List)
pnBooks.GET("/:id", partnumberBooksHandler.GetItems)
}
// Configurations (public - RBAC disabled)
configs := api.Group("/configs")
{
@@ -1309,6 +1319,12 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
configs.GET("/:uuid/export", exportHandler.ExportConfigCSV)
// Vendor spec (BOM) endpoints
configs.GET("/:uuid/vendor-spec", vendorSpecHandler.GetVendorSpec)
configs.PUT("/:uuid/vendor-spec", vendorSpecHandler.PutVendorSpec)
configs.POST("/:uuid/vendor-spec/resolve", vendorSpecHandler.ResolveVendorSpec)
configs.POST("/:uuid/vendor-spec/apply", vendorSpecHandler.ApplyVendorSpec)
configs.PATCH("/:uuid/server-count", func(c *gin.Context) {
uuid := c.Param("uuid")
var req struct {
@@ -1744,6 +1760,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
syncAPI.GET("/users-status", syncHandler.GetUsersStatus)
syncAPI.POST("/components", syncHandler.SyncComponents)
syncAPI.POST("/pricelists", syncHandler.SyncPricelists)
syncAPI.POST("/partnumber-books", syncHandler.SyncPartnumberBooks)
syncAPI.POST("/partnumber-seen", syncHandler.ReportPartnumberSeen)
syncAPI.POST("/all", syncHandler.SyncAll)
syncAPI.POST("/push", syncHandler.PushPendingChanges)
syncAPI.GET("/pending/count", syncHandler.GetPendingCount)

View File

@@ -0,0 +1,90 @@
package handlers
import (
"net/http"
"strconv"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"github.com/gin-gonic/gin"
)
// PartnumberBooksHandler provides read-only access to local partnumber book snapshots.
type PartnumberBooksHandler struct {
localDB *localdb.LocalDB
}
func NewPartnumberBooksHandler(localDB *localdb.LocalDB) *PartnumberBooksHandler {
return &PartnumberBooksHandler{localDB: localDB}
}
// List returns all local partnumber book snapshots.
// GET /api/partnumber-books
func (h *PartnumberBooksHandler) List(c *gin.Context) {
bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB())
books, err := bookRepo.ListBooks()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
type bookSummary struct {
ID uint `json:"id"`
ServerID int `json:"server_id"`
Version string `json:"version"`
CreatedAt string `json:"created_at"`
IsActive bool `json:"is_active"`
ItemCount int64 `json:"item_count"`
}
summaries := make([]bookSummary, 0, len(books))
for _, b := range books {
summaries = append(summaries, bookSummary{
ID: b.ID,
ServerID: b.ServerID,
Version: b.Version,
CreatedAt: b.CreatedAt.Format("2006-01-02"),
IsActive: b.IsActive,
ItemCount: bookRepo.CountBookItems(b.ID),
})
}
c.JSON(http.StatusOK, gin.H{
"books": summaries,
"total": len(summaries),
})
}
// GetItems returns items for a partnumber book by server ID.
// GET /api/partnumber-books/:id
func (h *PartnumberBooksHandler) GetItems(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 64)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid book ID"})
return
}
bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB())
// Find local book by server_id
var book localdb.LocalPartnumberBook
if err := h.localDB.DB().Where("server_id = ?", id).First(&book).Error; err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "partnumber book not found"})
return
}
items, err := bookRepo.GetBookItems(book.ID)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"book_id": book.ServerID,
"version": book.Version,
"is_active": book.IsActive,
"items": items,
"total": len(items),
})
}

View File

@@ -234,6 +234,33 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
h.syncService.RecordSyncHeartbeat()
}
// SyncPartnumberBooks syncs partnumber book snapshots from MariaDB to local SQLite.
// POST /api/sync/partnumber-books
func (h *SyncHandler) SyncPartnumberBooks(c *gin.Context) {
if !h.ensureSyncReadiness(c) {
return
}
startTime := time.Now()
pulled, err := h.syncService.PullPartnumberBooks()
if err != nil {
slog.Error("partnumber books pull failed", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, SyncResultResponse{
Success: true,
Message: "Partnumber books synced successfully",
Synced: pulled,
Duration: time.Since(startTime).String(),
})
h.syncService.RecordSyncHeartbeat()
}
// SyncAllResponse represents result of full sync
type SyncAllResponse struct {
Success bool `json:"success"`
@@ -643,3 +670,37 @@ 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"`
Ignored bool `json:"ignored"`
} `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,
Ignored: it.Ignored,
})
}
}
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

@@ -0,0 +1,209 @@
package handlers
import (
"encoding/json"
"errors"
"net/http"
"strings"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin"
)
// VendorSpecHandler handles vendor BOM spec operations for a configuration.
type VendorSpecHandler struct {
localDB *localdb.LocalDB
}
func NewVendorSpecHandler(localDB *localdb.LocalDB) *VendorSpecHandler {
return &VendorSpecHandler{localDB: localDB}
}
// 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) {
cfg, err := h.lookupConfig(c.Param("uuid"))
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
return
}
spec := cfg.VendorSpec
if spec == nil {
spec = localdb.VendorSpec{}
}
c.JSON(http.StatusOK, gin.H{"vendor_spec": spec})
}
// PutVendorSpec saves (replaces) the vendor spec for a configuration.
// PUT /api/configs/:uuid/vendor-spec
func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
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"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
for i := range body.VendorSpec {
if body.VendorSpec[i].SortOrder == 0 {
body.VendorSpec[i].SortOrder = (i + 1) * 10
}
// Persist canonical LOT mapping only.
body.VendorSpec[i].LotMappings = normalizeLotMappings(body.VendorSpec[i].LotMappings)
body.VendorSpec[i].ResolvedLotName = ""
body.VendorSpec[i].ResolutionSource = ""
body.VendorSpec[i].ManualLotSuggestion = ""
body.VendorSpec[i].LotQtyPerPN = 0
body.VendorSpec[i].LotAllocations = nil
}
spec := localdb.VendorSpec(body.VendorSpec)
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
}
c.JSON(http.StatusOK, gin.H{"vendor_spec": spec})
}
func normalizeLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
if len(in) == 0 {
return nil
}
merged := make(map[string]int, len(in))
order := make([]string, 0, len(in))
for _, m := range in {
lot := strings.TrimSpace(m.LotName)
if lot == "" {
continue
}
qty := m.QuantityPerPN
if qty < 1 {
qty = 1
}
if _, exists := merged[lot]; !exists {
order = append(order, lot)
}
merged[lot] += qty
}
out := make([]localdb.VendorSpecLotMapping, 0, len(order))
for _, lot := range order {
out = append(out, localdb.VendorSpecLotMapping{
LotName: lot,
QuantityPerPN: merged[lot],
})
}
if len(out) == 0 {
return nil
}
return out
}
// ResolveVendorSpec resolves vendor PN → LOT without modifying the cart.
// POST /api/configs/:uuid/vendor-spec/resolve
func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
if _, err := h.lookupConfig(c.Param("uuid")); err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "configuration not found"})
return
}
var body struct {
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB())
resolver := services.NewVendorSpecResolver(bookRepo)
resolved, err := resolver.Resolve(body.VendorSpec)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
book, _ := bookRepo.GetActiveBook()
aggregated, err := services.AggregateLOTs(resolved, book, bookRepo)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"resolved": resolved,
"aggregated": aggregated,
})
}
// ApplyVendorSpec applies the resolved BOM to the cart (Estimate items).
// POST /api/configs/:uuid/vendor-spec/apply
func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) {
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 {
LotName string `json:"lot_name"`
Quantity int `json:"quantity"`
UnitPrice float64 `json:"unit_price"`
} `json:"items"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
newItems := make(localdb.LocalConfigItems, 0, len(body.Items))
for _, it := range body.Items {
newItems = append(newItems, localdb.LocalConfigItem{
LotName: it.LotName,
Quantity: it.Quantity,
UnitPrice: it.UnitPrice,
})
}
itemsJSON, err := json.Marshal(newItems)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
if err := h.localDB.DB().Model(cfg).Update("items", string(itemsJSON)).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"items": newItems})
}

View File

@@ -59,7 +59,7 @@ func NewWebHandler(_ string, componentService *services.ComponentService) (*WebH
templates := make(map[string]*template.Template)
// Load each page template with base
simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html", "config_revisions.html"}
simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html", "config_revisions.html", "partnumber_books.html"}
for _, page := range simplePages {
var tmpl *template.Template
var err error
@@ -188,6 +188,10 @@ func (h *WebHandler) PricelistDetail(c *gin.Context) {
h.render(c, "pricelist_detail.html", gin.H{"ActivePage": "pricelists"})
}
func (h *WebHandler) PartnumberBooks(c *gin.Context) {
h.render(c, "partnumber_books.html", gin.H{"ActivePage": "partnumber-books"})
}
// Partials for htmx
func (h *WebHandler) ComponentsPartial(c *gin.Context) {

View File

@@ -142,6 +142,8 @@ func New(dbPath string) (*LocalDB, error) {
&LocalRemoteMigrationApplied{},
&LocalSyncGuardState{},
&PendingChange{},
&LocalPartnumberBook{},
&LocalPartnumberBookItem{},
); err != nil {
return nil, fmt.Errorf("migrating sqlite database: %w", err)
}

View File

@@ -864,3 +864,4 @@ WHERE id IN (SELECT id FROM ranked)
return nil
}

View File

@@ -103,6 +103,7 @@ type LocalConfiguration struct {
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
VendorSpec VendorSpec `gorm:"type:text" json:"vendor_spec,omitempty"`
Line int `gorm:"column:line_no;index" json:"line"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
@@ -243,3 +244,85 @@ type PendingChange struct {
func (PendingChange) TableName() string {
return "pending_changes"
}
// LocalPartnumberBook stores a version snapshot of the PN→LOT mapping book (pull-only from PriceForge)
type LocalPartnumberBook struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
ServerID int `gorm:"uniqueIndex;not null" json:"server_id"`
Version string `gorm:"not null" json:"version"`
CreatedAt time.Time `gorm:"not null" json:"created_at"`
IsActive bool `gorm:"not null;default:true" json:"is_active"`
}
func (LocalPartnumberBook) TableName() string {
return "local_partnumber_books"
}
// LocalPartnumberBookItem stores a single PN→LOT mapping within a book snapshot
type LocalPartnumberBookItem struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
BookID uint `gorm:"not null;index:idx_local_book_pn,priority:1" json:"book_id"`
Partnumber string `gorm:"not null;index:idx_local_book_pn,priority:2" json:"partnumber"`
LotName string `gorm:"not null" json:"lot_name"`
IsPrimaryPN bool `gorm:"not null;default:false" json:"is_primary_pn"`
Description string `json:"description,omitempty"`
}
func (LocalPartnumberBookItem) TableName() string {
return "local_partnumber_book_items"
}
// VendorSpecItem represents a single row in a vendor BOM specification
type VendorSpecItem struct {
SortOrder int `json:"sort_order"`
VendorPartnumber string `json:"vendor_partnumber"`
Quantity int `json:"quantity"`
Description string `json:"description,omitempty"`
UnitPrice *float64 `json:"unit_price,omitempty"`
TotalPrice *float64 `json:"total_price,omitempty"`
ResolvedLotName string `json:"resolved_lot_name,omitempty"`
ResolutionSource string `json:"resolution_source,omitempty"` // "book", "manual", "unresolved"
ManualLotSuggestion string `json:"manual_lot_suggestion,omitempty"`
LotQtyPerPN int `json:"lot_qty_per_pn,omitempty"`
LotAllocations []VendorSpecLotAllocation `json:"lot_allocations,omitempty"`
LotMappings []VendorSpecLotMapping `json:"lot_mappings,omitempty"`
}
type VendorSpecLotAllocation struct {
LotName string `json:"lot_name"`
Quantity int `json:"quantity"` // quantity of LOT per 1 vendor PN
}
// VendorSpecLotMapping is the canonical persisted LOT mapping for a vendor PN row.
// It stores all mapped LOTs (base + bundle) uniformly.
type VendorSpecLotMapping struct {
LotName string `json:"lot_name"`
QuantityPerPN int `json:"quantity_per_pn"`
}
// VendorSpec is a JSON-encodable slice of VendorSpecItem
type VendorSpec []VendorSpecItem
func (v VendorSpec) Value() (driver.Value, error) {
if v == nil {
return nil, nil
}
return json.Marshal(v)
}
func (v *VendorSpec) Scan(value interface{}) error {
if value == nil {
*v = nil
return nil
}
var bytes []byte
switch val := value.(type) {
case []byte:
bytes = val
case string:
bytes = []byte(val)
default:
return errors.New("type assertion failed for VendorSpec")
}
return json.Unmarshal(bytes, v)
}

View File

@@ -0,0 +1,66 @@
package repository
import (
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"gorm.io/gorm"
)
// PartnumberBookRepository provides read-only access to local partnumber book snapshots.
type PartnumberBookRepository struct {
db *gorm.DB
}
func NewPartnumberBookRepository(db *gorm.DB) *PartnumberBookRepository {
return &PartnumberBookRepository{db: db}
}
// GetActiveBook returns the most recently active local partnumber book.
func (r *PartnumberBookRepository) GetActiveBook() (*localdb.LocalPartnumberBook, error) {
var book localdb.LocalPartnumberBook
err := r.db.Where("is_active = 1").Order("created_at DESC, id DESC").First(&book).Error
if err != nil {
return nil, err
}
return &book, nil
}
// GetBookItems returns all items for the given local book ID.
func (r *PartnumberBookRepository) GetBookItems(bookID uint) ([]localdb.LocalPartnumberBookItem, error) {
var items []localdb.LocalPartnumberBookItem
err := r.db.Where("book_id = ?", bookID).Find(&items).Error
return items, err
}
// FindLotByPartnumber looks up a partnumber in the active book and returns the matching items.
func (r *PartnumberBookRepository) FindLotByPartnumber(bookID uint, partnumber string) ([]localdb.LocalPartnumberBookItem, error) {
var items []localdb.LocalPartnumberBookItem
err := r.db.Where("book_id = ? AND partnumber = ?", bookID, partnumber).Find(&items).Error
return items, err
}
// ListBooks returns all local partnumber books ordered newest first.
func (r *PartnumberBookRepository) ListBooks() ([]localdb.LocalPartnumberBook, error) {
var books []localdb.LocalPartnumberBook
err := r.db.Order("created_at DESC, id DESC").Find(&books).Error
return books, err
}
// SaveBook saves a new partnumber book snapshot.
func (r *PartnumberBookRepository) SaveBook(book *localdb.LocalPartnumberBook) error {
return r.db.Save(book).Error
}
// SaveBookItems bulk-inserts items for a book snapshot.
func (r *PartnumberBookRepository) SaveBookItems(items []localdb.LocalPartnumberBookItem) error {
if len(items) == 0 {
return nil
}
return r.db.CreateInBatches(items, 500).Error
}
// CountBookItems returns the number of items for a given local book ID.
func (r *PartnumberBookRepository) CountBookItems(bookID uint) int64 {
var count int64
r.db.Model(&localdb.LocalPartnumberBookItem{}).Where("book_id = ?", bookID).Count(&count)
return count
}

View File

@@ -1053,7 +1053,7 @@ func (s *LocalConfigurationService) isOwner(cfg *localdb.LocalConfiguration, own
func (s *LocalConfigurationService) createWithVersion(localCfg *localdb.LocalConfiguration, createdBy string) error {
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
if localCfg.IsActive && localCfg.Line <= 0 {
if localCfg.IsActive {
if err := s.ensureConfigurationLineTx(tx, localCfg); err != nil {
return err
}
@@ -1133,7 +1133,7 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
}
}
if localCfg.IsActive && localCfg.Line <= 0 {
if localCfg.IsActive {
if err := s.ensureConfigurationLineTx(tx, localCfg); err != nil {
return err
}
@@ -1250,17 +1250,61 @@ func (s *LocalConfigurationService) loadVersionForPendingTx(tx *gorm.DB, localCf
if err := tx.Where("configuration_uuid = ?", localCfg.UUID).
Order("version_no DESC").
First(&latest).Error; err != nil {
return nil, fmt.Errorf("load version for pending change: %w", err)
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, fmt.Errorf("load version for pending change: %w", err)
}
// Legacy/imported rows may exist without local version history.
// Bootstrap the first version so pending sync payloads can reference a version.
version, createErr := s.appendVersionTx(tx, localCfg, "bootstrap", "")
if createErr != nil {
return nil, fmt.Errorf("bootstrap version for pending change: %w", createErr)
}
if err := tx.Model(&localdb.LocalConfiguration{}).
Where("uuid = ?", localCfg.UUID).
Update("current_version_id", version.ID).Error; err != nil {
return nil, fmt.Errorf("set current version id for bootstrapped pending change: %w", err)
}
localCfg.CurrentVersionID = &version.ID
return version, nil
}
return &latest, nil
}
func (s *LocalConfigurationService) ensureConfigurationLineTx(tx *gorm.DB, localCfg *localdb.LocalConfiguration) error {
line, err := localdb.NextConfigurationLineTx(tx, localCfg.ProjectUUID, localCfg.UUID)
if err != nil {
return fmt.Errorf("assign line_no for configuration %s: %w", localCfg.UUID, err)
if localCfg == nil || !localCfg.IsActive {
return nil
}
needsAssign := localCfg.Line <= 0
if !needsAssign {
query := tx.Model(&localdb.LocalConfiguration{}).
Where("is_active = ? AND line_no = ?", true, localCfg.Line)
if strings.TrimSpace(localCfg.UUID) != "" {
query = query.Where("uuid <> ?", strings.TrimSpace(localCfg.UUID))
}
if localCfg.ProjectUUID != nil && strings.TrimSpace(*localCfg.ProjectUUID) != "" {
query = query.Where("project_uuid = ?", strings.TrimSpace(*localCfg.ProjectUUID))
} else {
query = query.Where("project_uuid IS NULL OR TRIM(project_uuid) = ''")
}
var conflicts int64
if err := query.Count(&conflicts).Error; err != nil {
return fmt.Errorf("check line_no conflict for configuration %s: %w", localCfg.UUID, err)
}
needsAssign = conflicts > 0
}
if needsAssign {
line, err := localdb.NextConfigurationLineTx(tx, localCfg.ProjectUUID, localCfg.UUID)
if err != nil {
return fmt.Errorf("assign line_no for configuration %s: %w", localCfg.UUID, err)
}
localCfg.Line = line
}
localCfg.Line = line
return nil
}

View File

@@ -0,0 +1,127 @@
package sync
import (
"fmt"
"log/slog"
"time"
"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.
// 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")
mariaDB, err := s.getDB()
if err != nil {
return 0, fmt.Errorf("database not available: %w", err)
}
localBookRepo := repository.NewPartnumberBookRepository(s.localDB.DB())
type serverBook struct {
ID int `gorm:"column:id"`
Version string `gorm:"column:version"`
CreatedAt time.Time `gorm:"column:created_at"`
IsActive bool `gorm:"column:is_active"`
}
var serverBooks []serverBook
if err := mariaDB.Raw("SELECT id, version, created_at, is_active FROM qt_partnumber_books 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 {
var existing localdb.LocalPartnumberBook
err := s.localDB.DB().Where("server_id = ?", sb.ID).First(&existing).Error
if err == nil {
// 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
}
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,
CreatedAt: sb.CreatedAt,
IsActive: sb.IsActive,
}
if err := localBookRepo.SaveBook(localBook); err != nil {
slog.Error("failed to save local partnumber book", "server_id", sb.ID, "error", err)
continue
}
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
}
slog.Info("partnumber book saved locally", "server_id", sb.ID, "version", sb.Version, "items_saved", n)
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,51 @@
package sync
import (
"fmt"
"log/slog"
"time"
)
// SeenPartnumber represents an unresolved vendor partnumber to report.
type SeenPartnumber struct {
Partnumber string
Description string
Ignored bool
}
// 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, is_ignored, last_seen_at)
VALUES
('manual', '', ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
last_seen_at = VALUES(last_seen_at),
is_ignored = VALUES(is_ignored),
description = COALESCE(NULLIF(VALUES(description), ''), description)
`, item.Partnumber, item.Description, item.Ignored, 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

@@ -148,6 +148,9 @@ func (s *Service) ImportConfigurationsToLocal() (*ConfigImportResult, error) {
if localCfg.Line <= 0 && existing.Line > 0 {
localCfg.Line = existing.Line
}
// vendor_spec is local-only for BOM tab and is not stored on server.
// Preserve it during server pull updates.
localCfg.VendorSpec = existing.VendorSpec
result.Updated++
} else {
result.Imported++

View File

@@ -315,6 +315,70 @@ func TestImportConfigurationsToLocalPullsLine(t *testing.T) {
}
}
func TestImportConfigurationsToLocalPreservesLocalVendorSpec(t *testing.T) {
local := newLocalDBForSyncTest(t)
serverDB := newServerDBForSyncTest(t)
cfg := models.Configuration{
UUID: "server-vendorspec-config",
OwnerUsername: "tester",
Name: "Cfg VendorSpec Pull",
Items: models.ConfigItems{{LotName: "CPU_PULL", Quantity: 1, UnitPrice: 900}},
ServerCount: 1,
Line: 50,
}
total := cfg.Items.Total()
cfg.TotalPrice = &total
if err := serverDB.Create(&cfg).Error; err != nil {
t.Fatalf("seed server config: %v", err)
}
localSpec := localdb.VendorSpec{
{
SortOrder: 10,
VendorPartnumber: "GPU-NVHGX-H200-8141",
Quantity: 1,
Description: "NVIDIA HGX Delta-Next GPU Baseboard",
LotMappings: []localdb.VendorSpecLotMapping{
{LotName: "GPU_NV_H200_141GB_SXM_(HGX)", QuantityPerPN: 8},
},
},
}
if err := local.SaveConfiguration(&localdb.LocalConfiguration{
UUID: cfg.UUID,
OriginalUsername: "tester",
Name: "Local cfg",
Items: localdb.LocalConfigItems{{LotName: "CPU_PULL", Quantity: 1, UnitPrice: 900}},
IsActive: true,
SyncStatus: "synced",
Line: 50,
VendorSpec: localSpec,
CreatedAt: time.Now().Add(-30 * time.Minute),
UpdatedAt: time.Now().Add(-30 * time.Minute),
}); err != nil {
t.Fatalf("seed local configuration: %v", err)
}
svc := syncsvc.NewServiceWithDB(serverDB, local)
if _, err := svc.ImportConfigurationsToLocal(); err != nil {
t.Fatalf("import configurations to local: %v", err)
}
localCfg, err := local.GetConfigurationByUUID(cfg.UUID)
if err != nil {
t.Fatalf("load local config: %v", err)
}
if len(localCfg.VendorSpec) != 1 {
t.Fatalf("expected local vendor_spec preserved, got %d rows", len(localCfg.VendorSpec))
}
if localCfg.VendorSpec[0].VendorPartnumber != "GPU-NVHGX-H200-8141" {
t.Fatalf("unexpected vendor_partnumber after import: %q", localCfg.VendorSpec[0].VendorPartnumber)
}
if len(localCfg.VendorSpec[0].LotMappings) != 1 || localCfg.VendorSpec[0].LotMappings[0].LotName != "GPU_NV_H200_141GB_SXM_(HGX)" {
t.Fatalf("unexpected lot mappings after import: %+v", localCfg.VendorSpec[0].LotMappings)
}
}
func TestPushPendingChangesCreateThenUpdateBeforeFirstPush(t *testing.T) {
local := newLocalDBForSyncTest(t)
serverDB := newServerDBForSyncTest(t)

View File

@@ -0,0 +1,129 @@
package services
import (
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/repository"
)
// ResolvedBOMRow is the result of resolving a single vendor BOM row.
type ResolvedBOMRow struct {
localdb.VendorSpecItem
// ResolutionSource already on VendorSpecItem: "book", "manual_suggestion", "unresolved"
}
// AggregatedLOT represents a LOT with its aggregated quantity from the BOM.
type AggregatedLOT struct {
LotName string
Quantity int
}
// VendorSpecResolver resolves vendor BOM rows to LOT names using the active partnumber book.
type VendorSpecResolver struct {
bookRepo *repository.PartnumberBookRepository
}
func NewVendorSpecResolver(bookRepo *repository.PartnumberBookRepository) *VendorSpecResolver {
return &VendorSpecResolver{bookRepo: bookRepo}
}
// Resolve resolves each vendor spec item's lot name using the 3-step algorithm.
// It returns the resolved items. Manual lot suggestions from the input are preserved as pre-fill.
func (r *VendorSpecResolver) Resolve(items []localdb.VendorSpecItem) ([]localdb.VendorSpecItem, error) {
// Step 1: Get the active book
book, err := r.bookRepo.GetActiveBook()
if err != nil {
// No book available — mark all as unresolved
for i := range items {
if items[i].ResolvedLotName == "" {
items[i].ResolutionSource = "unresolved"
}
}
return items, nil
}
for i, item := range items {
pn := item.VendorPartnumber
// Step 1: Look up in active book
matches, err := r.bookRepo.FindLotByPartnumber(book.ID, pn)
if err == nil && len(matches) > 0 {
items[i].ResolvedLotName = matches[0].LotName
items[i].ResolutionSource = "book"
continue
}
// Step 2: Pre-fill from manual_lot_suggestion if provided
if item.ManualLotSuggestion != "" {
items[i].ResolvedLotName = item.ManualLotSuggestion
items[i].ResolutionSource = "manual_suggestion"
continue
}
// Step 3: Unresolved
items[i].ResolvedLotName = ""
items[i].ResolutionSource = "unresolved"
}
return items, nil
}
// AggregateLOTs applies the qty-logic to compute per-LOT quantities from the resolved BOM.
// qty(lot) = SUM(qty of primary PN rows for this lot) if any primary PN exists, else 1.
func AggregateLOTs(items []localdb.VendorSpecItem, book *localdb.LocalPartnumberBook, bookRepo *repository.PartnumberBookRepository) ([]AggregatedLOT, error) {
// Gather all unique lot names that resolved
lotPrimary := make(map[string]int) // lot_name → sum of primary PN quantities
lotAny := make(map[string]bool) // lot_name → seen at least once (non-primary)
lotHasPrimary := make(map[string]bool) // lot_name → has at least one primary PN in spec
if book != nil {
for _, item := range items {
if item.ResolvedLotName == "" {
continue
}
lot := item.ResolvedLotName
pn := item.VendorPartnumber
// Find if this pn is primary for its lot
matches, err := bookRepo.FindLotByPartnumber(book.ID, pn)
if err != nil || len(matches) == 0 {
// manual/unresolved — treat as non-primary
lotAny[lot] = true
continue
}
for _, m := range matches {
if m.LotName == lot {
if m.IsPrimaryPN {
lotPrimary[lot] += item.Quantity
lotHasPrimary[lot] = true
} else {
lotAny[lot] = true
}
}
}
}
} else {
// No book: all resolved rows contribute qty=1 per lot
for _, item := range items {
if item.ResolvedLotName != "" {
lotAny[item.ResolvedLotName] = true
}
}
}
// Build aggregated list
seen := make(map[string]bool)
var result []AggregatedLOT
for _, item := range items {
lot := item.ResolvedLotName
if lot == "" || seen[lot] {
continue
}
seen[lot] = true
qty := 1
if lotHasPrimary[lot] {
qty = lotPrimary[lot]
}
result = append(result, AggregatedLOT{LotName: lot, Quantity: qty})
}
return result, nil
}

6
package-lock.json generated Normal file
View File

@@ -0,0 +1,6 @@
{
"name": "QuoteForge",
"lockfileVersion": 3,
"requires": true,
"packages": {}
}

1
package.json Normal file
View File

@@ -0,0 +1 @@
{}

View File

@@ -21,6 +21,7 @@
<div class="hidden md:flex space-x-4">
<a href="/projects" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Мои проекты</a>
<a href="/pricelists" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Прайслисты</a>
<a href="/partnumber-books" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Партномера</a>
<a href="/setup" class="text-gray-600 hover:text-gray-900 px-3 py-2 text-sm">Настройки</a>
</div>
</div>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,256 @@
{{define "title"}}QuoteForge - Партномера{{end}}
{{define "content"}}
<div class="space-y-4">
<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">
<div class="bg-white rounded-lg shadow p-4">
<div class="text-xs text-gray-500 mb-1">Активный лист</div>
<div id="card-version" class="font-mono font-semibold text-gray-800 text-sm truncate"></div>
<div id="card-date" class="text-xs text-gray-400 mt-0.5"></div>
</div>
<div class="bg-white rounded-lg shadow p-4">
<div class="text-xs text-gray-500 mb-1">Уникальных LOT</div>
<div id="card-lots" class="text-2xl font-bold text-blue-600"></div>
</div>
<div class="bg-white rounded-lg shadow p-4">
<div class="text-xs text-gray-500 mb-1">Всего PN</div>
<div id="card-pn-total" class="text-2xl font-bold text-gray-800"></div>
</div>
<div class="bg-white rounded-lg shadow p-4">
<div class="text-xs text-gray-500 mb-1">Primary PN</div>
<div id="card-pn-primary" class="text-2xl font-bold text-green-600"></div>
</div>
</div>
<div id="summary-empty" class="hidden bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-sm text-yellow-800">
Нет активного листа сопоставлений. Нажмите «Синхронизировать» для загрузки с сервера.
</div>
<!-- Active book items with search -->
<div id="active-book-section" class="hidden bg-white rounded-lg shadow overflow-hidden">
<div class="px-4 py-3 border-b flex items-center justify-between gap-3">
<span class="font-semibold text-gray-800 whitespace-nowrap">Сопоставления активного листа</span>
<input type="text" id="pn-search" placeholder="Поиск по PN или LOT..."
class="flex-1 max-w-xs px-3 py-1.5 border rounded text-sm focus:ring-2 focus:ring-blue-400 focus:outline-none"
oninput="filterItems(this.value)">
<span id="pn-count" class="text-xs text-gray-400 whitespace-nowrap"></span>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-600 sticky top-0">
<tr>
<th class="px-4 py-2 text-left">Partnumber</th>
<th class="px-4 py-2 text-left">LOT</th>
<th class="px-4 py-2 text-center w-24">Primary</th>
<th class="px-4 py-2 text-left">Описание</th>
</tr>
</thead>
<tbody id="active-items-body"></tbody>
</table>
</div>
</div>
<!-- All books list (collapsed by default) -->
<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>
<!-- 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>
<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() {
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 (!allBooks.length) {
document.getElementById('books-empty').classList.remove('hidden');
document.getElementById('summary-empty').classList.remove('hidden');
return;
}
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 = '';
pageBooks.forEach(b => {
const tr = document.createElement('tr');
tr.className = 'border-b hover:bg-gray-50';
tr.innerHTML = `
<td class="px-4 py-2 font-mono text-xs">${b.version}</td>
<td class="px-4 py-2 text-gray-500 text-xs">${b.created_at}</td>
<td class="px-4 py-2 text-right text-xs">${b.item_count}</td>
<td class="px-4 py-2 text-center">
${b.is_active
? '<span class="px-2 py-0.5 bg-green-100 text-green-700 rounded text-xs">Активный</span>'
: '<span class="px-2 py-0.5 bg-gray-100 text-gray-500 rounded text-xs">Архив</span>'}
</td>
`;
tbody.appendChild(tr);
});
document.getElementById('books-table').classList.remove('hidden');
// 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) {
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 || [];
const lots = new Set(allItems.map(i => i.lot_name));
const primaryCount = allItems.filter(i => i.is_primary_pn).length;
document.getElementById('card-version').textContent = book.version;
document.getElementById('card-date').textContent = book.created_at;
document.getElementById('card-lots').textContent = lots.size;
document.getElementById('card-pn-total').textContent = allItems.length;
document.getElementById('card-pn-primary').textContent = primaryCount;
document.getElementById('summary-cards').classList.remove('hidden');
document.getElementById('active-book-section').classList.remove('hidden');
renderItems(allItems);
}
function renderItems(items) {
const tbody = document.getElementById('active-items-body');
tbody.innerHTML = '';
items.forEach(item => {
const tr = document.createElement('tr');
tr.className = 'border-b hover:bg-gray-50';
tr.innerHTML = `
<td class="px-4 py-1.5 font-mono text-xs">${item.partnumber}</td>
<td class="px-4 py-1.5 text-xs font-medium text-blue-700">${item.lot_name}</td>
<td class="px-4 py-1.5 text-center text-green-600 text-xs">${item.is_primary_pn ? '✓' : ''}</td>
<td class="px-4 py-1.5 text-xs text-gray-500">${item.description || ''}</td>
`;
tbody.appendChild(tr);
});
document.getElementById('pn-count').textContent = `${items.length} из ${allItems.length}`;
}
function filterItems(query) {
const q = query.trim().toLowerCase();
if (!q) { renderItems(allItems); return; }
renderItems(allItems.filter(i =>
i.partnumber.toLowerCase().includes(q) || i.lot_name.toLowerCase().includes(q)
));
}
async function syncPartnumberBooks() {
let resp, data;
try {
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');
}
}
document.addEventListener('DOMContentLoaded', loadBooks);
</script>
{{end}}
{{template "base" .}}

View File

@@ -373,7 +373,7 @@ function renderConfigs(configs) {
const serverCount = c.server_count || 1;
const author = c.owner_username || (c.user && c.user.username) || '—';
const unitPrice = serverCount > 0 ? (total / serverCount) : 0;
const lineValue = (typeof c.line === 'number' && c.line > 0) ? c.line : ((idx + 1) * 10);
const lineValue = (idx + 1) * 10;
const serverModel = (c.server_model || '').trim() || '—';
totalSum += total;