Compare commits
26 Commits
v1.3.5
...
42458455f7
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
42458455f7 | ||
| 2f0957ae4e | |||
| 65db9b37ea | |||
| ed0ef04d10 | |||
| 2e0faf4aec | |||
| 4b0879779a | |||
| 2b175a3d1e | |||
| 5732c75b85 | |||
| eb7c3739ce | |||
|
|
6e0335af7c | ||
|
|
a42a80beb8 | ||
|
|
586114c79c | ||
|
|
e9230c0e58 | ||
|
|
aa65fc8156 | ||
|
|
b22e961656 | ||
|
|
af83818564 | ||
|
|
8a138327a3 | ||
|
|
d1f65f6684 | ||
| d0400b18a3 | |||
| d3f1a838eb | |||
| c6086ac03a | |||
| a127ebea82 | |||
| 347599e06b | |||
| 4a44d48366 | |||
| 23882637b5 | |||
| 5e56f386cc |
3
.gitmodules
vendored
Normal file
3
.gitmodules
vendored
Normal file
@@ -0,0 +1,3 @@
|
||||
[submodule "bible"]
|
||||
path = bible
|
||||
url = https://git.mchus.pro/mchus/bible.git
|
||||
11
AGENTS.md
Normal file
11
AGENTS.md
Normal 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/`.
|
||||
29
CLAUDE.md
29
CLAUDE.md
@@ -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
1
bible
Submodule
Submodule bible added at 34b457d654
@@ -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
|
||||
|
||||
---
|
||||
@@ -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 |
|
||||
|
||||
---
|
||||
@@ -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
|
||||
|
||||
364
bible-local/09-vendor-spec.md
Normal file
364
bible-local/09-vendor-spec.md
Normal 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 |
|
||||
@@ -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)
|
||||
|
||||
@@ -195,6 +195,9 @@ func buildGPUSegment(items []models.ConfigItem, cats map[string]string) string {
|
||||
if !ok || group != GroupGPU {
|
||||
continue
|
||||
}
|
||||
if strings.HasPrefix(strings.ToUpper(it.LotName), "MB_") {
|
||||
continue
|
||||
}
|
||||
model := parseGPUModel(it.LotName)
|
||||
if model == "" {
|
||||
model = "UNK"
|
||||
@@ -332,7 +335,7 @@ func parseGPUModel(lotName string) string {
|
||||
continue
|
||||
}
|
||||
switch p {
|
||||
case "NV", "NVIDIA", "AMD", "RADEON", "PCIE", "PCI", "SXM", "SXMX":
|
||||
case "NV", "NVIDIA", "INTEL", "AMD", "RADEON", "PCIE", "PCI", "SXM", "SXMX":
|
||||
continue
|
||||
default:
|
||||
if strings.Contains(p, "GB") {
|
||||
|
||||
90
internal/handlers/partnumber_books.go
Normal file
90
internal/handlers/partnumber_books.go
Normal file
@@ -0,0 +1,90 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
// PartnumberBooksHandler provides read-only access to local partnumber book snapshots.
|
||||
type PartnumberBooksHandler struct {
|
||||
localDB *localdb.LocalDB
|
||||
}
|
||||
|
||||
func NewPartnumberBooksHandler(localDB *localdb.LocalDB) *PartnumberBooksHandler {
|
||||
return &PartnumberBooksHandler{localDB: localDB}
|
||||
}
|
||||
|
||||
// List returns all local partnumber book snapshots.
|
||||
// GET /api/partnumber-books
|
||||
func (h *PartnumberBooksHandler) List(c *gin.Context) {
|
||||
bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB())
|
||||
books, err := bookRepo.ListBooks()
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
type bookSummary struct {
|
||||
ID uint `json:"id"`
|
||||
ServerID int `json:"server_id"`
|
||||
Version string `json:"version"`
|
||||
CreatedAt string `json:"created_at"`
|
||||
IsActive bool `json:"is_active"`
|
||||
ItemCount int64 `json:"item_count"`
|
||||
}
|
||||
|
||||
summaries := make([]bookSummary, 0, len(books))
|
||||
for _, b := range books {
|
||||
summaries = append(summaries, bookSummary{
|
||||
ID: b.ID,
|
||||
ServerID: b.ServerID,
|
||||
Version: b.Version,
|
||||
CreatedAt: b.CreatedAt.Format("2006-01-02"),
|
||||
IsActive: b.IsActive,
|
||||
ItemCount: bookRepo.CountBookItems(b.ID),
|
||||
})
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"books": summaries,
|
||||
"total": len(summaries),
|
||||
})
|
||||
}
|
||||
|
||||
// GetItems returns items for a partnumber book by server ID.
|
||||
// GET /api/partnumber-books/:id
|
||||
func (h *PartnumberBooksHandler) GetItems(c *gin.Context) {
|
||||
idStr := c.Param("id")
|
||||
id, err := strconv.ParseUint(idStr, 10, 64)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid book ID"})
|
||||
return
|
||||
}
|
||||
|
||||
bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB())
|
||||
|
||||
// Find local book by server_id
|
||||
var book localdb.LocalPartnumberBook
|
||||
if err := h.localDB.DB().Where("server_id = ?", id).First(&book).Error; err != nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "partnumber book not found"})
|
||||
return
|
||||
}
|
||||
|
||||
items, err := bookRepo.GetBookItems(book.ID)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"book_id": book.ServerID,
|
||||
"version": book.Version,
|
||||
"is_active": book.IsActive,
|
||||
"items": items,
|
||||
"total": len(items),
|
||||
})
|
||||
}
|
||||
@@ -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)})
|
||||
}
|
||||
|
||||
209
internal/handlers/vendor_spec.go
Normal file
209
internal/handlers/vendor_spec.go
Normal 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})
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -864,3 +864,4 @@ WHERE id IN (SELECT id FROM ranked)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
66
internal/repository/partnumber_book.go
Normal file
66
internal/repository/partnumber_book.go
Normal file
@@ -0,0 +1,66 @@
|
||||
package repository
|
||||
|
||||
import (
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// PartnumberBookRepository provides read-only access to local partnumber book snapshots.
|
||||
type PartnumberBookRepository struct {
|
||||
db *gorm.DB
|
||||
}
|
||||
|
||||
func NewPartnumberBookRepository(db *gorm.DB) *PartnumberBookRepository {
|
||||
return &PartnumberBookRepository{db: db}
|
||||
}
|
||||
|
||||
// GetActiveBook returns the most recently active local partnumber book.
|
||||
func (r *PartnumberBookRepository) GetActiveBook() (*localdb.LocalPartnumberBook, error) {
|
||||
var book localdb.LocalPartnumberBook
|
||||
err := r.db.Where("is_active = 1").Order("created_at DESC, id DESC").First(&book).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &book, nil
|
||||
}
|
||||
|
||||
// GetBookItems returns all items for the given local book ID.
|
||||
func (r *PartnumberBookRepository) GetBookItems(bookID uint) ([]localdb.LocalPartnumberBookItem, error) {
|
||||
var items []localdb.LocalPartnumberBookItem
|
||||
err := r.db.Where("book_id = ?", bookID).Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
// FindLotByPartnumber looks up a partnumber in the active book and returns the matching items.
|
||||
func (r *PartnumberBookRepository) FindLotByPartnumber(bookID uint, partnumber string) ([]localdb.LocalPartnumberBookItem, error) {
|
||||
var items []localdb.LocalPartnumberBookItem
|
||||
err := r.db.Where("book_id = ? AND partnumber = ?", bookID, partnumber).Find(&items).Error
|
||||
return items, err
|
||||
}
|
||||
|
||||
// ListBooks returns all local partnumber books ordered newest first.
|
||||
func (r *PartnumberBookRepository) ListBooks() ([]localdb.LocalPartnumberBook, error) {
|
||||
var books []localdb.LocalPartnumberBook
|
||||
err := r.db.Order("created_at DESC, id DESC").Find(&books).Error
|
||||
return books, err
|
||||
}
|
||||
|
||||
// SaveBook saves a new partnumber book snapshot.
|
||||
func (r *PartnumberBookRepository) SaveBook(book *localdb.LocalPartnumberBook) error {
|
||||
return r.db.Save(book).Error
|
||||
}
|
||||
|
||||
// SaveBookItems bulk-inserts items for a book snapshot.
|
||||
func (r *PartnumberBookRepository) SaveBookItems(items []localdb.LocalPartnumberBookItem) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
return r.db.CreateInBatches(items, 500).Error
|
||||
}
|
||||
|
||||
// CountBookItems returns the number of items for a given local book ID.
|
||||
func (r *PartnumberBookRepository) CountBookItems(bookID uint) int64 {
|
||||
var count int64
|
||||
r.db.Model(&localdb.LocalPartnumberBookItem{}).Where("book_id = ?", bookID).Count(&count)
|
||||
return count
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -289,6 +289,13 @@ func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLeve
|
||||
result.ResolvedPricelistIDs[sourceKey] = latest.ID
|
||||
}
|
||||
}
|
||||
if st.id == 0 && s.localDB != nil {
|
||||
localPL, err := s.localDB.GetLatestLocalPricelistBySource(sourceKey)
|
||||
if err == nil && localPL != nil {
|
||||
st.id = localPL.ServerID
|
||||
result.ResolvedPricelistIDs[sourceKey] = localPL.ServerID
|
||||
}
|
||||
}
|
||||
if st.id == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
127
internal/services/sync/partnumber_books.go
Normal file
127
internal/services/sync/partnumber_books.go
Normal 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
|
||||
}
|
||||
51
internal/services/sync/partnumber_seen.go
Normal file
51
internal/services/sync/partnumber_seen.go
Normal 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
|
||||
}
|
||||
@@ -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++
|
||||
|
||||
@@ -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)
|
||||
|
||||
129
internal/services/vendor_spec_resolver.go
Normal file
129
internal/services/vendor_spec_resolver.go
Normal file
@@ -0,0 +1,129 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
)
|
||||
|
||||
// ResolvedBOMRow is the result of resolving a single vendor BOM row.
|
||||
type ResolvedBOMRow struct {
|
||||
localdb.VendorSpecItem
|
||||
// ResolutionSource already on VendorSpecItem: "book", "manual_suggestion", "unresolved"
|
||||
}
|
||||
|
||||
// AggregatedLOT represents a LOT with its aggregated quantity from the BOM.
|
||||
type AggregatedLOT struct {
|
||||
LotName string
|
||||
Quantity int
|
||||
}
|
||||
|
||||
// VendorSpecResolver resolves vendor BOM rows to LOT names using the active partnumber book.
|
||||
type VendorSpecResolver struct {
|
||||
bookRepo *repository.PartnumberBookRepository
|
||||
}
|
||||
|
||||
func NewVendorSpecResolver(bookRepo *repository.PartnumberBookRepository) *VendorSpecResolver {
|
||||
return &VendorSpecResolver{bookRepo: bookRepo}
|
||||
}
|
||||
|
||||
// Resolve resolves each vendor spec item's lot name using the 3-step algorithm.
|
||||
// It returns the resolved items. Manual lot suggestions from the input are preserved as pre-fill.
|
||||
func (r *VendorSpecResolver) Resolve(items []localdb.VendorSpecItem) ([]localdb.VendorSpecItem, error) {
|
||||
// Step 1: Get the active book
|
||||
book, err := r.bookRepo.GetActiveBook()
|
||||
if err != nil {
|
||||
// No book available — mark all as unresolved
|
||||
for i := range items {
|
||||
if items[i].ResolvedLotName == "" {
|
||||
items[i].ResolutionSource = "unresolved"
|
||||
}
|
||||
}
|
||||
return items, nil
|
||||
}
|
||||
|
||||
for i, item := range items {
|
||||
pn := item.VendorPartnumber
|
||||
|
||||
// Step 1: Look up in active book
|
||||
matches, err := r.bookRepo.FindLotByPartnumber(book.ID, pn)
|
||||
if err == nil && len(matches) > 0 {
|
||||
items[i].ResolvedLotName = matches[0].LotName
|
||||
items[i].ResolutionSource = "book"
|
||||
continue
|
||||
}
|
||||
|
||||
// Step 2: Pre-fill from manual_lot_suggestion if provided
|
||||
if item.ManualLotSuggestion != "" {
|
||||
items[i].ResolvedLotName = item.ManualLotSuggestion
|
||||
items[i].ResolutionSource = "manual_suggestion"
|
||||
continue
|
||||
}
|
||||
|
||||
// Step 3: Unresolved
|
||||
items[i].ResolvedLotName = ""
|
||||
items[i].ResolutionSource = "unresolved"
|
||||
}
|
||||
|
||||
return items, nil
|
||||
}
|
||||
|
||||
// AggregateLOTs applies the qty-logic to compute per-LOT quantities from the resolved BOM.
|
||||
// qty(lot) = SUM(qty of primary PN rows for this lot) if any primary PN exists, else 1.
|
||||
func AggregateLOTs(items []localdb.VendorSpecItem, book *localdb.LocalPartnumberBook, bookRepo *repository.PartnumberBookRepository) ([]AggregatedLOT, error) {
|
||||
// Gather all unique lot names that resolved
|
||||
lotPrimary := make(map[string]int) // lot_name → sum of primary PN quantities
|
||||
lotAny := make(map[string]bool) // lot_name → seen at least once (non-primary)
|
||||
lotHasPrimary := make(map[string]bool) // lot_name → has at least one primary PN in spec
|
||||
|
||||
if book != nil {
|
||||
for _, item := range items {
|
||||
if item.ResolvedLotName == "" {
|
||||
continue
|
||||
}
|
||||
lot := item.ResolvedLotName
|
||||
pn := item.VendorPartnumber
|
||||
|
||||
// Find if this pn is primary for its lot
|
||||
matches, err := bookRepo.FindLotByPartnumber(book.ID, pn)
|
||||
if err != nil || len(matches) == 0 {
|
||||
// manual/unresolved — treat as non-primary
|
||||
lotAny[lot] = true
|
||||
continue
|
||||
}
|
||||
for _, m := range matches {
|
||||
if m.LotName == lot {
|
||||
if m.IsPrimaryPN {
|
||||
lotPrimary[lot] += item.Quantity
|
||||
lotHasPrimary[lot] = true
|
||||
} else {
|
||||
lotAny[lot] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// No book: all resolved rows contribute qty=1 per lot
|
||||
for _, item := range items {
|
||||
if item.ResolvedLotName != "" {
|
||||
lotAny[item.ResolvedLotName] = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Build aggregated list
|
||||
seen := make(map[string]bool)
|
||||
var result []AggregatedLOT
|
||||
for _, item := range items {
|
||||
lot := item.ResolvedLotName
|
||||
if lot == "" || seen[lot] {
|
||||
continue
|
||||
}
|
||||
seen[lot] = true
|
||||
qty := 1
|
||||
if lotHasPrimary[lot] {
|
||||
qty = lotPrimary[lot]
|
||||
}
|
||||
result = append(result, AggregatedLOT{LotName: lot, Quantity: qty})
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
6
package-lock.json
generated
Normal file
6
package-lock.json
generated
Normal file
@@ -0,0 +1,6 @@
|
||||
{
|
||||
"name": "QuoteForge",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {}
|
||||
}
|
||||
1
package.json
Normal file
1
package.json
Normal file
@@ -0,0 +1 @@
|
||||
{}
|
||||
@@ -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
256
web/templates/partnumber_books.html
Normal file
256
web/templates/partnumber_books.html
Normal 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" .}}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -97,6 +97,40 @@ function formatDateTime(value) {
|
||||
});
|
||||
}
|
||||
|
||||
function formatDateParts(value) {
|
||||
if (!value) return null;
|
||||
const date = new Date(value);
|
||||
if (Number.isNaN(date.getTime())) return null;
|
||||
return {
|
||||
date: date.toLocaleDateString('ru-RU', {
|
||||
year: 'numeric',
|
||||
month: '2-digit',
|
||||
day: '2-digit'
|
||||
}),
|
||||
time: date.toLocaleTimeString('ru-RU', {
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
};
|
||||
}
|
||||
|
||||
function renderAuditCell(value, user) {
|
||||
const parts = formatDateParts(value);
|
||||
const safeUser = escapeHtml((user || '—').trim() || '—');
|
||||
if (!parts) {
|
||||
return '<div class="leading-tight">' +
|
||||
'<div class="text-gray-400">—</div>' +
|
||||
'<div class="text-gray-400">—</div>' +
|
||||
'<div class="text-gray-500">@ ' + safeUser + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
return '<div class="leading-tight whitespace-nowrap">' +
|
||||
'<div>' + escapeHtml(parts.date) + '</div>' +
|
||||
'<div class="text-gray-500">' + escapeHtml(parts.time) + '</div>' +
|
||||
'<div class="text-gray-600">@ ' + safeUser + '</div>' +
|
||||
'</div>';
|
||||
}
|
||||
|
||||
function normalizeVariant(variant) {
|
||||
const trimmed = (variant || '').trim();
|
||||
return trimmed === '' ? 'main' : trimmed;
|
||||
@@ -225,20 +259,20 @@ async function loadProjects() {
|
||||
return;
|
||||
}
|
||||
|
||||
let html = '<div class="overflow-x-auto"><table class="w-full">';
|
||||
let html = '<div class="overflow-x-auto"><table class="w-full table-fixed min-w-[980px]">';
|
||||
html += '<thead class="bg-gray-50">';
|
||||
html += '<tr>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Код</th>';
|
||||
html += '<th class="w-28 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Код</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">';
|
||||
html += '<button type="button" onclick="toggleSort(\'name\')" class="inline-flex items-center gap-1 hover:text-gray-700">Название';
|
||||
if (sortField === 'name') {
|
||||
html += sortDir === 'asc' ? ' <span>↑</span>' : ' <span>↓</span>';
|
||||
}
|
||||
html += '</button></th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Создан @ автор</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Изменен @ кто</th>';
|
||||
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Варианты</th>';
|
||||
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
|
||||
html += '<th class="w-44 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Создан</th>';
|
||||
html += '<th class="w-44 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Изменен</th>';
|
||||
html += '<th class="w-36 px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Варианты</th>';
|
||||
html += '<th class="w-36 px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
|
||||
html += '</tr>';
|
||||
html += '<tr>';
|
||||
html += '<th class="px-4 py-2"></th>';
|
||||
@@ -259,14 +293,12 @@ async function loadProjects() {
|
||||
const displayName = p.name || '';
|
||||
const createdBy = p.owner_username || '—';
|
||||
const updatedBy = '—';
|
||||
const createdLabel = formatDateTime(p.created_at) + ' @ ' + createdBy;
|
||||
const updatedLabel = formatDateTime(p.updated_at) + ' @ ' + updatedBy;
|
||||
const variantChips = renderVariantChips(p.code, p.variant, p.uuid);
|
||||
html += '<td class="px-4 py-3 text-sm font-medium"><a class="text-blue-600 hover:underline" href="/projects/' + p.uuid + '">' + escapeHtml(p.code || '—') + '</a></td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-700">' + escapeHtml(displayName) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(createdLabel) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-600">' + escapeHtml(updatedLabel) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm">' + variantChips + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm font-medium align-top"><a class="inline-block max-w-full text-blue-600 hover:underline whitespace-nowrap" href="/projects/' + p.uuid + '">' + escapeHtml(p.code || '—') + '</a></td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-700 align-top"><div class="truncate" title="' + escapeHtml(displayName) + '">' + escapeHtml(displayName || '—') + '</div></td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-600 align-top">' + renderAuditCell(p.created_at, createdBy) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-gray-600 align-top">' + renderAuditCell(p.updated_at, updatedBy) + '</td>';
|
||||
html += '<td class="px-4 py-3 text-sm align-top"><div class="flex flex-wrap gap-1">' + variantChips + '</div></td>';
|
||||
html += '<td class="px-4 py-3 text-sm text-right"><div class="inline-flex items-center gap-2">';
|
||||
|
||||
if (p.is_active) {
|
||||
|
||||
Reference in New Issue
Block a user