diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000..fa8f9e7 --- /dev/null +++ b/.gitmodules @@ -0,0 +1,3 @@ +[submodule "bible"] + path = bible + url = https://git.mchus.pro/mchus/bible.git diff --git a/AGENTS.md b/AGENTS.md new file mode 100644 index 0000000..7320f1e --- /dev/null +++ b/AGENTS.md @@ -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/`. diff --git a/CLAUDE.md b/CLAUDE.md index 0bab65c..7744f93 100644 --- a/CLAUDE.md +++ b/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 ``` diff --git a/bible b/bible new file mode 160000 index 0000000..472c8a6 --- /dev/null +++ b/bible @@ -0,0 +1 @@ +Subproject commit 472c8a6918913a2500e238d54f9c819597de8ab9 diff --git a/bible/01-overview.md b/bible-local/01-overview.md similarity index 100% rename from bible/01-overview.md rename to bible-local/01-overview.md diff --git a/bible/02-architecture.md b/bible-local/02-architecture.md similarity index 100% rename from bible/02-architecture.md rename to bible-local/02-architecture.md diff --git a/bible/03-database.md b/bible-local/03-database.md similarity index 64% rename from bible/03-database.md rename to bible-local/03-database.md index d9f6f34..741f71e 100644 --- a/bible/03-database.md +++ b/bible-local/03-database.md @@ -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 ''@'%'; GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO ''@'%'; GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO ''@'%'; +GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO ''@'%'; +GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO ''@'%'; +GRANT INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen TO ''@'%'; + 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 --- diff --git a/bible/04-api.md b/bible-local/04-api.md similarity index 76% rename from bible/04-api.md rename to bible-local/04-api.md index 65d8687..22384e3 100644 --- a/bible/04-api.md +++ b/bible-local/04-api.md @@ -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 | --- diff --git a/bible/05-config.md b/bible-local/05-config.md similarity index 100% rename from bible/05-config.md rename to bible-local/05-config.md diff --git a/bible/06-backup.md b/bible-local/06-backup.md similarity index 100% rename from bible/06-backup.md rename to bible-local/06-backup.md diff --git a/bible/07-dev.md b/bible-local/07-dev.md similarity index 95% rename from bible/07-dev.md rename to bible-local/07-dev.md index f3995ca..538e6f1 100644 --- a/bible/07-dev.md +++ b/bible-local/07-dev.md @@ -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 diff --git a/bible-local/09-vendor-spec.md b/bible-local/09-vendor-spec.md new file mode 100644 index 0000000..5d726a4 --- /dev/null +++ b/bible-local/09-vendor-spec.md @@ -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 ''@'%'; +GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO ''@'%'; +``` + +### `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_.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 | diff --git a/bible/README.md b/bible-local/README.md similarity index 100% rename from bible/README.md rename to bible-local/README.md diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index dd0aa14..86deb4e 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -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) diff --git a/internal/handlers/partnumber_books.go b/internal/handlers/partnumber_books.go new file mode 100644 index 0000000..a5c9470 --- /dev/null +++ b/internal/handlers/partnumber_books.go @@ -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), + }) +} diff --git a/internal/handlers/sync.go b/internal/handlers/sync.go index 0f82ad2..77372ab 100644 --- a/internal/handlers/sync.go +++ b/internal/handlers/sync.go @@ -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)}) +} diff --git a/internal/handlers/vendor_spec.go b/internal/handlers/vendor_spec.go new file mode 100644 index 0000000..42bc9d4 --- /dev/null +++ b/internal/handlers/vendor_spec.go @@ -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}) +} diff --git a/internal/handlers/web.go b/internal/handlers/web.go index f87153f..c841b36 100644 --- a/internal/handlers/web.go +++ b/internal/handlers/web.go @@ -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) { diff --git a/internal/localdb/localdb.go b/internal/localdb/localdb.go index 79a05e5..80928e6 100644 --- a/internal/localdb/localdb.go +++ b/internal/localdb/localdb.go @@ -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) } diff --git a/internal/localdb/migrations.go b/internal/localdb/migrations.go index adaeebe..1dc8d04 100644 --- a/internal/localdb/migrations.go +++ b/internal/localdb/migrations.go @@ -864,3 +864,4 @@ WHERE id IN (SELECT id FROM ranked) return nil } + diff --git a/internal/localdb/models.go b/internal/localdb/models.go index 1d5b190..b85974f 100644 --- a/internal/localdb/models.go +++ b/internal/localdb/models.go @@ -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) +} diff --git a/internal/repository/partnumber_book.go b/internal/repository/partnumber_book.go new file mode 100644 index 0000000..26f034d --- /dev/null +++ b/internal/repository/partnumber_book.go @@ -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 +} diff --git a/internal/services/local_configuration.go b/internal/services/local_configuration.go index 9a27db6..18895d4 100644 --- a/internal/services/local_configuration.go +++ b/internal/services/local_configuration.go @@ -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 } diff --git a/internal/services/sync/partnumber_books.go b/internal/services/sync/partnumber_books.go new file mode 100644 index 0000000..0647e6e --- /dev/null +++ b/internal/services/sync/partnumber_books.go @@ -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 +} diff --git a/internal/services/sync/partnumber_seen.go b/internal/services/sync/partnumber_seen.go new file mode 100644 index 0000000..3903d55 --- /dev/null +++ b/internal/services/sync/partnumber_seen.go @@ -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 +} diff --git a/internal/services/sync/service.go b/internal/services/sync/service.go index 3451d1e..f914869 100644 --- a/internal/services/sync/service.go +++ b/internal/services/sync/service.go @@ -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++ diff --git a/internal/services/sync/service_projects_push_test.go b/internal/services/sync/service_projects_push_test.go index 6d2efd0..687c2c0 100644 --- a/internal/services/sync/service_projects_push_test.go +++ b/internal/services/sync/service_projects_push_test.go @@ -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) diff --git a/internal/services/vendor_spec_resolver.go b/internal/services/vendor_spec_resolver.go new file mode 100644 index 0000000..e003702 --- /dev/null +++ b/internal/services/vendor_spec_resolver.go @@ -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 +} diff --git a/package-lock.json b/package-lock.json new file mode 100644 index 0000000..99c5667 --- /dev/null +++ b/package-lock.json @@ -0,0 +1,6 @@ +{ + "name": "QuoteForge", + "lockfileVersion": 3, + "requires": true, + "packages": {} +} diff --git a/package.json b/package.json new file mode 100644 index 0000000..0967ef4 --- /dev/null +++ b/package.json @@ -0,0 +1 @@ +{} diff --git a/web/templates/base.html b/web/templates/base.html index 535d826..9ba9e68 100644 --- a/web/templates/base.html +++ b/web/templates/base.html @@ -21,6 +21,7 @@ diff --git a/web/templates/index.html b/web/templates/index.html index 91663b0..edd3589 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -63,6 +63,27 @@ + +
+ +
+ + +
+
@@ -122,113 +143,116 @@
- -
- -
-
-
- -
- $ - - + + + + + + +
+ + + @@ -787,6 +811,11 @@ document.addEventListener('DOMContentLoaded', async function() { hideAutocomplete(); } }); + + // Load vendor spec BOM for this configuration + if (configUUID) { + loadVendorSpec(configUUID); + } }); async function loadAllComponents() { @@ -794,11 +823,22 @@ async function loadAllComponents() { const resp = await fetch('/api/components?per_page=5000'); const data = await resp.json(); allComponents = data.components || []; + window._bomAllComponents = allComponents; } catch(e) { console.error('Failed to load components', e); allComponents = []; + window._bomAllComponents = []; } } +function _bomLots() { + return [...new Set((window._bomAllComponents || allComponents).map(c => c.lot_name).filter(Boolean))].sort(); +} +const BOM_LOT_DATALIST_DIVIDER = '────────'; +function _bomLotValid(v) { + const lot = (v || '').trim(); + if (!lot || lot === BOM_LOT_DATALIST_DIVIDER) return false; + return (window._bomAllComponents || allComponents).some(c => c.lot_name === lot); +} function updateServerCount() { const serverCountInput = document.getElementById('server-count'); @@ -1775,6 +1815,7 @@ function removeFromCart(lotName) { } function updateCartUI() { + window._currentCart = cart; // expose for BOM/Pricing tabs const total = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0); document.getElementById('cart-total').textContent = formatMoney(total); @@ -2270,13 +2311,25 @@ function renderSalePriceTable() { // Custom price functionality function calculateCustomPrice() { const customPriceInput = document.getElementById('custom-price-input'); + const adjustedPricesEl = document.getElementById('adjusted-prices'); + const discountInfoEl = document.getElementById('discount-info'); + const discountPercentEl = document.getElementById('discount-percent'); + const adjustedPricesBodyEl = document.getElementById('adjusted-prices-body'); + const adjustedTotalOriginalEl = document.getElementById('adjusted-total-original'); + const adjustedTotalNewEl = document.getElementById('adjusted-total-new'); + const adjustedTotalFinalEl = document.getElementById('adjusted-total-final'); + + if (!customPriceInput || !adjustedPricesEl || !discountInfoEl || !discountPercentEl || + !adjustedPricesBodyEl || !adjustedTotalOriginalEl || !adjustedTotalNewEl || !adjustedTotalFinalEl) { + return; + } const customPrice = parseFloat(customPriceInput.value) || 0; const originalTotal = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0); if (customPrice <= 0 || cart.length === 0 || originalTotal <= 0) { - document.getElementById('adjusted-prices').classList.add('hidden'); - document.getElementById('discount-info').classList.add('hidden'); + adjustedPricesEl.classList.add('hidden'); + discountInfoEl.classList.add('hidden'); return; } @@ -2285,11 +2338,11 @@ function calculateCustomPrice() { const coefficient = customPrice / originalTotal; // Show discount info - document.getElementById('discount-info').classList.remove('hidden'); - document.getElementById('discount-percent').textContent = discountPercent.toFixed(1) + '%'; + discountInfoEl.classList.remove('hidden'); + discountPercentEl.textContent = discountPercent.toFixed(1) + '%'; // Update discount color based on value - const discountEl = document.getElementById('discount-percent'); + const discountEl = discountPercentEl; if (discountPercent > 0) { discountEl.className = 'text-2xl font-bold text-green-600'; } else if (discountPercent < 0) { @@ -2332,17 +2385,21 @@ function calculateCustomPrice() { `; }); - document.getElementById('adjusted-prices-body').innerHTML = html; - document.getElementById('adjusted-total-original').textContent = formatMoney(totalOriginal); - document.getElementById('adjusted-total-new').textContent = formatMoney(totalNew); - document.getElementById('adjusted-total-final').textContent = formatMoney(totalNew); - document.getElementById('adjusted-prices').classList.remove('hidden'); + adjustedPricesBodyEl.innerHTML = html; + adjustedTotalOriginalEl.textContent = formatMoney(totalOriginal); + adjustedTotalNewEl.textContent = formatMoney(totalNew); + adjustedTotalFinalEl.textContent = formatMoney(totalNew); + adjustedPricesEl.classList.remove('hidden'); } function clearCustomPrice() { - document.getElementById('custom-price-input').value = ''; - document.getElementById('adjusted-prices').classList.add('hidden'); - document.getElementById('discount-info').classList.add('hidden'); + const customPriceInput = document.getElementById('custom-price-input'); + const adjustedPricesEl = document.getElementById('adjusted-prices'); + const discountInfoEl = document.getElementById('discount-info'); + + if (customPriceInput) customPriceInput.value = ''; + if (adjustedPricesEl) adjustedPricesEl.classList.add('hidden'); + if (discountInfoEl) discountInfoEl.classList.add('hidden'); triggerAutoSave(); } @@ -2486,6 +2543,1357 @@ function updatePriceUpdateDate(dateStr) { document.getElementById('price-update-date').textContent = 'Обновлено: ' + timeAgo; } +// ==================== TOP-LEVEL TABS ==================== + +let currentTopTab = 'estimate'; + +function switchTopTab(tab) { + currentTopTab = tab; + const tabs = ['estimate', 'bom', 'pricing']; + tabs.forEach(t => { + const btn = document.getElementById('top-tab-' + t); + const section = document.getElementById('top-section-' + t); + if (t === tab) { + btn.classList.remove('border-transparent', 'text-gray-500'); + btn.classList.add('border-blue-600', 'text-blue-600'); + section.classList.remove('hidden'); + } else { + btn.classList.remove('border-blue-600', 'text-blue-600'); + btn.classList.add('border-transparent', 'text-gray-500'); + section.classList.add('hidden'); + } + }); + if (tab === 'pricing') { + renderPricingTab(); + } +} + +// ==================== BOM ВЕНДОРА ==================== + +let bomRows = []; // [{vendor_pn, quantity, description, unit_price, total_price, resolved_lot, resolution_source}] +let bomImportRaw = null; // { mode:'raw'|'parsed', rows, columnTypes, ignoredRows, rowErrors, uiError } + +const BOM_COL_TYPES = [ + { value: 'ignore', label: 'Не использовать' }, + { value: 'pn', label: 'P/N' }, + { value: 'qty', label: 'Кол-во' }, + { value: 'price', label: 'Цена' }, + { value: 'description', label: 'Описание' } +]; + +function _bomRawHeaderWidthClass(colType) { + switch (colType) { + case 'qty': return 'w-24 min-w-24'; + case 'price': return 'w-32 min-w-32'; + case 'pn': return 'min-w-40'; + case 'description': return 'min-w-48'; + default: return 'min-w-28'; + } +} + +function _bomRawCellWidthClass(colType) { + switch (colType) { + case 'qty': return 'w-24 min-w-24'; + case 'price': return 'w-32 min-w-32'; + default: return ''; + } +} + +function parsePastePrice(s) { + if (!s) return null; + let v = String(s).replace(/[$\s]/g, ''); + if (/,\d{1,2}$/.test(v)) v = v.replace(/\./g, '').replace(',', '.'); + else v = v.replace(/,/g, ''); + const n = parseFloat(v); + return isNaN(n) ? null : n; +} + +function _ensureBomDatalist() { + let dl = document.getElementById('lot-autocomplete-list'); + if (!dl) { + dl = document.createElement('datalist'); + dl.id = 'lot-autocomplete-list'; + document.body.appendChild(dl); + } + const all = _bomLots(); + const cartLots = []; + const seenCart = new Set(); + (window._currentCart || []).forEach(item => { + const lot = (item?.lot_name || '').trim(); + if (!lot || seenCart.has(lot)) return; + seenCart.add(lot); + cartLots.push(lot); + }); + + const mappedSet = new Set(); + bomRows.forEach(r => { + _getRowCanonicalLotMappings(r).forEach(m => { + if (m?.lot_name) mappedSet.add(m.lot_name); + }); + }); + + const priorityLots = cartLots.filter(l => !mappedSet.has(l)); + const prioritySet = new Set(priorityLots); + const rest = all.filter(l => !prioritySet.has(l)); + const parts = []; + priorityLots.forEach(l => parts.push(`