25 Commits

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-01 16:41:14 +03:00
Mikhail Chusavitin
6e0335af7c Fix pricing tab warehouse totals and guard custom price DOM access 2026-02-27 16:53:34 +03:00
Mikhail Chusavitin
a42a80beb8 fix(bom): preserve local vendor spec on config import 2026-02-27 10:11:20 +03:00
Mikhail Chusavitin
586114c79c refactor(bom): enforce canonical lot_mappings persistence 2026-02-27 09:47:46 +03:00
Mikhail Chusavitin
e9230c0e58 feat(bom): canonical lot mappings and updated vendor spec docs 2026-02-25 19:07:27 +03:00
Mikhail Chusavitin
aa65fc8156 Fix project line numbering and reorder bootstrap 2026-02-25 17:19:26 +03:00
Mikhail Chusavitin
b22e961656 feat(projects): compact table layout for dates and names 2026-02-25 17:19:26 +03:00
Mikhail Chusavitin
af83818564 fix(pricelists): tolerate restricted DB grants and use embedded assets only 2026-02-25 17:19:26 +03:00
Mikhail Chusavitin
8a138327a3 fix(sync): backfill missing items for existing local pricelists 2026-02-25 17:18:57 +03:00
Mikhail Chusavitin
d1f65f6684 feat(projects): compact table layout for dates and names 2026-02-24 15:42:04 +03:00
Mikhail Chusavitin
7b371add10 Merge branch 'stable'
# Conflicts:
#	bible/03-database.md
2026-02-24 15:13:41 +03:00
d0400b18a3 feat(vendor-spec): BOM import, LOT autocomplete, pricing, partnumber_seen push
- BOM paste: auto-detect columns by content (price, qty, PN, description);
  handles $5,114.00 and European comma-decimal formats
- LOT input: HTML5 datalist rebuilt on each renderBOMTable from allComponents;
  oninput updates data only (no re-render), onchange validates+resolves
- BOM persistence: PUT handler explicitly marshals VendorSpec to JSON string
  (GORM Update does not reliably call driver.Valuer for custom types)
- BOM autosave after every resolveBOM() call
- Pricing tab: async renderPricingTab() calls /api/quote/price-levels for all
  resolved LOTs directly — Estimate prices shown even before cart apply
- Unresolved PNs pushed to qt_vendor_partnumber_seen via POST
  /api/sync/partnumber-seen (fire-and-forget from JS)
- sync.PushPartnumberSeen(): upsert with ON DUPLICATE KEY UPDATE last_seen_at
- partnumber_books: pull ALL books (not only is_active=1); re-pull items when
  header exists but item count is 0; fallback for missing description column
- partnumber_books UI: collapsible snapshot section (collapsed by default),
  pagination (10/page), sync button always visible in header
- vendorSpec handlers: use GetConfigurationByUUID + IsActive check (removed
  original_username from WHERE — GetUsername returns "" without JWT)
- bible/09-vendor-spec.md: updated with all architectural decisions

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 22:21:13 +03:00
d3f1a838eb feat: add Партномера nav item and summary page
- Top nav: link to /partnumber-books
- Page: summary cards (active version, unique LOTs, total PN, primary PN)
  + searchable items table for active book
  + collapsible history of all snapshots

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

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

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

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

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

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-02-21 10:22:22 +03:00
e5b6902c9e Implement persistent Line ordering for project specs and update bible 2026-02-21 07:09:38 +03:00
46 changed files with 4122 additions and 260 deletions

3
.gitmodules vendored Normal file
View File

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

11
AGENTS.md Normal file
View File

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

View File

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

1
bible Submodule

Submodule bible added at 472c8a6918

View File

@@ -144,7 +144,7 @@ This prevents selecting empty/incomplete snapshots and removes nondeterministic
### Principle
Append-only: every save creates an immutable snapshot in `local_configuration_versions`.
Append-only for **spec+price** changes: immutable snapshots are stored in `local_configuration_versions`.
```
local_configurations
@@ -153,9 +153,11 @@ local_configurations
local_configuration_versions (v1)
```
- `version_no = max + 1` on every save
- `version_no = max + 1` when configuration **spec+price** changes
- Old versions are never modified or deleted in normal flow
- Rollback does **not** rewind history — it creates a **new** version from the snapshot
- Operational updates (`line_no` reorder, server count, project move, rename)
are synced via `pending_changes` but do **not** create a new revision snapshot
### Rollback
@@ -181,6 +183,19 @@ local → pending → synced
---
## Project Specification Ordering (`Line`)
- Each project configuration has persistent `line_no` (`10,20,30...`) in both SQLite and MariaDB.
- Project list ordering is deterministic:
`line_no ASC`, then `created_at DESC`, then `id DESC`.
- Drag-and-drop reorder in project UI updates `line_no` for active project configurations.
- Reorder writes are queued as configuration `update` events in `pending_changes`
without creating new configuration versions.
- Backward compatibility: if remote MariaDB schema does not yet include `line_no`,
sync falls back to create/update without `line_no` instead of failing.
---
## Sync Payload for Versioning
Events in `pending_changes` for configurations contain:

View File

@@ -21,11 +21,22 @@ File: `qfs.db` in the user-state directory (see [05-config.md](05-config.md)).
| `local_pricelists` | Pricelist headers | `id`, `server_id` (unique), `source`, `version`, `created_at` |
| `local_pricelist_items` | Pricelist line items ← **sole source of prices** | `id`, `pricelist_id` (FK), `lot_name`, `price`, `lot_category` |
#### Partnumber Books (PN → LOT mapping, pull-only from PriceForge)
| Table | Purpose | Key Fields |
|-------|---------|------------|
| `local_partnumber_books` | Version snapshots of PN→LOT mappings | `id`, `server_id` (unique), `version`, `created_at`, `is_active` |
| `local_partnumber_book_items` | PN→LOT mapping rows | `id`, `book_id` (FK), `partnumber`, `lot_name`, `is_primary_pn` |
Active book: `WHERE is_active=1 ORDER BY created_at DESC, id DESC LIMIT 1`
`is_primary_pn=1` means this PN's quantity in the vendor BOM determines qty(LOT). If only non-primary PNs are present for a LOT, qty defaults to 1.
#### Configurations and Projects
| Table | Purpose | Key Fields |
|-------|---------|------------|
| `local_configurations` | Saved configurations | `id`, `uuid` (unique), `items` (JSON), `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` |
@@ -51,6 +62,7 @@ INDEX local_pricelists(source, created_at) -- used for "latest by source" quer
INDEX local_configurations(pricelist_id)
INDEX local_configurations(warehouse_pricelist_id)
INDEX local_configurations(competitor_pricelist_id)
INDEX local_configurations(project_uuid, line_no) -- project ordering (Line column)
UNIQUE INDEX local_configurations(uuid)
```
@@ -90,11 +102,16 @@ Database: `RFQ_LOG`
| `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 | SELECT, INSERT, UPDATE |
| `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 |
| `qt_projects` | Projects | SELECT, INSERT, UPDATE |
| `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
@@ -114,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;
```
@@ -134,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'@'%';
@@ -147,21 +171,22 @@ SHOW GRANTS FOR 'quote_user'@'%';
## Migrations
### SQLite Migrations (local)
### SQLite Migrations (local) — три уровня, выполняются при каждом старте
**1. GORM AutoMigrate** (`internal/localdb/localdb.go`) — первый и основной уровень.
Список Go-моделей передаётся в `db.AutoMigrate(...)`. GORM создаёт отсутствующие таблицы и добавляет новые колонки. Колонки и таблицы **не удаляет**.
→ Для добавления новой таблицы или колонки достаточно добавить модель/поле и включить модель в AutoMigrate.
**2. `runLocalMigrations`** (`internal/localdb/migrations.go`) — второй уровень, для операций которые AutoMigrate не умеет: backfill данных, пересоздание таблиц, создание индексов.
Каждая функция выполняется один раз — идемпотентность через запись `id` в `local_schema_migrations`.
**3. Централизованные (server-side)** — третий уровень, при проверке готовности к синку.
SQL-тексты хранятся в `qt_client_local_migrations` (MariaDB, пишет только PriceForge). Клиент читает, применяет к локальной SQLite, записывает в `local_remote_migrations_applied` + `qt_client_schema_state`.
### MariaDB Migrations (server-side)
- Stored in `migrations/` (SQL files)
- Applied via `-migrate` flag or automatically on first run
- Idempotent: checked by `id` in `local_schema_migrations`
- Already-applied migrations are skipped
```bash
go run ./cmd/qfs -migrate
```
### Centralized Migrations (server-side)
- Stored in `qt_client_local_migrations` (MariaDB)
- Applied automatically during sync readiness check
- Applied via `-migrate` flag
- `min_app_version` — minimum app version required for the migration
---

View File

@@ -57,6 +57,8 @@
| GET | `/api/configs/:uuid/versions` | List versions |
| GET | `/api/configs/:uuid/versions/:version` | Get specific version |
`line` field in configuration payloads is backed by persistent `line_no` in DB.
### Projects
| Method | Endpoint | Purpose |
@@ -67,6 +69,10 @@
| PUT | `/api/projects/:uuid` | Update project |
| DELETE | `/api/projects/:uuid` | Archive project variant (soft-delete via `is_active=false`; fails if project has no `variant` set — main projects cannot be deleted this way) |
| GET | `/api/projects/:uuid/configs` | Project configurations |
| PATCH | `/api/projects/:uuid/configs/reorder` | Reorder active project configurations (`ordered_uuids`) and persist `line_no` |
`GET /api/projects/:uuid/configs` ordering:
`line ASC`, then `created_at DESC`, then `id DESC`.
### Sync
@@ -83,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 |
@@ -108,6 +143,7 @@
| `/projects/:uuid` | Project details |
| `/pricelists` | Pricelist list |
| `/pricelists/:id` | Pricelist details |
| `/partnumber-books` | Partnumber books (active book summary + snapshot history) |
| `/setup` | Connection settings |
---

View File

@@ -34,7 +34,7 @@ make help # All available commands
## Code Style
- **Formatting:** `gofmt` (mandatory)
- **Logging:** `slog` only (structured logging)
- **Logging:** `slog` only (structured logging to the binary's stdout/stderr). No `console.log` or any other logging in browser-side JS — the browser console is never used for diagnostics.
- **Errors:** explicit wrapping with context (`fmt.Errorf("context: %w", err)`)
- **Style:** no unnecessary abstractions; minimum code for the task

View File

@@ -0,0 +1,364 @@
# 09 — Vendor Spec (BOM Import)
## Overview
The vendor spec feature allows importing a vendor BOM (Bill of Materials) into a configuration. It maps vendor part numbers (PN) to internal LOT names using an active partnumber book (snapshot pulled from PriceForge), then aggregates quantities to populate the Estimate (cart).
---
## Architecture
### Storage
| Data | Storage | Sync direction |
|------|---------|---------------|
| `vendor_spec` JSON | `local_configurations.vendor_spec` (TEXT, JSON-encoded) | Two-way via `pending_changes` |
| Partnumber book snapshots | `local_partnumber_books` + `local_partnumber_book_items` | Pull-only from PriceForge |
`vendor_spec` is a JSON array of `VendorSpecItem` objects stored inside the configuration row. It syncs to MariaDB `qt_configurations.vendor_spec` via the existing pending_changes mechanism.
### `vendor_spec` JSON Schema
```json
[
{
"sort_order": 10,
"vendor_partnumber": "ABC-123",
"quantity": 2,
"description": "...",
"unit_price": 4500.00,
"total_price": 9000.00,
"lot_mappings": [
{ "lot_name": "LOT_A", "quantity_per_pn": 1 },
{ "lot_name": "LOT_B", "quantity_per_pn": 2 }
]
}
]
```
`lot_mappings[]` is the canonical persisted LOT mapping list for a BOM row.
Each mapping entry stores:
- `lot_name`
- `quantity_per_pn` (how many units of this LOT are included in **one vendor PN**)
### PN → LOT Mapping Contract (single LOT, multiplier, bundle)
QuoteForge expects the server to return/store BOM rows (`vendor_spec`) using a single canonical mapping list:
- `lot_mappings[]` contains **all** LOT mappings for the PN row (single LOT and bundle cases alike)
- the list stores exactly what the user sees in BOM (LOT + "LOT в 1 PN")
- the DB contract does **not** split mappings into "base LOT" vs "bundle LOTs"
#### Final quantity contribution to Estimate
For one BOM row with vendor PN quantity `pn_qty`:
- each mapping contribution:
- `lot_qty = pn_qty * lot_mappings[i].quantity_per_pn`
#### Example: one PN maps to multiple LOTs
```json
{
"vendor_partnumber": "SYS-821GE-TNHR",
"quantity": 3,
"lot_mappings": [
{ "lot_name": "CHASSIS_X13_8GPU", "quantity_per_pn": 1 },
{ "lot_name": "PS_3000W_Titanium", "quantity_per_pn": 2 },
{ "lot_name": "RAILKIT_X13", "quantity_per_pn": 1 }
]
}
```
This row contributes to Estimate:
- `CHASSIS_X13_8GPU``3 * 1 = 3`
- `PS_3000W_Titanium``3 * 2 = 6`
- `RAILKIT_X13``3 * 1 = 3`
---
## Partnumber Books (Snapshots)
Partnumber books are immutable versioned snapshots of the global PN→LOT mapping table, analogous to pricelists. PriceForge creates new snapshots; QuoteForge only pulls and reads them.
### SQLite (local mirror)
```sql
CREATE TABLE local_partnumber_books (
id INTEGER PRIMARY KEY AUTOINCREMENT,
server_id INTEGER UNIQUE NOT NULL, -- id from qt_partnumber_books
version TEXT NOT NULL, -- format YYYY-MM-DD-NNN
created_at DATETIME NOT NULL,
is_active INTEGER NOT NULL DEFAULT 1
);
CREATE TABLE local_partnumber_book_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
book_id INTEGER NOT NULL, -- FK → local_partnumber_books.id
partnumber TEXT NOT NULL,
lot_name TEXT NOT NULL,
is_primary_pn INTEGER NOT NULL DEFAULT 0,
description TEXT
);
CREATE INDEX idx_local_book_pn ON local_partnumber_book_items(book_id, partnumber);
```
**Active book query:** `WHERE is_active=1 ORDER BY created_at DESC, id DESC LIMIT 1`
**Schema creation:** GORM AutoMigrate (not `runLocalMigrations`).
### MariaDB (managed exclusively by PriceForge)
```sql
CREATE TABLE qt_partnumber_books (
id INT AUTO_INCREMENT PRIMARY KEY,
version VARCHAR(50) NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
is_active TINYINT(1) NOT NULL DEFAULT 1
);
CREATE TABLE qt_partnumber_book_items (
id INT AUTO_INCREMENT PRIMARY KEY,
book_id INT NOT NULL,
partnumber VARCHAR(255) NOT NULL,
lot_name VARCHAR(255) NOT NULL,
is_primary_pn TINYINT(1) NOT NULL DEFAULT 0,
description VARCHAR(10000) NULL,
INDEX idx_book_pn (book_id, partnumber),
FOREIGN KEY (book_id) REFERENCES qt_partnumber_books(id)
);
ALTER TABLE qt_configurations ADD COLUMN vendor_spec JSON NULL;
```
QuoteForge has `SELECT` permission only on `qt_partnumber_books` and `qt_partnumber_book_items`. All writes are managed by PriceForge.
**Grant (add to existing user setup):**
```sql
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO '<DB_USER>'@'%';
```
### `is_primary_pn` semantics
- `1` = primary PN for this `lot_name`. Its quantity in the vendor BOM determines `qty(LOT)`.
- `0` = non-primary (trigger) PN. If no primary PN for the LOT is found in BOM, contributes `qty=1`.
---
## Resolution Algorithm (3-step)
For each `vendor_partnumber` in the BOM, QuoteForge builds/updates UI-visible LOT mappings:
1. **Active book lookup** — query `local_partnumber_book_items WHERE book_id = activeBook.id AND partnumber = ?`.
2. **Populate BOM UI** — if a match exists, BOM row shows a LOT value (user can still edit it).
3. **Unresolved** — red row + inline LOT input with strict autocomplete.
Persistence note: the application stores the final user-visible mappings in `lot_mappings[]` (not separate "resolved/manual" persisted fields).
---
## Qty Aggregation Logic
After resolution, qty per LOT is computed as:
```
qty(lot) = SUM(quantity of rows where is_primary_pn=1 AND resolved_lot=lot)
if at least one primary PN for this lot was found in BOM
= 1
if only non-primary PNs for this lot were found
```
Examples (book: LOT_A → x1[primary], x2, x3):
- BOM: x2×1, x3×2 → 1×LOT_A (no primary PN)
- BOM: x1×2, x2×1 → 2×LOT_A (primary qty=2)
- BOM: x1×1, x2×3 → 1×LOT_A (primary qty=1)
---
## UI: Three Top-Level Tabs
The configurator (`/configurator`) has three tabs:
1. **Estimate** — existing cart/component configurator (unchanged).
2. **BOM** — paste/import vendor BOM, manual column mapping, LOT matching, bundle decomposition (`1 PN -> multiple LOTs`), "Пересчитать эстимейт", "Очистить".
3. **Ценообразование** — pricing summary table + custom price input.
BOM data is shared between tabs 2 and 3.
### BOM Import UI (raw table, manual column mapping)
After paste (`Ctrl+V`) QuoteForge renders an editable raw table (not auto-detected parsing).
- The pasted rows are shown **as-is** (including header rows, if present).
- The user selects a type for each column manually:
- `P/N`
- `Кол-во`
- `Цена`
- `Описание`
- `Не использовать`
- Required mapping:
- exactly one `P/N`
- exactly one `Кол-во`
- Optional mapping:
- `Цена` (0..1)
- `Описание` (0..1)
- Rows can be:
- ignored (UI-only, excluded from `vendor_spec`)
- deleted
- Raw cells are editable inline after paste.
Notes:
- There is **no auto column detection**.
- There is **no auto header-row skip**.
- Raw import layout itself is not stored on server; only normalized `vendor_spec` is stored.
### LOT matching in BOM table
The BOM table adds service columns on the right:
- `LOT`
- `LOT в 1 PN`
- actions (`+`, ignore, delete)
`LOT` behavior:
- The first LOT row shown in the BOM UI is the primary LOT mapping for the PN row.
- Additional LOT rows are added via the `+` action.
- inline LOT input is strict:
- autocomplete source = full local components list (`/api/components?per_page=5000`)
- free text that does not match an existing LOT is rejected
`LOT в 1 PN` behavior:
- quantity multiplier for each visible LOT row in BOM (`quantity_per_pn` in persisted `lot_mappings[]`)
- default = `1`
- editable inline
### Bundle mode (`1 PN -> multiple LOTs`)
The `+` action in BOM rows adds an extra LOT mapping row for the same vendor PN row.
- All visible LOT rows (first + added rows) are persisted uniformly in `lot_mappings[]`
- Each mapping row has:
- LOT
- qty (`LOT in 1 PN` = `quantity_per_pn`)
### BOM restore on config open
On config open, QuoteForge loads `vendor_spec` from server and reconstructs the editable BOM table in normalized form:
- columns restored as: `Qty | P/N | Description | Price`
- column mapping restored as:
- `qty`, `pn`, `description`, `price`
- LOT / `LOT в 1 PN` rows are restored from `vendor_spec.lot_mappings[]`
This restores the BOM editing state, but not the original raw Excel layout (extra columns, ignored rows, original headers).
### Pricing Tab: column order
```
LOT | PN вендора | Описание | Кол-во | Estimate | Цена вендора | Склад | Конк.
```
**If BOM is empty** — the pricing tab still renders, using cart items as rows (PN вендора = "—", Цена вендора = "—").
**Description source priority:** BOM row description → LOT description from `local_components`.
### Pricing Tab: BOM + Estimate merge behavior
When BOM exists, the pricing tab renders:
- BOM-based rows (including rows resolved via manual LOT and bundle mappings)
- plus **Estimate-only LOTs** (rows currently in cart but not covered by BOM mappings)
Estimate-only rows are shown as separate rows with:
- `PN вендора = "—"`
- vendor price = `—`
- description from local components
### Pricing Tab: "Своя цена" input
- Manual entry → proportionally redistributes custom price into "Цена вендора" cells (proportional to each row's Estimate share). Last row absorbs rounding remainder.
- "Проставить цены BOM" button → restores per-row original BOM prices directly (no proportional redistribution). Sets "Своя цена" to their sum.
- Both paths show "Скидка от Estimate: X%" info.
- "Экспорт CSV" button → downloads `pricing_<uuid>.csv` with UTF-8 BOM, same column order as table, plus Итого row.
---
## API Endpoints
| Method | URL | Description |
|--------|-----|-------------|
| GET | `/api/configs/:uuid/vendor-spec` | Fetch stored BOM |
| PUT | `/api/configs/:uuid/vendor-spec` | Replace BOM (full update) |
| POST | `/api/configs/:uuid/vendor-spec/resolve` | Resolve PNs → LOTs (no cart mutation) |
| POST | `/api/configs/:uuid/vendor-spec/apply` | Apply resolved LOTs to cart |
| GET | `/api/partnumber-books` | List local book snapshots |
| GET | `/api/partnumber-books/:id` | Items for a book by server_id |
| POST | `/api/sync/partnumber-books` | Pull book snapshots from MariaDB |
| POST | `/api/sync/partnumber-seen` | Push unresolved PNs to `qt_vendor_partnumber_seen` on MariaDB |
## Unresolved PN Tracking (`qt_vendor_partnumber_seen`)
After each `resolveBOM()` call, QuoteForge pushes PN rows to `POST /api/sync/partnumber-seen` (fire-and-forget from JS — errors silently ignored):
- unresolved BOM rows (`ignored = false`)
- raw BOM rows explicitly marked as ignored in UI (`ignored = true`) — these rows are **not** saved to `vendor_spec`, but are reported for server-side tracking
The handler calls `sync.PushPartnumberSeen()` which upserts into `qt_vendor_partnumber_seen`:
```sql
INSERT INTO qt_vendor_partnumber_seen (source_type, vendor, partnumber, description, is_ignored, last_seen_at)
VALUES ('manual', '', ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
last_seen_at = VALUES(last_seen_at),
is_ignored = VALUES(is_ignored),
description = COALESCE(NULLIF(VALUES(description), ''), description)
```
Uniqueness key: `partnumber` only (after PriceForge migration 025). PriceForge uses this table for populating the partnumber book.
## BOM Persistence
- `vendor_spec` is saved to server via `PUT /api/configs/:uuid/vendor-spec`.
- `GET` / `PUT` `vendor_spec` must preserve row-level mapping fields used by the UI:
- `lot_mappings[]`
- each item: `lot_name`, `quantity_per_pn`
- `description` is persisted in each BOM row and is used by the Pricing tab when available.
- Ignored raw rows are **not** persisted into `vendor_spec`.
- The PUT handler explicitly marshals `VendorSpec` to JSON string before passing to GORM `Update` (GORM does not reliably call `driver.Valuer` for custom types in `Update(column, value)`).
- BOM is autosaved (debounced) after BOM-changing actions, including:
- `resolveBOM()`
- LOT row qty (`LOT в 1 PN`) changes
- LOT row add/remove (`+` / delete in bundle context)
- "Сохранить BOM" button triggers explicit save.
## Pricing Tab: Estimate Price Source
`renderPricingTab()` is async. It calls `POST /api/quote/price-levels` with LOTs collected from:
- `lot_mappings[]` from BOM rows
- current Estimate/cart LOTs not covered by BOM mappings (to show estimate-only rows)
This ensures Estimate prices appear for:
- manually matched LOTs in the BOM tab
- bundle LOTs
- LOTs already present in Estimate but not mapped from BOM
### Apply to Estimate (`Пересчитать эстимейт`)
When applying BOM to Estimate, QuoteForge builds cart rows from explicit UI mappings stored in `lot_mappings[]`.
For a BOM row with PN qty = `Q`:
- each mapped LOT contributes `Q * quantity_per_pn`
Rows without any valid LOT mapping are skipped.
## Web Route
| Route | Page |
|-------|------|
| `/partnumber-books` | Partnumber books — active book summary (unique LOTs, total PN, primary PN count), searchable items table, collapsible snapshot history |

View File

@@ -336,8 +336,6 @@ func derefString(value *string) string {
return *value
}
func setConfigDefaults(cfg *config.Config) {
if cfg.Server.Host == "" {
cfg.Server.Host = "127.0.0.1"
@@ -822,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)
@@ -942,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")
@@ -991,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")
{
@@ -1311,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 {
@@ -1662,6 +1676,43 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
c.JSON(http.StatusOK, result)
})
projects.PATCH("/:uuid/configs/reorder", func(c *gin.Context) {
var req struct {
OrderedUUIDs []string `json:"ordered_uuids"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if len(req.OrderedUUIDs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "ordered_uuids is required"})
return
}
configs, err := configService.ReorderProjectConfigurationsNoAuth(c.Param("uuid"), req.OrderedUUIDs)
if err != nil {
switch {
case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
default:
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
}
return
}
total := 0.0
for i := range configs {
if configs[i].TotalPrice != nil {
total += *configs[i].TotalPrice
}
}
c.JSON(http.StatusOK, gin.H{
"project_uuid": c.Param("uuid"),
"configurations": configs,
"total": total,
})
})
projects.POST("/:uuid/configs", func(c *gin.Context) {
var req services.CreateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
@@ -1709,6 +1760,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
syncAPI.GET("/users-status", syncHandler.GetUsersStatus)
syncAPI.POST("/components", syncHandler.SyncComponents)
syncAPI.POST("/pricelists", syncHandler.SyncPricelists)
syncAPI.POST("/partnumber-books", syncHandler.SyncPartnumberBooks)
syncAPI.POST("/partnumber-seen", syncHandler.ReportPartnumberSeen)
syncAPI.POST("/all", syncHandler.SyncAll)
syncAPI.POST("/push", syncHandler.PushPendingChanges)
syncAPI.GET("/pending/count", syncHandler.GetPendingCount)

View File

@@ -77,7 +77,7 @@ func TestConfigurationVersioningAPI(t *testing.T) {
if err := json.Unmarshal(rbRec.Body.Bytes(), &rbResp); err != nil {
t.Fatalf("unmarshal rollback response: %v", err)
}
if rbResp.Message == "" || rbResp.CurrentVersion.VersionNo != 3 {
if rbResp.Message == "" || rbResp.CurrentVersion.VersionNo != 2 {
t.Fatalf("unexpected rollback response: %+v", rbResp)
}

View File

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

View File

@@ -234,6 +234,33 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
h.syncService.RecordSyncHeartbeat()
}
// SyncPartnumberBooks syncs partnumber book snapshots from MariaDB to local SQLite.
// POST /api/sync/partnumber-books
func (h *SyncHandler) SyncPartnumberBooks(c *gin.Context) {
if !h.ensureSyncReadiness(c) {
return
}
startTime := time.Now()
pulled, err := h.syncService.PullPartnumberBooks()
if err != nil {
slog.Error("partnumber books pull failed", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": err.Error(),
})
return
}
c.JSON(http.StatusOK, SyncResultResponse{
Success: true,
Message: "Partnumber books synced successfully",
Synced: pulled,
Duration: time.Since(startTime).String(),
})
h.syncService.RecordSyncHeartbeat()
}
// SyncAllResponse represents result of full sync
type SyncAllResponse struct {
Success bool `json:"success"`
@@ -643,3 +670,37 @@ func (h *SyncHandler) getReadinessCached(maxAge time.Duration) *sync.SyncReadine
h.readinessMu.Unlock()
return readiness
}
// ReportPartnumberSeen pushes unresolved vendor partnumbers to qt_vendor_partnumber_seen on MariaDB.
// POST /api/sync/partnumber-seen
func (h *SyncHandler) ReportPartnumberSeen(c *gin.Context) {
var body struct {
Items []struct {
Partnumber string `json:"partnumber"`
Description string `json:"description"`
Ignored bool `json:"ignored"`
} `json:"items"`
}
if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
items := make([]sync.SeenPartnumber, 0, len(body.Items))
for _, it := range body.Items {
if it.Partnumber != "" {
items = append(items, sync.SeenPartnumber{
Partnumber: it.Partnumber,
Description: it.Description,
Ignored: it.Ignored,
})
}
}
if err := h.syncService.PushPartnumberSeen(items); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"reported": len(items)})
}

View File

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

View File

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

View File

@@ -33,6 +33,7 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
Article: cfg.Article,
PricelistID: cfg.PricelistID,
OnlyInStock: cfg.OnlyInStock,
Line: cfg.Line,
PriceUpdatedAt: cfg.PriceUpdatedAt,
CreatedAt: cfg.CreatedAt,
UpdatedAt: time.Now(),
@@ -80,6 +81,7 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
Article: local.Article,
PricelistID: local.PricelistID,
OnlyInStock: local.OnlyInStock,
Line: local.Line,
PriceUpdatedAt: local.PriceUpdatedAt,
CreatedAt: local.CreatedAt,
}

View File

@@ -253,3 +253,63 @@ func TestRunLocalMigrationsDeduplicatesConfigurationVersionsBySpecAndPrice(t *te
t.Fatalf("expected current_version_id to point to kept latest version v3")
}
}
func TestRunLocalMigrationsBackfillsConfigurationLineNo(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "line_no_backfill.db")
local, err := New(dbPath)
if err != nil {
t.Fatalf("open localdb: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
projectUUID := "project-line"
cfg1 := &LocalConfiguration{
UUID: "line-cfg-1",
ProjectUUID: &projectUUID,
Name: "Cfg 1",
Items: LocalConfigItems{},
SyncStatus: "pending",
OriginalUsername: "tester",
IsActive: true,
CreatedAt: time.Now().Add(-2 * time.Hour),
}
cfg2 := &LocalConfiguration{
UUID: "line-cfg-2",
ProjectUUID: &projectUUID,
Name: "Cfg 2",
Items: LocalConfigItems{},
SyncStatus: "pending",
OriginalUsername: "tester",
IsActive: true,
CreatedAt: time.Now().Add(-1 * time.Hour),
}
if err := local.SaveConfiguration(cfg1); err != nil {
t.Fatalf("save cfg1: %v", err)
}
if err := local.SaveConfiguration(cfg2); err != nil {
t.Fatalf("save cfg2: %v", err)
}
if err := local.DB().Model(&LocalConfiguration{}).Where("uuid IN ?", []string{cfg1.UUID, cfg2.UUID}).Update("line_no", 0).Error; err != nil {
t.Fatalf("reset line_no: %v", err)
}
if err := local.DB().Where("id = ?", "2026_02_19_local_config_line_no").Delete(&LocalSchemaMigration{}).Error; err != nil {
t.Fatalf("delete migration record: %v", err)
}
if err := runLocalMigrations(local.DB()); err != nil {
t.Fatalf("rerun local migrations: %v", err)
}
var rows []LocalConfiguration
if err := local.DB().Where("uuid IN ?", []string{cfg1.UUID, cfg2.UUID}).Order("created_at ASC").Find(&rows).Error; err != nil {
t.Fatalf("load configurations: %v", err)
}
if len(rows) != 2 {
t.Fatalf("expected 2 configurations, got %d", len(rows))
}
if rows[0].Line != 10 || rows[1].Line != 20 {
t.Fatalf("expected line_no [10,20], got [%d,%d]", rows[0].Line, rows[1].Line)
}
}

View File

@@ -142,6 +142,8 @@ func New(dbPath string) (*LocalDB, error) {
&LocalRemoteMigrationApplied{},
&LocalSyncGuardState{},
&PendingChange{},
&LocalPartnumberBook{},
&LocalPartnumberBookItem{},
); err != nil {
return nil, fmt.Errorf("migrating sqlite database: %w", err)
}
@@ -341,7 +343,7 @@ func (l *LocalDB) GetProjectByName(ownerUsername, name string) (*LocalProject, e
func (l *LocalDB) GetProjectConfigurations(projectUUID string) ([]LocalConfiguration, error) {
var configs []LocalConfiguration
err := l.db.Where("project_uuid = ? AND is_active = ?", projectUUID, true).
Order("created_at DESC").
Order(configurationLineOrderClause()).
Find(&configs).Error
return configs, err
}
@@ -514,9 +516,54 @@ func (l *LocalDB) BackfillConfigurationProjects(defaultOwner string) error {
// SaveConfiguration saves a configuration to local SQLite
func (l *LocalDB) SaveConfiguration(config *LocalConfiguration) error {
if config != nil && config.IsActive && config.Line <= 0 {
line, err := l.NextConfigurationLine(config.ProjectUUID, config.UUID)
if err != nil {
return err
}
config.Line = line
}
return l.db.Save(config).Error
}
func (l *LocalDB) NextConfigurationLine(projectUUID *string, excludeUUID string) (int, error) {
return NextConfigurationLineTx(l.db, projectUUID, excludeUUID)
}
func NextConfigurationLineTx(tx *gorm.DB, projectUUID *string, excludeUUID string) (int, error) {
query := tx.Model(&LocalConfiguration{}).
Where("is_active = ?", true)
trimmedExclude := strings.TrimSpace(excludeUUID)
if trimmedExclude != "" {
query = query.Where("uuid <> ?", trimmedExclude)
}
if projectUUID != nil && strings.TrimSpace(*projectUUID) != "" {
query = query.Where("project_uuid = ?", strings.TrimSpace(*projectUUID))
} else {
query = query.Where("project_uuid IS NULL OR TRIM(project_uuid) = ''")
}
var maxLine int
if err := query.Select("COALESCE(MAX(line_no), 0)").Scan(&maxLine).Error; err != nil {
return 0, fmt.Errorf("read max line_no: %w", err)
}
if maxLine < 0 {
maxLine = 0
}
next := ((maxLine / 10) + 1) * 10
if next < 10 {
next = 10
}
return next, nil
}
func configurationLineOrderClause() string {
return "CASE WHEN COALESCE(local_configurations.line_no, 0) <= 0 THEN 2147483647 ELSE local_configurations.line_no END ASC, local_configurations.created_at DESC, local_configurations.id DESC"
}
// GetConfigurations returns all local configurations
func (l *LocalDB) GetConfigurations() ([]LocalConfiguration, error) {
var configs []LocalConfiguration

View File

@@ -108,6 +108,11 @@ var localMigrations = []localMigration{
name: "Deduplicate configuration revisions by spec+price",
run: deduplicateConfigurationVersionsBySpecAndPrice,
},
{
id: "2026_02_19_local_config_line_no",
name: "Add line_no to local_configurations and backfill ordering",
run: addLocalConfigurationLineNo,
},
}
func runLocalMigrations(db *gorm.DB) error {
@@ -806,3 +811,57 @@ func addLocalConfigurationSupportCode(tx *gorm.DB) error {
}
return nil
}
func addLocalConfigurationLineNo(tx *gorm.DB) error {
type columnInfo struct {
Name string `gorm:"column:name"`
}
var columns []columnInfo
if err := tx.Raw(`
SELECT name FROM pragma_table_info('local_configurations')
WHERE name IN ('line_no')
`).Scan(&columns).Error; err != nil {
return fmt.Errorf("check local_configurations(line_no) existence: %w", err)
}
if len(columns) == 0 {
if err := tx.Exec(`
ALTER TABLE local_configurations
ADD COLUMN line_no INTEGER
`).Error; err != nil {
return fmt.Errorf("add local_configurations.line_no: %w", err)
}
slog.Info("added line_no to local_configurations")
}
if err := tx.Exec(`
WITH ranked AS (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY COALESCE(NULLIF(TRIM(project_uuid), ''), '__NO_PROJECT__')
ORDER BY created_at ASC, id ASC
) AS rn
FROM local_configurations
WHERE line_no IS NULL OR line_no <= 0
)
UPDATE local_configurations
SET line_no = (
SELECT rn * 10
FROM ranked
WHERE ranked.id = local_configurations.id
)
WHERE id IN (SELECT id FROM ranked)
`).Error; err != nil {
return fmt.Errorf("backfill local_configurations.line_no: %w", err)
}
if err := tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_local_configurations_project_line_no
ON local_configurations(project_uuid, line_no)
`).Error; err != nil {
return fmt.Errorf("ensure idx_local_configurations_project_line_no: %w", err)
}
return nil
}

View File

@@ -83,35 +83,37 @@ func (s *LocalStringList) Scan(value interface{}) error {
// LocalConfiguration stores configurations in local SQLite
type LocalConfiguration struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
ServerID *uint `json:"server_id"` // ID on MariaDB server, NULL if local only
ProjectUUID *string `gorm:"index" json:"project_uuid,omitempty"`
CurrentVersionID *string `gorm:"index" json:"current_version_id,omitempty"`
IsActive bool `gorm:"default:true;index" json:"is_active"`
Name string `gorm:"not null" json:"name"`
Items LocalConfigItems `gorm:"type:text" json:"items"` // JSON stored as text in SQLite
TotalPrice *float64 `json:"total_price"`
CustomPrice *float64 `json:"custom_price"`
Notes string `json:"notes"`
IsTemplate bool `gorm:"default:false" json:"is_template"`
ServerCount int `gorm:"default:1" json:"server_count"`
ServerModel string `gorm:"size:100" json:"server_model,omitempty"`
SupportCode string `gorm:"size:20" json:"support_code,omitempty"`
Article string `gorm:"size:80" json:"article,omitempty"`
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UUID string `gorm:"uniqueIndex;not null" json:"uuid"`
ServerID *uint `json:"server_id"` // ID on MariaDB server, NULL if local only
ProjectUUID *string `gorm:"index" json:"project_uuid,omitempty"`
CurrentVersionID *string `gorm:"index" json:"current_version_id,omitempty"`
IsActive bool `gorm:"default:true;index" json:"is_active"`
Name string `gorm:"not null" json:"name"`
Items LocalConfigItems `gorm:"type:text" json:"items"` // JSON stored as text in SQLite
TotalPrice *float64 `json:"total_price"`
CustomPrice *float64 `json:"custom_price"`
Notes string `json:"notes"`
IsTemplate bool `gorm:"default:false" json:"is_template"`
ServerCount int `gorm:"default:1" json:"server_count"`
ServerModel string `gorm:"size:100" json:"server_model,omitempty"`
SupportCode string `gorm:"size:20" json:"support_code,omitempty"`
Article string `gorm:"size:80" json:"article,omitempty"`
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
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"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
SyncedAt *time.Time `json:"synced_at"`
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified'
OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference
OriginalUsername string `gorm:"not null;default:'';index" json:"original_username"`
CurrentVersion *LocalConfigurationVersion `gorm:"foreignKey:CurrentVersionID;references:ID" json:"current_version,omitempty"`
Versions []LocalConfigurationVersion `gorm:"foreignKey:ConfigurationUUID;references:UUID" json:"versions,omitempty"`
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"`
UpdatedAt time.Time `json:"updated_at"`
SyncedAt *time.Time `json:"synced_at"`
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified'
OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference
OriginalUsername string `gorm:"not null;default:'';index" json:"original_username"`
CurrentVersion *LocalConfigurationVersion `gorm:"foreignKey:CurrentVersionID;references:ID" json:"current_version,omitempty"`
Versions []LocalConfigurationVersion `gorm:"foreignKey:ConfigurationUUID;references:UUID" json:"versions,omitempty"`
}
func (LocalConfiguration) TableName() string {
@@ -242,3 +244,85 @@ type PendingChange struct {
func (PendingChange) TableName() string {
return "pending_changes"
}
// LocalPartnumberBook stores a version snapshot of the PN→LOT mapping book (pull-only from PriceForge)
type LocalPartnumberBook struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
ServerID int `gorm:"uniqueIndex;not null" json:"server_id"`
Version string `gorm:"not null" json:"version"`
CreatedAt time.Time `gorm:"not null" json:"created_at"`
IsActive bool `gorm:"not null;default:true" json:"is_active"`
}
func (LocalPartnumberBook) TableName() string {
return "local_partnumber_books"
}
// LocalPartnumberBookItem stores a single PN→LOT mapping within a book snapshot
type LocalPartnumberBookItem struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
BookID uint `gorm:"not null;index:idx_local_book_pn,priority:1" json:"book_id"`
Partnumber string `gorm:"not null;index:idx_local_book_pn,priority:2" json:"partnumber"`
LotName string `gorm:"not null" json:"lot_name"`
IsPrimaryPN bool `gorm:"not null;default:false" json:"is_primary_pn"`
Description string `json:"description,omitempty"`
}
func (LocalPartnumberBookItem) TableName() string {
return "local_partnumber_book_items"
}
// VendorSpecItem represents a single row in a vendor BOM specification
type VendorSpecItem struct {
SortOrder int `json:"sort_order"`
VendorPartnumber string `json:"vendor_partnumber"`
Quantity int `json:"quantity"`
Description string `json:"description,omitempty"`
UnitPrice *float64 `json:"unit_price,omitempty"`
TotalPrice *float64 `json:"total_price,omitempty"`
ResolvedLotName string `json:"resolved_lot_name,omitempty"`
ResolutionSource string `json:"resolution_source,omitempty"` // "book", "manual", "unresolved"
ManualLotSuggestion string `json:"manual_lot_suggestion,omitempty"`
LotQtyPerPN int `json:"lot_qty_per_pn,omitempty"`
LotAllocations []VendorSpecLotAllocation `json:"lot_allocations,omitempty"`
LotMappings []VendorSpecLotMapping `json:"lot_mappings,omitempty"`
}
type VendorSpecLotAllocation struct {
LotName string `json:"lot_name"`
Quantity int `json:"quantity"` // quantity of LOT per 1 vendor PN
}
// VendorSpecLotMapping is the canonical persisted LOT mapping for a vendor PN row.
// It stores all mapped LOTs (base + bundle) uniformly.
type VendorSpecLotMapping struct {
LotName string `json:"lot_name"`
QuantityPerPN int `json:"quantity_per_pn"`
}
// VendorSpec is a JSON-encodable slice of VendorSpecItem
type VendorSpec []VendorSpecItem
func (v VendorSpec) Value() (driver.Value, error) {
if v == nil {
return nil, nil
}
return json.Marshal(v)
}
func (v *VendorSpec) Scan(value interface{}) error {
if value == nil {
*v = nil
return nil
}
var bytes []byte
switch val := value.(type) {
case []byte:
bytes = val
case string:
bytes = []byte(val)
default:
return errors.New("type assertion failed for VendorSpec")
}
return json.Unmarshal(bytes, v)
}

View File

@@ -28,6 +28,7 @@ func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
"article": localCfg.Article,
"pricelist_id": localCfg.PricelistID,
"only_in_stock": localCfg.OnlyInStock,
"line": localCfg.Line,
"price_updated_at": localCfg.PriceUpdatedAt,
"created_at": localCfg.CreatedAt,
"updated_at": localCfg.UpdatedAt,
@@ -61,6 +62,7 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
Article string `json:"article"`
PricelistID *uint `json:"pricelist_id"`
OnlyInStock bool `json:"only_in_stock"`
Line int `json:"line"`
PriceUpdatedAt *time.Time `json:"price_updated_at"`
OriginalUserID uint `json:"original_user_id"`
OriginalUsername string `json:"original_username"`
@@ -90,6 +92,7 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
Article: snapshot.Article,
PricelistID: snapshot.PricelistID,
OnlyInStock: snapshot.OnlyInStock,
Line: snapshot.Line,
PriceUpdatedAt: snapshot.PriceUpdatedAt,
OriginalUserID: snapshot.OriginalUserID,
OriginalUsername: snapshot.OriginalUsername,

View File

@@ -61,6 +61,7 @@ type Configuration struct {
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
Line int `gorm:"column:line_no;index" json:"line"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
CurrentVersionNo int `gorm:"-" json:"current_version_no,omitempty"`

View File

@@ -1,6 +1,8 @@
package repository
import (
"strings"
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/gorm"
)
@@ -14,7 +16,13 @@ func NewConfigurationRepository(db *gorm.DB) *ConfigurationRepository {
}
func (r *ConfigurationRepository) Create(config *models.Configuration) error {
return r.db.Create(config).Error
if err := r.db.Create(config).Error; err != nil {
if isUnknownLineNoColumnError(err) {
return r.db.Omit("line_no").Create(config).Error
}
return err
}
return nil
}
func (r *ConfigurationRepository) GetByID(id uint) (*models.Configuration, error) {
@@ -36,7 +44,21 @@ func (r *ConfigurationRepository) GetByUUID(uuid string) (*models.Configuration,
}
func (r *ConfigurationRepository) Update(config *models.Configuration) error {
return r.db.Save(config).Error
if err := r.db.Save(config).Error; err != nil {
if isUnknownLineNoColumnError(err) {
return r.db.Omit("line_no").Save(config).Error
}
return err
}
return nil
}
func isUnknownLineNoColumnError(err error) bool {
if err == nil {
return false
}
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "unknown column 'line_no'") || strings.Contains(msg, "no column named line_no")
}
func (r *ConfigurationRepository) Delete(id uint) error {

View File

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

View File

@@ -6,6 +6,7 @@ import (
"fmt"
"io"
"math"
"sort"
"strings"
"time"
@@ -42,6 +43,7 @@ type ExportItem struct {
// ConfigExportBlock represents one configuration (server) in the export.
type ConfigExportBlock struct {
Article string
Line int
ServerCount int
UnitPrice float64 // sum of component prices for one server
Items []ExportItem
@@ -92,7 +94,10 @@ func (s *ExportService) ToCSV(w io.Writer, data *ProjectExportData) error {
}
for i, block := range data.Configs {
lineNo := (i + 1) * 10
lineNo := block.Line
if lineNo <= 0 {
lineNo = (i + 1) * 10
}
serverCount := block.ServerCount
if serverCount < 1 {
@@ -104,13 +109,13 @@ func (s *ExportService) ToCSV(w io.Writer, data *ProjectExportData) error {
// Server summary row
serverRow := []string{
fmt.Sprintf("%d", lineNo), // Line
"", // Type
block.Article, // p/n
"", // Description
"", // Qty (1 pcs.)
fmt.Sprintf("%d", serverCount), // Qty (total)
formatPriceInt(block.UnitPrice), // Price (1 pcs.)
formatPriceWithSpace(totalPrice), // Price (total)
"", // Type
block.Article, // p/n
"", // Description
"", // Qty (1 pcs.)
fmt.Sprintf("%d", serverCount), // Qty (total)
formatPriceInt(block.UnitPrice), // Price (1 pcs.)
formatPriceWithSpace(totalPrice), // Price (total)
}
if err := csvWriter.Write(serverRow); err != nil {
return fmt.Errorf("failed to write server row: %w", err)
@@ -124,14 +129,14 @@ func (s *ExportService) ToCSV(w io.Writer, data *ProjectExportData) error {
// Component rows
for _, item := range sortedItems {
componentRow := []string{
"", // Line
item.Category, // Type
item.LotName, // p/n
"", // Description
fmt.Sprintf("%d", item.Quantity), // Qty (1 pcs.)
"", // Qty (total)
formatPriceComma(item.UnitPrice), // Price (1 pcs.)
"", // Price (total)
"", // Line
item.Category, // Type
item.LotName, // p/n
"", // Description
fmt.Sprintf("%d", item.Quantity), // Qty (1 pcs.)
"", // Qty (total)
formatPriceComma(item.UnitPrice), // Price (1 pcs.)
"", // Price (total)
}
if err := csvWriter.Write(componentRow); err != nil {
return fmt.Errorf("failed to write component row: %w", err)
@@ -174,9 +179,30 @@ func (s *ExportService) ConfigToExportData(cfg *models.Configuration) *ProjectEx
// ProjectToExportData converts multiple configurations into ProjectExportData.
func (s *ExportService) ProjectToExportData(configs []models.Configuration) *ProjectExportData {
sortedConfigs := make([]models.Configuration, len(configs))
copy(sortedConfigs, configs)
sort.Slice(sortedConfigs, func(i, j int) bool {
leftLine := sortedConfigs[i].Line
rightLine := sortedConfigs[j].Line
if leftLine <= 0 {
leftLine = int(^uint(0) >> 1)
}
if rightLine <= 0 {
rightLine = int(^uint(0) >> 1)
}
if leftLine != rightLine {
return leftLine < rightLine
}
if !sortedConfigs[i].CreatedAt.Equal(sortedConfigs[j].CreatedAt) {
return sortedConfigs[i].CreatedAt.After(sortedConfigs[j].CreatedAt)
}
return sortedConfigs[i].UUID > sortedConfigs[j].UUID
})
blocks := make([]ConfigExportBlock, 0, len(configs))
for i := range configs {
blocks = append(blocks, s.buildExportBlock(&configs[i]))
for i := range sortedConfigs {
blocks = append(blocks, s.buildExportBlock(&sortedConfigs[i]))
}
return &ProjectExportData{
Configs: blocks,
@@ -214,6 +240,7 @@ func (s *ExportService) buildExportBlock(cfg *models.Configuration) ConfigExport
return ConfigExportBlock{
Article: cfg.Article,
Line: cfg.Line,
ServerCount: serverCount,
UnitPrice: unitTotal,
Items: items,

View File

@@ -8,6 +8,7 @@ import (
"time"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/models"
)
func newTestProjectData(items []ExportItem, article string, serverCount int) *ProjectExportData {
@@ -357,6 +358,51 @@ func TestToCSV_MultipleBlocks(t *testing.T) {
}
}
func TestProjectToExportData_SortsByLine(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil, nil)
configs := []models.Configuration{
{
UUID: "cfg-1",
Line: 30,
Article: "ART-30",
ServerCount: 1,
Items: models.ConfigItems{{LotName: "LOT-30", Quantity: 1, UnitPrice: 300}},
CreatedAt: time.Now().Add(-1 * time.Hour),
},
{
UUID: "cfg-2",
Line: 10,
Article: "ART-10",
ServerCount: 1,
Items: models.ConfigItems{{LotName: "LOT-10", Quantity: 1, UnitPrice: 100}},
CreatedAt: time.Now().Add(-2 * time.Hour),
},
{
UUID: "cfg-3",
Line: 20,
Article: "ART-20",
ServerCount: 1,
Items: models.ConfigItems{{LotName: "LOT-20", Quantity: 1, UnitPrice: 200}},
CreatedAt: time.Now().Add(-3 * time.Hour),
},
}
data := svc.ProjectToExportData(configs)
if len(data.Configs) != 3 {
t.Fatalf("expected 3 blocks, got %d", len(data.Configs))
}
if data.Configs[0].Article != "ART-10" || data.Configs[0].Line != 10 {
t.Fatalf("first block must be line 10, got article=%s line=%d", data.Configs[0].Article, data.Configs[0].Line)
}
if data.Configs[1].Article != "ART-20" || data.Configs[1].Line != 20 {
t.Fatalf("second block must be line 20, got article=%s line=%d", data.Configs[1].Article, data.Configs[1].Line)
}
if data.Configs[2].Article != "ART-30" || data.Configs[2].Line != 30 {
t.Fatalf("third block must be line 30, got article=%s line=%d", data.Configs[2].Article, data.Configs[2].Line)
}
}
func TestFormatPriceWithSpace(t *testing.T) {
tests := []struct {
input float64

View File

@@ -107,6 +107,7 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
if err := s.createWithVersion(localCfg, ownerUsername); err != nil {
return nil, fmt.Errorf("create configuration with version: %w", err)
}
cfg.Line = localCfg.Line
// Record usage stats
_ = s.quoteService.RecordUsage(req.Items)
@@ -325,6 +326,7 @@ func (s *LocalConfigurationService) CloneToProject(configUUID string, ownerUsern
if err := s.createWithVersion(localCfg, ownerUsername); err != nil {
return nil, fmt.Errorf("clone configuration with version: %w", err)
}
clone.Line = localCfg.Line
return clone, nil
}
@@ -640,6 +642,7 @@ func (s *LocalConfigurationService) CloneNoAuthToProjectFromVersion(configUUID s
if err := s.createWithVersion(localCfg, ownerUsername); err != nil {
return nil, fmt.Errorf("clone configuration without auth with version: %w", err)
}
clone.Line = localCfg.Line
return clone, nil
}
@@ -826,21 +829,13 @@ func (s *LocalConfigurationService) UpdateServerCount(configUUID string, serverC
return fmt.Errorf("save local configuration: %w", err)
}
// Use existing current version for the pending change
var version localdb.LocalConfigurationVersion
if localCfg.CurrentVersionID != nil && *localCfg.CurrentVersionID != "" {
if err := tx.Where("id = ?", *localCfg.CurrentVersionID).First(&version).Error; err != nil {
return fmt.Errorf("load current version: %w", err)
}
} else {
if err := tx.Where("configuration_uuid = ?", localCfg.UUID).
Order("version_no DESC").First(&version).Error; err != nil {
return fmt.Errorf("load latest version: %w", err)
}
version, err := s.loadVersionForPendingTx(tx, localCfg)
if err != nil {
return err
}
cfg = localdb.LocalToConfiguration(localCfg)
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "update", &version, ""); err != nil {
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "update", version, ""); err != nil {
return fmt.Errorf("enqueue server-count pending change: %w", err)
}
return nil
@@ -852,6 +847,99 @@ func (s *LocalConfigurationService) UpdateServerCount(configUUID string, serverC
return cfg, nil
}
func (s *LocalConfigurationService) ReorderProjectConfigurationsNoAuth(projectUUID string, orderedUUIDs []string) ([]models.Configuration, error) {
projectUUID = strings.TrimSpace(projectUUID)
if projectUUID == "" {
return nil, ErrProjectNotFound
}
if _, err := s.localDB.GetProjectByUUID(projectUUID); err != nil {
return nil, ErrProjectNotFound
}
if len(orderedUUIDs) == 0 {
return []models.Configuration{}, nil
}
seen := make(map[string]struct{}, len(orderedUUIDs))
normalized := make([]string, 0, len(orderedUUIDs))
for _, raw := range orderedUUIDs {
u := strings.TrimSpace(raw)
if u == "" {
return nil, fmt.Errorf("ordered_uuids contains empty uuid")
}
if _, exists := seen[u]; exists {
return nil, fmt.Errorf("ordered_uuids contains duplicate uuid: %s", u)
}
seen[u] = struct{}{}
normalized = append(normalized, u)
}
err := s.localDB.DB().Transaction(func(tx *gorm.DB) error {
var active []localdb.LocalConfiguration
if err := tx.Where("project_uuid = ? AND is_active = ?", projectUUID, true).
Find(&active).Error; err != nil {
return fmt.Errorf("load project active configurations: %w", err)
}
if len(active) != len(normalized) {
return fmt.Errorf("ordered_uuids count mismatch: expected %d got %d", len(active), len(normalized))
}
byUUID := make(map[string]*localdb.LocalConfiguration, len(active))
for i := range active {
cfg := active[i]
byUUID[cfg.UUID] = &cfg
}
for _, id := range normalized {
if _, ok := byUUID[id]; !ok {
return fmt.Errorf("configuration %s not found in project %s", id, projectUUID)
}
}
now := time.Now()
for idx, id := range normalized {
cfg := byUUID[id]
newLine := (idx + 1) * 10
if cfg.Line == newLine {
continue
}
cfg.Line = newLine
cfg.UpdatedAt = now
cfg.SyncStatus = "pending"
if err := tx.Save(cfg).Error; err != nil {
return fmt.Errorf("save reordered configuration %s: %w", cfg.UUID, err)
}
version, err := s.loadVersionForPendingTx(tx, cfg)
if err != nil {
return err
}
if err := s.enqueueConfigurationPendingChangeTx(tx, cfg, "update", version, ""); err != nil {
return fmt.Errorf("enqueue reorder pending change for %s: %w", cfg.UUID, err)
}
}
return nil
})
if err != nil {
return nil, err
}
var localConfigs []localdb.LocalConfiguration
if err := s.localDB.DB().
Preload("CurrentVersion").
Where("project_uuid = ? AND is_active = ?", projectUUID, true).
Order("CASE WHEN COALESCE(line_no, 0) <= 0 THEN 2147483647 ELSE line_no END ASC, created_at DESC, id DESC").
Find(&localConfigs).Error; err != nil {
return nil, fmt.Errorf("load reordered configurations: %w", err)
}
result := make([]models.Configuration, 0, len(localConfigs))
for i := range localConfigs {
result = append(result, *localdb.LocalToConfiguration(&localConfigs[i]))
}
return result, nil
}
// ImportFromServer imports configurations from MariaDB to local SQLite cache.
func (s *LocalConfigurationService) ImportFromServer() (*sync.ConfigImportResult, error) {
return s.syncService.ImportConfigurationsToLocal()
@@ -965,6 +1053,11 @@ 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 {
if err := s.ensureConfigurationLineTx(tx, localCfg); err != nil {
return err
}
}
if err := tx.Create(localCfg).Error; err != nil {
return fmt.Errorf("create local configuration: %w", err)
}
@@ -1021,12 +1114,31 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
return fmt.Errorf("compare revision content: %w", err)
}
if sameRevisionContent {
cfg = localdb.LocalToConfiguration(&locked)
if !hasNonRevisionConfigurationChanges(&locked, localCfg) {
cfg = localdb.LocalToConfiguration(&locked)
return nil
}
if err := tx.Save(localCfg).Error; err != nil {
return fmt.Errorf("save local configuration (no new revision): %w", err)
}
cfg = localdb.LocalToConfiguration(localCfg)
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, operation, currentVersion, createdBy); err != nil {
return fmt.Errorf("enqueue %s pending change without revision: %w", operation, err)
}
if err := s.recalculateLocalPricelistUsageTx(tx); err != nil {
return fmt.Errorf("recalculate local pricelist usage: %w", err)
}
return nil
}
}
}
if localCfg.IsActive {
if err := s.ensureConfigurationLineTx(tx, localCfg); err != nil {
return err
}
}
if err := tx.Save(localCfg).Error; err != nil {
return fmt.Errorf("save local configuration: %w", err)
}
@@ -1061,6 +1173,50 @@ func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.
return cfg, nil
}
func hasNonRevisionConfigurationChanges(current *localdb.LocalConfiguration, next *localdb.LocalConfiguration) bool {
if current == nil || next == nil {
return true
}
if current.Name != next.Name ||
current.Notes != next.Notes ||
current.IsTemplate != next.IsTemplate ||
current.ServerModel != next.ServerModel ||
current.SupportCode != next.SupportCode ||
current.Article != next.Article ||
current.OnlyInStock != next.OnlyInStock ||
current.IsActive != next.IsActive ||
current.Line != next.Line {
return true
}
if !equalUintPtr(current.PricelistID, next.PricelistID) ||
!equalUintPtr(current.WarehousePricelistID, next.WarehousePricelistID) ||
!equalUintPtr(current.CompetitorPricelistID, next.CompetitorPricelistID) ||
!equalStringPtr(current.ProjectUUID, next.ProjectUUID) {
return true
}
return false
}
func equalStringPtr(a, b *string) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
return strings.TrimSpace(*a) == strings.TrimSpace(*b)
}
func equalUintPtr(a, b *uint) bool {
if a == nil && b == nil {
return true
}
if a == nil || b == nil {
return false
}
return *a == *b
}
func (s *LocalConfigurationService) loadCurrentVersionTx(tx *gorm.DB, localCfg *localdb.LocalConfiguration) (*localdb.LocalConfigurationVersion, error) {
var version localdb.LocalConfigurationVersion
if localCfg.CurrentVersionID != nil && *localCfg.CurrentVersionID != "" {
@@ -1082,6 +1238,76 @@ func (s *LocalConfigurationService) loadCurrentVersionTx(tx *gorm.DB, localCfg *
return &version, nil
}
func (s *LocalConfigurationService) loadVersionForPendingTx(tx *gorm.DB, localCfg *localdb.LocalConfiguration) (*localdb.LocalConfigurationVersion, error) {
if localCfg.CurrentVersionID != nil && *localCfg.CurrentVersionID != "" {
var current localdb.LocalConfigurationVersion
if err := tx.Where("id = ?", *localCfg.CurrentVersionID).First(&current).Error; err == nil {
return &current, nil
}
}
var latest localdb.LocalConfigurationVersion
if err := tx.Where("configuration_uuid = ?", localCfg.UUID).
Order("version_no DESC").
First(&latest).Error; err != nil {
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 {
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
}
return nil
}
func (s *LocalConfigurationService) hasSameRevisionContent(localCfg *localdb.LocalConfiguration, currentVersion *localdb.LocalConfigurationVersion) (bool, error) {
currentSnapshotCfg, err := s.decodeConfigurationSnapshot(currentVersion.Data)
if err != nil {
@@ -1195,6 +1421,9 @@ func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string,
current.ServerCount = rollbackData.ServerCount
current.PricelistID = rollbackData.PricelistID
current.OnlyInStock = rollbackData.OnlyInStock
if rollbackData.Line > 0 {
current.Line = rollbackData.Line
}
current.PriceUpdatedAt = rollbackData.PriceUpdatedAt
current.UpdatedAt = time.Now()
current.SyncStatus = "pending"

View File

@@ -137,6 +137,78 @@ func TestUpdateNoAuthSkipsRevisionWhenSpecAndPriceUnchanged(t *testing.T) {
}
}
func TestReorderProjectConfigurationsDoesNotCreateNewVersions(t *testing.T) {
service, local := newLocalConfigServiceForTest(t)
project := &localdb.LocalProject{
UUID: "project-reorder",
OwnerUsername: "tester",
Code: "PRJ-ORDER",
Variant: "",
Name: ptrString("Project Reorder"),
IsActive: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
SyncStatus: "pending",
}
if err := local.SaveProject(project); err != nil {
t.Fatalf("save project: %v", err)
}
first, err := service.Create("tester", &CreateConfigRequest{
Name: "Cfg A",
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}},
ServerCount: 1,
ProjectUUID: &project.UUID,
})
if err != nil {
t.Fatalf("create first config: %v", err)
}
second, err := service.Create("tester", &CreateConfigRequest{
Name: "Cfg B",
Items: models.ConfigItems{{LotName: "CPU_B", Quantity: 1, UnitPrice: 200}},
ServerCount: 1,
ProjectUUID: &project.UUID,
})
if err != nil {
t.Fatalf("create second config: %v", err)
}
beforeFirst := loadVersions(t, local, first.UUID)
beforeSecond := loadVersions(t, local, second.UUID)
reordered, err := service.ReorderProjectConfigurationsNoAuth(project.UUID, []string{second.UUID, first.UUID})
if err != nil {
t.Fatalf("reorder configurations: %v", err)
}
if len(reordered) != 2 {
t.Fatalf("expected 2 reordered configs, got %d", len(reordered))
}
if reordered[0].UUID != second.UUID || reordered[0].Line != 10 {
t.Fatalf("expected second config first with line 10, got uuid=%s line=%d", reordered[0].UUID, reordered[0].Line)
}
if reordered[1].UUID != first.UUID || reordered[1].Line != 20 {
t.Fatalf("expected first config second with line 20, got uuid=%s line=%d", reordered[1].UUID, reordered[1].Line)
}
afterFirst := loadVersions(t, local, first.UUID)
afterSecond := loadVersions(t, local, second.UUID)
if len(afterFirst) != len(beforeFirst) || len(afterSecond) != len(beforeSecond) {
t.Fatalf("reorder must not create new versions")
}
var pendingCount int64
if err := local.DB().
Table("pending_changes").
Where("entity_type = ? AND operation = ? AND entity_uuid IN ?", "configuration", "update", []string{first.UUID, second.UUID}).
Count(&pendingCount).Error; err != nil {
t.Fatalf("count reorder pending changes: %v", err)
}
if pendingCount < 2 {
t.Fatalf("expected at least 2 pending update changes for reorder, got %d", pendingCount)
}
}
func TestAppendOnlyInvariantOldRowsUnchanged(t *testing.T) {
service, local := newLocalConfigServiceForTest(t)

View File

@@ -16,10 +16,10 @@ import (
)
var (
ErrProjectNotFound = errors.New("project not found")
ErrProjectForbidden = errors.New("access to project forbidden")
ErrProjectCodeExists = errors.New("project code and variant already exist")
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
ErrProjectNotFound = errors.New("project not found")
ErrProjectForbidden = errors.New("access to project forbidden")
ErrProjectCodeExists = errors.New("project code and variant already exist")
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
)
type ProjectService struct {
@@ -31,10 +31,10 @@ func NewProjectService(localDB *localdb.LocalDB) *ProjectService {
}
type CreateProjectRequest struct {
Code string `json:"code"`
Variant string `json:"variant,omitempty"`
Code string `json:"code"`
Variant string `json:"variant,omitempty"`
Name *string `json:"name,omitempty"`
TrackerURL string `json:"tracker_url"`
TrackerURL string `json:"tracker_url"`
}
type UpdateProjectRequest struct {
@@ -275,8 +275,23 @@ func (s *ProjectService) ListConfigurations(projectUUID, ownerUsername, status s
}, nil
}
query := s.localDB.DB().
Preload("CurrentVersion").
Where("project_uuid = ?", projectUUID).
Order("CASE WHEN COALESCE(line_no, 0) <= 0 THEN 2147483647 ELSE line_no END ASC, created_at DESC, id DESC")
switch status {
case "active", "":
query = query.Where("is_active = ?", true)
case "archived":
query = query.Where("is_active = ?", false)
case "all":
default:
query = query.Where("is_active = ?", true)
}
var localConfigs []localdb.LocalConfiguration
if err := s.localDB.DB().Preload("CurrentVersion").Order("created_at DESC").Find(&localConfigs).Error; err != nil {
if err := query.Find(&localConfigs).Error; err != nil {
return nil, err
}
@@ -284,25 +299,6 @@ func (s *ProjectService) ListConfigurations(projectUUID, ownerUsername, status s
total := 0.0
for i := range localConfigs {
localCfg := localConfigs[i]
if localCfg.ProjectUUID == nil || *localCfg.ProjectUUID != projectUUID {
continue
}
switch status {
case "active", "":
if !localCfg.IsActive {
continue
}
case "archived":
if localCfg.IsActive {
continue
}
case "all":
default:
if !localCfg.IsActive {
continue
}
}
cfg := localdb.LocalToConfiguration(&localCfg)
if cfg.TotalPrice != nil {
total += *cfg.TotalPrice

View File

@@ -0,0 +1,127 @@
package sync
import (
"fmt"
"log/slog"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"gorm.io/gorm"
)
// PullPartnumberBooks synchronizes partnumber book snapshots from MariaDB to local SQLite.
// Append-only for headers; re-pulls items if a book header exists but has 0 items.
func (s *Service) PullPartnumberBooks() (int, error) {
slog.Info("starting partnumber book pull")
mariaDB, err := s.getDB()
if err != nil {
return 0, fmt.Errorf("database not available: %w", err)
}
localBookRepo := repository.NewPartnumberBookRepository(s.localDB.DB())
type serverBook struct {
ID int `gorm:"column:id"`
Version string `gorm:"column:version"`
CreatedAt time.Time `gorm:"column:created_at"`
IsActive bool `gorm:"column:is_active"`
}
var serverBooks []serverBook
if err := mariaDB.Raw("SELECT id, version, created_at, is_active FROM qt_partnumber_books ORDER BY created_at DESC, id DESC").Scan(&serverBooks).Error; err != nil {
return 0, fmt.Errorf("querying server partnumber books: %w", err)
}
slog.Info("partnumber books found on server", "count", len(serverBooks))
pulled := 0
for _, sb := range serverBooks {
var existing localdb.LocalPartnumberBook
err := s.localDB.DB().Where("server_id = ?", sb.ID).First(&existing).Error
if err == nil {
// Header exists — check whether items were saved
localItemCount := localBookRepo.CountBookItems(existing.ID)
if localItemCount > 0 {
slog.Debug("partnumber book already synced, skipping", "server_id", sb.ID, "version", sb.Version, "items", localItemCount)
continue
}
// Items missing — re-pull them
slog.Info("partnumber book header exists but has no items, re-pulling items", "server_id", sb.ID, "version", sb.Version)
n, err := pullBookItems(mariaDB, localBookRepo, sb.ID, existing.ID)
if err != nil {
slog.Error("failed to re-pull items for existing book", "server_id", sb.ID, "error", err)
} else {
slog.Info("re-pulled items for existing book", "server_id", sb.ID, "version", sb.Version, "items_saved", n)
pulled++
}
continue
}
slog.Info("pulling new partnumber book", "server_id", sb.ID, "version", sb.Version, "is_active", sb.IsActive)
localBook := &localdb.LocalPartnumberBook{
ServerID: sb.ID,
Version: sb.Version,
CreatedAt: sb.CreatedAt,
IsActive: sb.IsActive,
}
if err := localBookRepo.SaveBook(localBook); err != nil {
slog.Error("failed to save local partnumber book", "server_id", sb.ID, "error", err)
continue
}
n, err := pullBookItems(mariaDB, localBookRepo, sb.ID, localBook.ID)
if err != nil {
slog.Error("failed to pull items for new book", "server_id", sb.ID, "error", err)
continue
}
slog.Info("partnumber book saved locally", "server_id", sb.ID, "version", sb.Version, "items_saved", n)
pulled++
}
slog.Info("partnumber book pull completed", "new_books_pulled", pulled, "total_on_server", len(serverBooks))
return pulled, nil
}
// pullBookItems fetches items for a single book from MariaDB and saves them to SQLite.
// Returns the number of items saved.
func pullBookItems(mariaDB *gorm.DB, repo *repository.PartnumberBookRepository, serverBookID int, localBookID uint) (int, error) {
type serverItem struct {
Partnumber string `gorm:"column:partnumber"`
LotName string `gorm:"column:lot_name"`
IsPrimaryPN bool `gorm:"column:is_primary_pn"`
Description string `gorm:"column:description"`
}
// description column may not exist yet on older server schemas — query without it first,
// then retry with it to populate descriptions if available.
var serverItems []serverItem
err := mariaDB.Raw("SELECT partnumber, lot_name, is_primary_pn, description FROM qt_partnumber_book_items WHERE book_id = ?", serverBookID).Scan(&serverItems).Error
if err != nil {
slog.Warn("description column not available on server, retrying without it", "server_book_id", serverBookID, "error", err)
if err2 := mariaDB.Raw("SELECT partnumber, lot_name, is_primary_pn FROM qt_partnumber_book_items WHERE book_id = ?", serverBookID).Scan(&serverItems).Error; err2 != nil {
return 0, fmt.Errorf("querying items from server: %w", err2)
}
}
slog.Info("partnumber book items fetched from server", "server_book_id", serverBookID, "count", len(serverItems))
if len(serverItems) == 0 {
slog.Warn("server returned 0 items for book — check qt_partnumber_book_items on server", "server_book_id", serverBookID)
return 0, nil
}
localItems := make([]localdb.LocalPartnumberBookItem, 0, len(serverItems))
for _, si := range serverItems {
localItems = append(localItems, localdb.LocalPartnumberBookItem{
BookID: localBookID,
Partnumber: si.Partnumber,
LotName: si.LotName,
IsPrimaryPN: si.IsPrimaryPN,
Description: si.Description,
})
}
if err := repo.SaveBookItems(localItems); err != nil {
return 0, fmt.Errorf("saving items to local db: %w", err)
}
return len(localItems), nil
}

View File

@@ -0,0 +1,51 @@
package sync
import (
"fmt"
"log/slog"
"time"
)
// SeenPartnumber represents an unresolved vendor partnumber to report.
type SeenPartnumber struct {
Partnumber string
Description string
Ignored bool
}
// PushPartnumberSeen inserts unresolved vendor partnumbers into qt_vendor_partnumber_seen on MariaDB.
// Uses INSERT ... ON DUPLICATE KEY UPDATE so existing rows are updated (last_seen_at) without error.
func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
if len(items) == 0 {
return nil
}
mariaDB, err := s.getDB()
if err != nil {
return fmt.Errorf("database not available: %w", err)
}
now := time.Now().UTC()
for _, item := range items {
if item.Partnumber == "" {
continue
}
err := mariaDB.Exec(`
INSERT INTO qt_vendor_partnumber_seen
(source_type, vendor, partnumber, description, is_ignored, last_seen_at)
VALUES
('manual', '', ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE
last_seen_at = VALUES(last_seen_at),
is_ignored = VALUES(is_ignored),
description = COALESCE(NULLIF(VALUES(description), ''), description)
`, item.Partnumber, item.Description, item.Ignored, now).Error
if err != nil {
slog.Error("failed to upsert partnumber_seen", "partnumber", item.Partnumber, "error", err)
// Continue with remaining items
}
}
slog.Info("partnumber_seen pushed to server", "count", len(items))
return nil
}

View File

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

View File

@@ -250,6 +250,135 @@ func TestPushPendingChangesCreateIsIdempotent(t *testing.T) {
}
}
func TestPushPendingChangesConfigurationPushesLine(t *testing.T) {
local := newLocalDBForSyncTest(t)
serverDB := newServerDBForSyncTest(t)
localSync := syncsvc.NewService(nil, local)
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
pushService := syncsvc.NewServiceWithDB(serverDB, local)
created, err := configService.Create("tester", &services.CreateConfigRequest{
Name: "Cfg Line Push",
Items: models.ConfigItems{{LotName: "CPU_LINE", Quantity: 1, UnitPrice: 1000}},
ServerCount: 1,
})
if err != nil {
t.Fatalf("create config: %v", err)
}
if created.Line != 10 {
t.Fatalf("expected local create line=10, got %d", created.Line)
}
if _, err := pushService.PushPendingChanges(); err != nil {
t.Fatalf("push pending changes: %v", err)
}
var serverCfg models.Configuration
if err := serverDB.Where("uuid = ?", created.UUID).First(&serverCfg).Error; err != nil {
t.Fatalf("load server config: %v", err)
}
if serverCfg.Line != 10 {
t.Fatalf("expected server line=10 after push, got %d", serverCfg.Line)
}
}
func TestImportConfigurationsToLocalPullsLine(t *testing.T) {
local := newLocalDBForSyncTest(t)
serverDB := newServerDBForSyncTest(t)
cfg := models.Configuration{
UUID: "server-line-config",
OwnerUsername: "tester",
Name: "Cfg Line Pull",
Items: models.ConfigItems{{LotName: "CPU_PULL", Quantity: 1, UnitPrice: 900}},
ServerCount: 1,
Line: 40,
}
total := cfg.Items.Total()
cfg.TotalPrice = &total
if err := serverDB.Create(&cfg).Error; err != nil {
t.Fatalf("seed server config: %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 localCfg.Line != 40 {
t.Fatalf("expected imported line=40, got %d", localCfg.Line)
}
}
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)
@@ -361,6 +490,7 @@ CREATE TABLE qt_configurations (
competitor_pricelist_id INTEGER NULL,
disable_price_refresh INTEGER NOT NULL DEFAULT 0,
only_in_stock INTEGER NOT NULL DEFAULT 0,
line_no INTEGER NULL,
price_updated_at DATETIME NULL,
created_at DATETIME
);`).Error; err != nil {

View File

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

View File

@@ -0,0 +1,18 @@
ALTER TABLE qt_configurations
ADD COLUMN IF NOT EXISTS line_no INT NULL AFTER only_in_stock;
UPDATE qt_configurations q
JOIN (
SELECT
id,
ROW_NUMBER() OVER (
PARTITION BY COALESCE(NULLIF(TRIM(project_uuid), ''), '__NO_PROJECT__')
ORDER BY created_at ASC, id ASC
) AS rn
FROM qt_configurations
WHERE line_no IS NULL OR line_no <= 0
) ranked ON ranked.id = q.id
SET q.line_no = ranked.rn * 10;
ALTER TABLE qt_configurations
ADD INDEX IF NOT EXISTS idx_qt_configurations_project_line_no (project_uuid, line_no);

6
package-lock.json generated Normal file
View File

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

1
package.json Normal file
View File

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

View File

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

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,256 @@
{{define "title"}}QuoteForge - Партномера{{end}}
{{define "content"}}
<div class="space-y-4">
<h1 class="text-2xl font-bold text-gray-900">Партномера</h1>
<!-- Summary cards -->
<div id="summary-cards" class="grid grid-cols-2 md:grid-cols-4 gap-4 hidden">
<div class="bg-white rounded-lg shadow p-4">
<div class="text-xs text-gray-500 mb-1">Активный лист</div>
<div id="card-version" class="font-mono font-semibold text-gray-800 text-sm truncate"></div>
<div id="card-date" class="text-xs text-gray-400 mt-0.5"></div>
</div>
<div class="bg-white rounded-lg shadow p-4">
<div class="text-xs text-gray-500 mb-1">Уникальных LOT</div>
<div id="card-lots" class="text-2xl font-bold text-blue-600"></div>
</div>
<div class="bg-white rounded-lg shadow p-4">
<div class="text-xs text-gray-500 mb-1">Всего PN</div>
<div id="card-pn-total" class="text-2xl font-bold text-gray-800"></div>
</div>
<div class="bg-white rounded-lg shadow p-4">
<div class="text-xs text-gray-500 mb-1">Primary PN</div>
<div id="card-pn-primary" class="text-2xl font-bold text-green-600"></div>
</div>
</div>
<div id="summary-empty" class="hidden bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-sm text-yellow-800">
Нет активного листа сопоставлений. Нажмите «Синхронизировать» для загрузки с сервера.
</div>
<!-- Active book items with search -->
<div id="active-book-section" class="hidden bg-white rounded-lg shadow overflow-hidden">
<div class="px-4 py-3 border-b flex items-center justify-between gap-3">
<span class="font-semibold text-gray-800 whitespace-nowrap">Сопоставления активного листа</span>
<input type="text" id="pn-search" placeholder="Поиск по PN или LOT..."
class="flex-1 max-w-xs px-3 py-1.5 border rounded text-sm focus:ring-2 focus:ring-blue-400 focus:outline-none"
oninput="filterItems(this.value)">
<span id="pn-count" class="text-xs text-gray-400 whitespace-nowrap"></span>
</div>
<div class="overflow-x-auto">
<table class="w-full text-sm">
<thead class="bg-gray-50 text-gray-600 sticky top-0">
<tr>
<th class="px-4 py-2 text-left">Partnumber</th>
<th class="px-4 py-2 text-left">LOT</th>
<th class="px-4 py-2 text-center w-24">Primary</th>
<th class="px-4 py-2 text-left">Описание</th>
</tr>
</thead>
<tbody id="active-items-body"></tbody>
</table>
</div>
</div>
<!-- All books list (collapsed by default) -->
<div class="bg-white rounded-lg shadow overflow-hidden">
<!-- Header row — always visible -->
<div class="px-4 py-3 flex items-center justify-between">
<button onclick="toggleBooksSection()" class="flex items-center gap-2 text-sm font-semibold text-gray-800 hover:text-gray-600 select-none">
<svg id="books-chevron" class="w-4 h-4 text-gray-400 transition-transform duration-150" fill="none" stroke="currentColor" stroke-width="2" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" d="M9 5l7 7-7 7"/></svg>
Снимки сопоставлений (Partnumber Books)
</button>
<button onclick="syncPartnumberBooks()" class="px-4 py-2 bg-orange-500 text-white rounded hover:bg-orange-600 text-sm font-medium">
Синхронизировать
</button>
</div>
<!-- Collapsible body -->
<div id="books-section-body" class="hidden border-t">
<div id="books-list-loading" class="p-6 text-center text-gray-400 text-sm">Загрузка...</div>
<table id="books-table" class="w-full text-sm hidden">
<thead class="bg-gray-50 text-gray-600">
<tr>
<th class="px-4 py-2 text-left">Версия</th>
<th class="px-4 py-2 text-left">Дата</th>
<th class="px-4 py-2 text-right">Позиций</th>
<th class="px-4 py-2 text-center">Статус</th>
</tr>
</thead>
<tbody id="books-table-body"></tbody>
</table>
<div id="books-empty" class="hidden p-6 text-center text-gray-400 text-sm">
Нет загруженных снимков.
</div>
<!-- Pagination -->
<div id="books-pagination" class="hidden px-4 py-3 border-t flex items-center justify-between text-sm text-gray-600">
<span id="books-page-info"></span>
<div class="flex gap-2">
<button id="books-prev" onclick="changeBooksPage(-1)" class="px-3 py-1 rounded border hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed">← Назад</button>
<button id="books-next" onclick="changeBooksPage(1)" class="px-3 py-1 rounded border hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed">Вперёд →</button>
</div>
</div>
</div>
</div>
</div>
<script>
let allItems = [];
let allBooks = [];
let booksPage = 1;
const BOOKS_PER_PAGE = 10;
function toggleBooksSection() {
const body = document.getElementById('books-section-body');
const chevron = document.getElementById('books-chevron');
const collapsed = body.classList.toggle('hidden');
chevron.style.transform = collapsed ? '' : 'rotate(90deg)';
}
async function loadBooks() {
let resp, data;
try {
resp = await fetch('/api/partnumber-books');
data = await resp.json();
} catch (e) {
document.getElementById('books-list-loading').classList.add('hidden');
document.getElementById('books-empty').classList.remove('hidden');
return;
}
allBooks = data.books || [];
document.getElementById('books-list-loading').classList.add('hidden');
if (!allBooks.length) {
document.getElementById('books-empty').classList.remove('hidden');
document.getElementById('summary-empty').classList.remove('hidden');
return;
}
booksPage = 1;
renderBooksPage();
const active = allBooks.find(b => b.is_active) || allBooks[0];
await loadActiveBookItems(active);
}
function renderBooksPage() {
const total = allBooks.length;
const totalPages = Math.ceil(total / BOOKS_PER_PAGE);
const start = (booksPage - 1) * BOOKS_PER_PAGE;
const pageBooks = allBooks.slice(start, start + BOOKS_PER_PAGE);
const tbody = document.getElementById('books-table-body');
tbody.innerHTML = '';
pageBooks.forEach(b => {
const tr = document.createElement('tr');
tr.className = 'border-b hover:bg-gray-50';
tr.innerHTML = `
<td class="px-4 py-2 font-mono text-xs">${b.version}</td>
<td class="px-4 py-2 text-gray-500 text-xs">${b.created_at}</td>
<td class="px-4 py-2 text-right text-xs">${b.item_count}</td>
<td class="px-4 py-2 text-center">
${b.is_active
? '<span class="px-2 py-0.5 bg-green-100 text-green-700 rounded text-xs">Активный</span>'
: '<span class="px-2 py-0.5 bg-gray-100 text-gray-500 rounded text-xs">Архив</span>'}
</td>
`;
tbody.appendChild(tr);
});
document.getElementById('books-table').classList.remove('hidden');
// Pagination controls
if (total > BOOKS_PER_PAGE) {
document.getElementById('books-pagination').classList.remove('hidden');
document.getElementById('books-page-info').textContent =
`Снимки ${start + 1}${Math.min(start + BOOKS_PER_PAGE, total)} из ${total}`;
document.getElementById('books-prev').disabled = booksPage === 1;
document.getElementById('books-next').disabled = booksPage === totalPages;
} else {
document.getElementById('books-pagination').classList.add('hidden');
}
}
function changeBooksPage(delta) {
const totalPages = Math.ceil(allBooks.length / BOOKS_PER_PAGE);
booksPage = Math.max(1, Math.min(totalPages, booksPage + delta));
renderBooksPage();
}
async function loadActiveBookItems(book) {
let resp, data;
try {
resp = await fetch(`/api/partnumber-books/${book.server_id}`);
data = await resp.json();
} catch (e) {
return;
}
if (!resp.ok) return;
allItems = data.items || [];
const lots = new Set(allItems.map(i => i.lot_name));
const primaryCount = allItems.filter(i => i.is_primary_pn).length;
document.getElementById('card-version').textContent = book.version;
document.getElementById('card-date').textContent = book.created_at;
document.getElementById('card-lots').textContent = lots.size;
document.getElementById('card-pn-total').textContent = allItems.length;
document.getElementById('card-pn-primary').textContent = primaryCount;
document.getElementById('summary-cards').classList.remove('hidden');
document.getElementById('active-book-section').classList.remove('hidden');
renderItems(allItems);
}
function renderItems(items) {
const tbody = document.getElementById('active-items-body');
tbody.innerHTML = '';
items.forEach(item => {
const tr = document.createElement('tr');
tr.className = 'border-b hover:bg-gray-50';
tr.innerHTML = `
<td class="px-4 py-1.5 font-mono text-xs">${item.partnumber}</td>
<td class="px-4 py-1.5 text-xs font-medium text-blue-700">${item.lot_name}</td>
<td class="px-4 py-1.5 text-center text-green-600 text-xs">${item.is_primary_pn ? '✓' : ''}</td>
<td class="px-4 py-1.5 text-xs text-gray-500">${item.description || ''}</td>
`;
tbody.appendChild(tr);
});
document.getElementById('pn-count').textContent = `${items.length} из ${allItems.length}`;
}
function filterItems(query) {
const q = query.trim().toLowerCase();
if (!q) { renderItems(allItems); return; }
renderItems(allItems.filter(i =>
i.partnumber.toLowerCase().includes(q) || i.lot_name.toLowerCase().includes(q)
));
}
async function syncPartnumberBooks() {
let resp, data;
try {
resp = await fetch('/api/sync/partnumber-books', {method: 'POST'});
data = await resp.json();
} catch (e) {
showToast('Ошибка синхронизации', 'error');
return;
}
if (data.success) {
showToast(`Синхронизировано: ${data.synced} листов`, 'success');
loadBooks();
} else if (data.blocked) {
showToast(`Синк заблокирован: ${data.reason_text}`, 'error');
} else {
showToast('Ошибка: ' + (data.error || 'неизвестная ошибка'), 'error');
}
}
document.addEventListener('DOMContentLoaded', loadBooks);
</script>
{{end}}
{{template "base" .}}

View File

@@ -211,6 +211,8 @@ const projectUUID = '{{.ProjectUUID}}';
let configStatusMode = 'active';
let project = null;
let allConfigs = [];
let dragConfigUUID = '';
let isReorderingConfigs = false;
let projectVariants = [];
let projectsCatalog = [];
let variantMenuInitialized = false;
@@ -221,6 +223,11 @@ function escapeHtml(text) {
return div.innerHTML;
}
function formatMoneyNoDecimals(value) {
const safe = Number.isFinite(Number(value)) ? Number(value) : 0;
return '$' + Math.round(safe).toLocaleString('en-US');
}
function resolveProjectTrackerURL(projectData) {
if (!projectData) return '';
const explicitURL = (projectData.tracker_url || '').trim();
@@ -350,42 +357,53 @@ function renderConfigs(configs) {
let totalSum = 0;
let html = '<div class="bg-white rounded-lg shadow overflow-hidden"><table class="w-full">';
html += '<thead class="bg-gray-50"><tr>';
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">Line</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-left text-xs font-medium text-gray-500 uppercase">Цена (за 1 шт)</th>';
html += '<th class="px-4 py-3 text-left text-xs font-medium text-gray-500 uppercase">Цена за 1 шт.</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="px-4 py-3 text-center 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="px-2 py-3 text-center text-xs font-medium text-gray-500 uppercase w-12"></th>';
html += '<th class="px-4 py-3 text-right text-xs font-medium text-gray-500 uppercase">Действия</th>';
html += '</tr></thead><tbody class="divide-y">';
html += '</tr></thead><tbody class="divide-y" id="project-configs-tbody">';
configs.forEach(c => {
const date = new Date(c.created_at).toLocaleDateString('ru-RU');
configs.forEach((c, idx) => {
const total = c.total_price || 0;
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 = (idx + 1) * 10;
const serverModel = (c.server_model || '').trim() || '—';
totalSum += total;
html += '<tr class="hover:bg-gray-50">';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + date + '</td>';
const draggable = configStatusMode === 'active' ? 'true' : 'false';
html += '<tr class="hover:bg-gray-50" draggable="' + draggable + '" data-config-uuid="' + c.uuid + '" ondragstart="onConfigDragStart(event)" ondragover="onConfigDragOver(event)" ondragleave="onConfigDragLeave(event)" ondrop="onConfigDrop(event)" ondragend="onConfigDragEnd(event)">';
if (configStatusMode === 'active') {
html += '<td class="px-4 py-3 text-sm text-gray-500">';
html += '<span class="inline-flex items-center gap-2"><span class="drag-handle text-gray-400 hover:text-gray-700 cursor-grab active:cursor-grabbing select-none" title="Перетащить для изменения порядка" aria-label="Перетащить">';
html += '<svg class="w-4 h-4 pointer-events-none" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 6h.01M8 12h.01M8 18h.01M16 6h.01M16 12h.01M16 18h.01"></path></svg>';
html += '</span><span>' + lineValue + '</span></span></td>';
} else {
html += '<td class="px-4 py-3 text-sm text-gray-500">' + lineValue + '</td>';
}
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(serverModel) + '</td>';
if (configStatusMode === 'archived') {
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700">' + escapeHtml(c.name) + '</td>';
} else {
html += '<td class="px-4 py-3 text-sm font-medium"><a href="/configurator?uuid=' + c.uuid + '" class="text-blue-600 hover:text-blue-800 hover:underline">' + escapeHtml(c.name) + '</a></td>';
}
html += '<td class="px-4 py-3 text-sm text-gray-500">' + escapeHtml(author) + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-500">$' + unitPrice.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
html += '<td class="px-4 py-3 text-sm text-gray-500">' + formatMoneyNoDecimals(unitPrice) + '</td>';
if (configStatusMode === 'archived') {
html += '<td class="px-4 py-3 text-sm text-gray-500">' + serverCount + '</td>';
} else {
html += '<td class="px-4 py-3 text-sm text-gray-500"><input type="number" min="1" value="' + serverCount + '" class="w-16 px-1 py-0.5 border rounded text-center text-sm" data-uuid="' + c.uuid + '" data-prev="' + serverCount + '" onchange="updateConfigServerCount(this)"></td>';
}
html += '<td class="px-4 py-3 text-sm text-right" data-total-uuid="' + c.uuid + '">$' + total.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
html += '<td class="px-4 py-3 text-sm text-right" data-total-uuid="' + c.uuid + '">' + formatMoneyNoDecimals(total) + '</td>';
const versionNo = c.current_version_no || 1;
html += '<td class="px-4 py-3 text-sm text-center text-gray-500">v' + versionNo + '</td>';
html += '<td class="px-4 py-3 text-sm text-right space-x-2">';
html += '<td class="px-2 py-3 text-sm text-center text-gray-500 w-12">v' + versionNo + '</td>';
html += '<td class="px-4 py-3 text-sm text-right whitespace-nowrap"><div class="inline-flex items-center justify-end gap-2">';
if (configStatusMode === 'archived') {
html += '<button onclick="reactivateConfig(\'' + c.uuid + '\')" class="text-emerald-600 hover:text-emerald-800" title="Восстановить">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path></svg></button>';
@@ -397,15 +415,16 @@ function renderConfigs(configs) {
html += '<button onclick="deleteConfig(\'' + c.uuid + '\')" class="text-red-600 hover:text-red-800" title="В архив">';
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg></button>';
}
html += '</td></tr>';
html += '</div></td></tr>';
});
html += '</tbody>';
html += '<tfoot class="bg-gray-50 border-t">';
html += '<tr>';
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700" colspan="4">Итого по проекту</td>';
html += '<td class="px-4 py-3 text-sm font-medium text-gray-700" colspan="5">Итого по проекту</td>';
html += '<td class="px-4 py-3 text-sm text-gray-700">' + configs.length + '</td>';
html += '<td class="px-4 py-3 text-sm text-right font-semibold text-gray-900" data-footer-total="1">$' + totalSum.toLocaleString('en-US', {minimumFractionDigits: 2}) + '</td>';
html += '<td class="px-4 py-3 text-sm text-right font-semibold text-gray-900" data-footer-total="1">' + formatMoneyNoDecimals(totalSum) + '</td>';
html += '<td class="px-4 py-3"></td>';
html += '<td class="px-4 py-3"></td>';
html += '<td class="px-4 py-3"></td>';
html += '</tr>';
@@ -994,6 +1013,105 @@ document.addEventListener('keydown', function(e) {
}
});
function onConfigDragStart(event) {
if (configStatusMode !== 'active' || isReorderingConfigs) {
event.preventDefault();
return;
}
const row = event.target.closest('tr[data-config-uuid]');
if (!row) {
event.preventDefault();
return;
}
dragConfigUUID = row.dataset.configUuid || '';
if (!dragConfigUUID) {
event.preventDefault();
return;
}
row.classList.add('opacity-50');
event.dataTransfer.effectAllowed = 'move';
event.dataTransfer.setData('text/plain', dragConfigUUID);
}
function onConfigDragOver(event) {
if (!dragConfigUUID || configStatusMode !== 'active') return;
event.preventDefault();
const row = event.target.closest('tr[data-config-uuid]');
if (!row || row.dataset.configUuid === dragConfigUUID) return;
row.classList.add('ring-2', 'ring-blue-300');
}
function onConfigDragLeave(event) {
const row = event.target.closest('tr[data-config-uuid]');
if (!row) return;
row.classList.remove('ring-2', 'ring-blue-300');
}
async function onConfigDrop(event) {
if (!dragConfigUUID || configStatusMode !== 'active' || isReorderingConfigs) return;
event.preventDefault();
const targetRow = event.target.closest('tr[data-config-uuid]');
if (!targetRow) return;
targetRow.classList.remove('ring-2', 'ring-blue-300');
const targetUUID = targetRow.dataset.configUuid || '';
if (!targetUUID || targetUUID === dragConfigUUID) return;
const previous = allConfigs.slice();
const fromIndex = allConfigs.findIndex(c => c.uuid === dragConfigUUID);
const toIndex = allConfigs.findIndex(c => c.uuid === targetUUID);
if (fromIndex < 0 || toIndex < 0) return;
const [moved] = allConfigs.splice(fromIndex, 1);
allConfigs.splice(toIndex, 0, moved);
renderConfigs(allConfigs);
await saveConfigReorder(previous);
}
function onConfigDragEnd() {
document.querySelectorAll('tr[data-config-uuid]').forEach(row => {
row.classList.remove('ring-2', 'ring-blue-300', 'opacity-50');
});
dragConfigUUID = '';
}
async function saveConfigReorder(previousConfigs) {
if (isReorderingConfigs) return;
isReorderingConfigs = true;
const orderedUUIDs = allConfigs.map(c => c.uuid);
try {
const resp = await fetch('/api/projects/' + projectUUID + '/configs/reorder', {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({ordered_uuids: orderedUUIDs}),
});
if (!resp.ok) {
const data = await resp.json().catch(() => ({}));
throw new Error(data.error || 'Не удалось сохранить порядок');
}
const data = await resp.json();
allConfigs = data.configurations || allConfigs;
renderConfigs(allConfigs);
if (typeof showToast === 'function') {
showToast('Порядок сохранён', 'success');
}
} catch (e) {
allConfigs = previousConfigs.slice();
renderConfigs(allConfigs);
if (typeof showToast === 'function') {
showToast(e.message || 'Не удалось сохранить порядок', 'error');
} else {
alert(e.message || 'Не удалось сохранить порядок');
}
} finally {
isReorderingConfigs = false;
dragConfigUUID = '';
}
}
async function updateConfigServerCount(input) {
const uuid = input.dataset.uuid;
const prevValue = parseInt(input.dataset.prev) || 1;
@@ -1018,7 +1136,7 @@ async function updateConfigServerCount(input) {
// Update row total price
const totalCell = document.querySelector('[data-total-uuid="' + uuid + '"]');
if (totalCell && updated.total_price != null) {
totalCell.textContent = '$' + updated.total_price.toLocaleString('en-US', {minimumFractionDigits: 2});
totalCell.textContent = formatMoneyNoDecimals(updated.total_price);
}
// Update the config in allConfigs and recalculate footer total
for (let i = 0; i < allConfigs.length; i++) {
@@ -1040,7 +1158,7 @@ function updateFooterTotal() {
allConfigs.forEach(c => { totalSum += (c.total_price || 0); });
const footer = document.querySelector('tfoot td[data-footer-total]');
if (footer) {
footer.textContent = '$' + totalSum.toLocaleString('en-US', {minimumFractionDigits: 2});
footer.textContent = formatMoneyNoDecimals(totalSum);
}
}

View File

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