Compare commits

...

14 Commits

Author SHA1 Message Date
Mikhail Chusavitin c599897142 Simplify project documentation and release notes 2026-03-15 16:43:06 +03:00
Mikhail Chusavitin c964d66e64 Harden local runtime safety and error handling 2026-03-15 16:28:32 +03:00
Mikhail Chusavitin f0e6bba7e9 Remove partnumbers column from all pricelist views (data mixed across sources)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 14:24:15 +03:00
Mikhail Chusavitin 61d7e493bd Hide partnumbers column for competitor pricelist (data not linked locally)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 14:23:20 +03:00
Mikhail Chusavitin f930c79b34 Remove Поставщик column from pricelist detail (placeholder data)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 14:22:26 +03:00
Mikhail Chusavitin a0a57e0969 Redesign pricelist detail: differentiated layout by source type
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 13:14:14 +03:00
Mikhail Chusavitin b3003c4858 Redesign pricing tab: split into purchase/sale tables with unit prices
- Split into two sections: Цена покупки and Цена продажи
- All price cells show unit price (per 1 pcs); totals only in footer
- Added note "Цены указаны за 1 шт." next to each table heading
- Buy table: Своя цена redistributes proportionally with green/red coloring vs estimate; footer shows % diff
- Sale table: configurable uplift (default 1.3) applied to estimate; Склад/Конкуренты fixed at ×1.3
- Footer Склад/Конкуренты marked red with asterisk tooltip when coverage is partial
- CSV export updated: all 8 columns, SPEC-BUY/SPEC-SALE suffix, no % annotation in totals

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 12:55:17 +03:00
Mikhail Chusavitin e2da8b4253 Fix competitor price display and pricelist item deduplication
- Render competitor prices in Pricing tab (all three row branches)
- Add footer total accumulation for competitor column
- Deduplicate local_pricelist_items via migration + unique index
- Use ON CONFLICT DO NOTHING in SaveLocalPricelistItems to prevent duplicates on concurrent sync

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-13 10:33:04 +03:00
Mikhail Chusavitin 06397a6bd1 Local-first runtime cleanup and recovery hardening 2026-03-07 23:18:07 +03:00
Mikhail Chusavitin 4e977737ee Document legacy BOM tables 2026-03-07 21:13:08 +03:00
Mikhail Chusavitin 7c3752f110 Add vendor workspace import and pricing export workflow 2026-03-07 21:03:40 +03:00
Mikhail Chusavitin 08ecfd0826 Merge branch 'feature/vendor-spec-import' 2026-03-06 10:54:05 +03:00
Mikhail Chusavitin 42458455f7 Fix article generator producing 1xINTEL in GPU segment
MB_ lots (e.g. MB_INTEL_..._GPU8) are incorrectly categorized as GPU
in the pricelist. Two fixes:
- Skip MB_ lots in buildGPUSegment regardless of pricelist category
- Add INTEL to vendor token skip list in parseGPUModel (was missing,
  unlike AMD/NV/NVIDIA which were already skipped)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 10:53:31 +03:00
Mikhail Chusavitin 8663a87d28 Fix article generator producing 1xINTEL in GPU segment
MB_ lots (e.g. MB_INTEL_..._GPU8) are incorrectly categorized as GPU
in the pricelist. Two fixes:
- Skip MB_ lots in buildGPUSegment regardless of pricelist category
- Add INTEL to vendor token skip list in parseGPUModel (was missing,
  unlike AMD/NV/NVIDIA which were already skipped)

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-06 10:52:22 +03:00
92 changed files with 6209 additions and 5244 deletions
+6 -1
View File
@@ -75,7 +75,12 @@ Network Trash Folder
Temporary Items Temporary Items
.apdisk .apdisk
# Release artifacts (binaries, archives, checksums), but DO track releases/memory/ for changelog # Release artifacts (binaries, archives, checksums), but keep markdown notes tracked
releases/* releases/*
!releases/README.md
!releases/memory/ !releases/memory/
!releases/memory/** !releases/memory/**
!releases/**/
releases/**/*
!releases/README.md
!releases/*/RELEASE_NOTES.md
+32 -45
View File
@@ -1,66 +1,53 @@
# QuoteForge # QuoteForge
**Корпоративный конфигуратор серверов и расчёт КП** Local-first desktop web app for server configuration, quotation, and project work.
Offline-first архитектура: пользовательские операции через локальную SQLite, MariaDB только для синхронизации. Runtime model:
- user work is stored in local SQLite;
- MariaDB is used only for setup checks and background sync;
- HTTP server binds to loopback only.
![Go Version](https://img.shields.io/badge/Go-1.22+-00ADD8?style=flat&logo=go) ## What the app does
![License](https://img.shields.io/badge/License-Proprietary-red)
![Status](https://img.shields.io/badge/Status-In%20Development-yellow)
--- - configuration editor with price refresh from synced pricelists;
- projects with variants and ordered configurations;
- vendor BOM import and PN -> LOT resolution;
- revision history with rollback;
- rotating local backups.
## Документация ## Run
Полная архитектурная документация хранится в **[bible/](bible/README.md)**:
| Файл | Тема |
|------|------|
| [bible/01-overview.md](bible/01-overview.md) | Продукт, возможности, технологии, структура репо |
| [bible/02-architecture.md](bible/02-architecture.md) | Local-first, sync, ценообразование, версионность |
| [bible/03-database.md](bible/03-database.md) | SQLite и MariaDB схемы, права, миграции |
| [bible/04-api.md](bible/04-api.md) | Все API endpoints и web-маршруты |
| [bible/05-config.md](bible/05-config.md) | Конфигурация, env vars, установка |
| [bible/06-backup.md](bible/06-backup.md) | Резервное копирование |
| [bible/07-dev.md](bible/07-dev.md) | Команды разработки, стиль кода, guardrails |
---
## Быстрый старт
```bash ```bash
# Применить миграции
go run ./cmd/qfs -migrate
# Запустить
go run ./cmd/qfs go run ./cmd/qfs
# или
make run
``` ```
Приложение: http://localhost:8080 → откроется `/setup` для настройки подключения к MariaDB. Useful commands:
```bash ```bash
# Сборка go run ./cmd/qfs -migrate
go test ./...
go vet ./...
make build-release make build-release
# Проверка
go build ./cmd/qfs && go vet ./...
``` ```
--- On first run the app creates a minimal `config.yaml`, starts on `http://127.0.0.1:8080`, and opens `/setup` if DB credentials were not saved yet.
## Releases & Changelog ## Documentation
Changelog между версиями: `releases/memory/v{major}.{minor}.{patch}.md` - Shared engineering rules: [bible/README.md](bible/README.md)
- Project architecture: [bible-local/README.md](bible-local/README.md)
- Release notes: `releases/<version>/RELEASE_NOTES.md`
--- `bible-local/` is the source of truth for QuoteForge-specific architecture. If code changes behavior, update the matching file there in the same commit.
## Поддержка ## Repository map
- Email: mike@mchus.pro ```text
- Internal: @mchus cmd/ entry points and migration tools
internal/ application code
## Лицензия web/ templates and static assets
bible/ shared engineering rules
Собственность компании, только для внутреннего использования. См. [LICENSE](LICENSE). bible-local/ project architecture and contracts
releases/ packaged release artifacts and release notes
config.example.yaml runtime config reference
```
+33
View File
@@ -0,0 +1,33 @@
-- Generated from /Users/mchusavitin/Downloads/acc.csv
-- Unambiguous rows only. Rows from headers without a date were skipped.
INSERT INTO lot_log (`lot`, `supplier`, `date`, `price`, `quality`, `comments`) VALUES
('ACC_RMK_L_Type', '', '2024-04-01', 19, NULL, 'header supplier missing in source (45383)'),
('ACC_RMK_SLIDE', '', '2024-04-01', 31, NULL, 'header supplier missing in source (45383)'),
('NVLINK_2S_Bridge', '', '2023-01-01', 431, NULL, 'header supplier missing in source (44927)'),
('NVLINK_2S_Bridge', 'Jevy Yang', '2025-01-15', 139, NULL, NULL),
('NVLINK_2S_Bridge', 'Wendy', '2025-01-15', 143, NULL, NULL),
('NVLINK_2S_Bridge', 'HONCH (Darian)', '2025-05-06', 155, NULL, NULL),
('NVLINK_2S_Bridge', 'HONCH (Sunny)', '2025-06-17', 155, NULL, NULL),
('NVLINK_2S_Bridge', 'Wendy', '2025-07-02', 145, NULL, NULL),
('NVLINK_2S_Bridge', 'Honch (Sunny)', '2025-07-10', 155, NULL, NULL),
('NVLINK_2S_Bridge', 'Honch (Yan)', '2025-08-07', 155, NULL, NULL),
('NVLINK_2S_Bridge', 'Jevy', '2025-09-09', 155, NULL, NULL),
('NVLINK_2S_Bridge', 'Honch (Darian)', '2025-11-17', 102, NULL, NULL),
('NVLINK_2W_Bridge(H200)', '', '2023-01-01', 405, NULL, 'header supplier missing in source (44927)'),
('NVLINK_2W_Bridge(H200)', 'network logic / Stephen', '2025-02-10', 305, NULL, NULL),
('NVLINK_2W_Bridge(H200)', 'JEVY', '2025-02-18', 411, NULL, NULL),
('NVLINK_4W_Bridge(H200)', '', '2023-01-01', 820, NULL, 'header supplier missing in source (44927)'),
('NVLINK_4W_Bridge(H200)', 'network logic / Stephen', '2025-02-10', 610, NULL, NULL),
('NVLINK_4W_Bridge(H200)', 'JEVY', '2025-02-18', 754, NULL, NULL),
('25G_SFP28_MMA2P00-AS', 'HONCH (Doris)', '2025-02-19', 65, NULL, NULL),
('ACC_SuperCap', '', '2024-04-01', 59, NULL, 'header supplier missing in source (45383)'),
('ACC_SuperCap', 'Chiphome', '2025-02-28', 48, NULL, NULL);
-- Skipped source values due to missing date in header:
-- lot=ACC_RMK_L_Type; header=FOB; price=19; reason=header has supplier but no date
-- lot=ACC_RMK_SLIDE; header=FOB; price=31; reason=header has supplier but no date
-- lot=NVLINK_2S_Bridge; header=FOB; price=155; reason=header has supplier but no date
-- lot=NVLINK_2W_Bridge(H200); header=FOB; price=405; reason=header has supplier but no date
-- lot=NVLINK_4W_Bridge(H200); header=FOB; price=754; reason=header has supplier but no date
-- lot=25G_SFP28_MMA2P00-AS; header=FOB; price=65; reason=header has supplier but no date
-- lot=ACC_SuperCap; header=FOB; price=48; reason=header has supplier but no date
+1 -1
Submodule bible updated: 34b457d654...5a69e0bba8
+55 -104
View File
@@ -1,119 +1,70 @@
# 01 — Product Overview # 01 - Overview
## What is QuoteForge ## Product
A corporate server configuration and quotation tool. QuoteForge is a local-first tool for server configuration, quotation, and project tracking.
Operates in **strict local-first** mode: all user operations go through local SQLite; MariaDB is used only for synchronization.
--- Core user flows:
- create and edit configurations locally;
- calculate prices from synced pricelists;
- group configurations into projects and variants;
- import vendor workspaces and map vendor PNs to internal LOTs;
- review revision history and roll back safely.
## Features ## Runtime model
### For Users QuoteForge is a single-user thick client.
- Mobile-first interface — works comfortably on phones and tablets
- Server configurator — step-by-step component selection
- Automatic price calculation — based on pricelists from local cache
- CSV export — ready-to-use specifications for clients
- Configuration history — versioned snapshots with rollback support
- Full offline operation — continue working without network, sync later
- Guarded synchronization — sync is blocked by preflight check if local schema is not ready
### User Roles Rules:
- runtime HTTP binds to loopback only;
- browser requests are treated as part of the same local user session;
- MariaDB is not a live dependency for normal CRUD;
- if non-loopback deployment is ever introduced, auth/RBAC must be added first.
| Role | Permissions | ## Product scope
|------|-------------|
| `viewer` | View, create quotes, export |
| `editor` | + save configurations |
| `pricing_admin` | + manage prices and alerts |
| `admin` | Full access, user management |
### Price Freshness Indicators In scope:
- configurator and quote calculation;
- projects, variants, and configuration ordering;
- local revision history;
- read-only pricelist browsing from SQLite cache;
- background sync with MariaDB;
- rotating local backups.
| Color | Status | Condition | Out of scope and intentionally removed:
|-------|--------|-----------| - admin pricing UI/API;
| Green | Fresh | < 30 days, ≥ 3 sources | - alerts and notification workflows;
| Yellow | Normal | 3060 days | - stock import tooling;
| Orange | Aging | 6090 days | - cron jobs and importer utilities.
| Red | Stale | > 90 days or no data |
--- ## Tech stack
## Tech Stack
| Layer | Stack | | Layer | Stack |
|-------|-------| | --- | --- |
| Backend | Go 1.22+, Gin, GORM | | Backend | Go, Gin, GORM |
| Frontend | HTML, Tailwind CSS, htmx | | Frontend | HTML templates, htmx, Tailwind CSS |
| Local DB | SQLite (`qfs.db`) | | Local storage | SQLite |
| Server DB | MariaDB 11+ (sync + server admin) | | Sync transport | MariaDB |
| Export | encoding/csv, excelize (XLSX) | | Export | CSV and XLSX generation |
--- ## Repository map
## Product Scope
**In scope:**
- Component configurator and quotation calculation
- Projects and configurations
- Read-only pricelist viewing from local cache
- Sync (pull components/pricelists, push local changes)
**Out of scope (removed intentionally — do not restore):**
- Admin pricing UI/API
- Stock import
- Alerts
- Cron/importer utilities
---
## Repository Structure
```text
cmd/
qfs/ main HTTP runtime
migrate/ server migration tool
migrate_ops_projects/ OPS project migration helper
internal/
appstate/ backup and runtime state
config/ runtime config parsing
handlers/ HTTP handlers
localdb/ SQLite models and migrations
repository/ repositories
services/ business logic and sync
web/
templates/ HTML templates
static/ static assets
bible/ shared engineering rules
bible-local/ project-specific architecture
releases/ release artifacts and notes
``` ```
quoteforge/
├── cmd/
│ ├── qfs/main.go # HTTP server entry point
│ ├── migrate/ # Migration tool
│ └── migrate_ops_projects/ # OPS project migrator
├── internal/
│ ├── appmeta/ # App version metadata
│ ├── appstate/ # State management, backup
│ ├── article/ # Article generation
│ ├── config/ # Config parsing
│ ├── db/ # DB initialization
│ ├── handlers/ # HTTP handlers
│ ├── localdb/ # SQLite layer
│ ├── lotmatch/ # Lot matching logic
│ ├── middleware/ # Auth, CORS, etc.
│ ├── models/ # GORM models
│ ├── repository/ # Repository layer
│ └── services/ # Business logic
├── web/
│ ├── templates/ # HTML templates + partials
│ └── static/ # CSS, JS, assets
├── migrations/ # SQL migration files (30+)
├── bible/ # Architectural documentation (this section)
├── releases/memory/ # Per-version changelogs
├── config.example.yaml # Config template (the only one in repo)
└── go.mod
```
---
## Integration with Existing DB
QuoteForge integrates with the existing `RFQ_LOG` database:
**Read-only:**
- `lot` — component catalog
- `qt_lot_metadata` — extended component data
- `qt_categories` — categories
- `qt_pricelists`, `qt_pricelist_items` — pricelists
**Read + Write:**
- `qt_configurations` — configurations
- `qt_projects` — projects
**Sync service tables:**
- `qt_client_local_migrations` — migration catalog (SELECT only)
- `qt_client_schema_state` — applied migrations state
- `qt_pricelist_sync_status` — pricelist sync status
+47 -202
View File
@@ -1,220 +1,65 @@
# 02 Architecture # 02 - Architecture
## Local-First Principle ## Local-first rule
**SQLite** is the single source of truth for the user. SQLite is the runtime source of truth.
**MariaDB** is a sync server only — it never blocks local operations. MariaDB is sync transport plus setup and migration tooling.
``` ```text
User browser -> Gin handlers -> SQLite
-> pending_changes
background sync <------> MariaDB
SQLite (qfs.db) ← all CRUD operations go here
│ background sync (every 5 min)
MariaDB (RFQ_LOG) ← pull/push only
``` ```
**Rules:** Rules:
- All CRUD operations go through SQLite only - user CRUD must continue when MariaDB is offline;
- If MariaDB is unavailable → local work continues without restrictions - runtime handlers and pages must read and write SQLite only;
- Changes are queued in `pending_changes` and pushed on next sync - MariaDB access in runtime code is allowed only inside sync and setup flows;
- no live MariaDB fallback for reads that already exist in local cache.
--- ## Sync contract
## Synchronization Bidirectional:
- projects;
- configurations;
- `vendor_spec`;
- pending change metadata.
### Data Flow Diagram Pull-only:
- components;
- pricelists and pricelist items;
- partnumber books and partnumber book items.
``` Readiness guard:
[ SERVER / MariaDB ] - every sync push/pull runs a preflight check;
┌───────────────────────────┐ - blocked sync returns `423 Locked` with a machine-readable reason;
│ qt_projects │ - local work continues even when sync is blocked.
│ qt_configurations │
│ qt_pricelists │
│ qt_pricelist_items │
│ qt_pricelist_sync_status │
└─────────────┬─────────────┘
pull (projects/configs/pricelists)
┌────────────────────┴────────────────────┐
│ │
[ CLIENT A / SQLite ] [ CLIENT B / SQLite ]
local_projects local_projects
local_configurations local_configurations
local_pricelists local_pricelists
local_pricelist_items local_pricelist_items
pending_changes pending_changes
│ │
└────── push (projects/configs only) ─────┘
[ SERVER / MariaDB ]
```
### Sync Direction by Entity ## Pricing contract
| Entity | Direction | Prices come only from `local_pricelist_items`.
|--------|-----------|
| Configurations | Client ↔ Server ↔ Other Clients |
| Projects | Client ↔ Server ↔ Other Clients |
| Pricelists | Server → Clients only (no push) |
| Components | Server → Clients only |
Local pricelists not present on the server and not referenced by active configurations are deleted automatically on sync. Rules:
- `local_components` is metadata-only;
- quote calculation must not read prices from components;
- latest pricelist selection ignores snapshots without items;
- auto pricelist mode stays auto and must not be persisted as an explicit resolved ID.
### Soft Deletes (Archive Pattern) ## Configuration versioning
Configurations and projects are **never hard-deleted**. Deletion is archive via `is_active = false`. Configuration revisions are append-only snapshots stored in `local_configuration_versions`.
- `DELETE /api/configs/:uuid` → sets `is_active = false` (archived); can be restored via `reactivate` Rules:
- `DELETE /api/projects/:uuid` → archives a project **variant** only (`variant` field must be non-empty); main projects cannot be deleted via this endpoint - create a new revision only when spec or price content changes;
- rollback creates a new head revision from an old snapshot;
- rename, reorder, project move, and similar operational edits do not create a new revision snapshot;
- current revision pointer must be recoverable if legacy or damaged rows are found locally.
## Sync Readiness Guard ## Vendor BOM contract
Before every push/pull, a preflight check runs: Vendor BOM is stored in `vendor_spec` on the configuration row.
1. Is the server (MariaDB) reachable?
2. Can centralized local DB migrations be applied?
3. Does the application version satisfy `min_app_version` of pending migrations?
**If the check fails:** Rules:
- Local CRUD continues without restriction - PN to LOT resolution uses the active local partnumber book;
- Sync API returns `423 Locked` with `reason_code` and `reason_text` - canonical persisted mapping is `lot_mappings[]`;
- UI shows a red indicator with the block reason - QuoteForge does not use legacy BOM tables such as `qt_bom`, `qt_lot_bundles`, or `qt_lot_bundle_items`.
---
## Pricing
### Principle
**Prices come only from `local_pricelist_items`.**
Components (`local_components`) are metadata-only — they contain no pricing information.
### Lookup Pattern
```go
// Look up a price for a line item
price, found := s.lookupPriceByPricelistID(pricelistID, lotName)
if found && price > 0 {
// use price
}
// Inside lookupPriceByPricelistID:
localPL, err := s.localDB.GetLocalPricelistByServerID(pricelistID)
price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName)
```
### Multi-Level Pricelists
A configuration can reference up to three pricelists simultaneously:
| Field | Purpose |
|-------|---------|
| `pricelist_id` | Primary (estimate) |
| `warehouse_pricelist_id` | Warehouse pricing |
| `competitor_pricelist_id` | Competitor pricing |
Pricelist sources: `estimate` | `warehouse` | `competitor`
### "Auto" Pricelist Selection
Configurator supports explicit and automatic selection per source (`estimate`, `warehouse`, `competitor`):
- **Explicit mode:** concrete `pricelist_id` is set by user in settings.
- **Auto mode:** client sends no explicit ID for that source; backend resolves the current latest active pricelist.
`auto` must stay `auto` after price-level refresh and after manual "refresh prices":
- resolved IDs are runtime-only and must not overwrite user's mode;
- switching to explicit selection must clear runtime auto resolution for that source.
### Latest Pricelist Resolution Rules
For both server (`qt_pricelists`) and local cache (`local_pricelists`), "latest by source" is resolved with:
1. only pricelists that have at least one item (`EXISTS ...pricelist_items`);
2. deterministic sort: `created_at DESC, id DESC`.
This prevents selecting empty/incomplete snapshots and removes nondeterministic ties.
---
## Configuration Versioning
### Principle
Append-only for **spec+price** changes: immutable snapshots are stored in `local_configuration_versions`.
```
local_configurations
└── current_version_id ──► local_configuration_versions (v3) ← active
local_configuration_versions (v2)
local_configuration_versions (v1)
```
- `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
```bash
POST /api/configs/:uuid/rollback
{
"target_version": 3,
"note": "optional comment"
}
```
Result:
- A new version `vN` is created with `data` from the target version
- `change_note = "rollback to v{target_version}"` (+ note if provided)
- `current_version_id` is switched to the new version
- Configuration moves to `sync_status = pending`
### Sync Status Flow
```
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:
| Field | Description |
|-------|-------------|
| `configuration_uuid` | Identifier |
| `operation` | `create` / `update` / `rollback` |
| `current_version_id` | Active version ID |
| `current_version_no` | Version number |
| `snapshot` | Current configuration state |
| `idempotency_key` | For idempotent push |
| `conflict_policy` | `last_write_wins` |
---
## Background Processes
| Process | Interval | What it does |
|---------|----------|--------------|
| Sync worker | 5 min | push pending + pull all |
| Backup scheduler | configurable (`backup.time`) | creates ZIP archives |
+51 -190
View File
@@ -1,206 +1,67 @@
# 03 Database # 03 - Database
## SQLite (local, client-side) ## SQLite
File: `qfs.db` in the user-state directory (see [05-config.md](05-config.md)). SQLite is the local runtime database.
### Tables Main tables:
#### Components and Reference Data
| Table | Purpose | Key Fields |
|-------|---------|------------|
| `local_components` | Component metadata (NO prices) | `lot_name` (PK), `lot_description`, `category`, `model` |
| `connection_settings` | MariaDB connection settings | key-value store |
| `app_settings` | Application settings | `key` (PK), `value`, `updated_at` |
#### Pricelists
| Table | Purpose | Key Fields |
|-------|---------|------------|
| `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), `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` |
#### Sync
| Table | Purpose | | Table | Purpose |
|-------|---------| | --- | --- |
| `pending_changes` | Queue of changes to push to MariaDB | | `local_components` | synced component metadata |
| `local_schema_migrations` | Applied migrations (idempotency guard) | | `local_pricelists` | local pricelist headers |
| `local_pricelist_items` | local pricelist rows, the only runtime price source |
| `local_projects` | user projects |
| `local_configurations` | user configurations |
| `local_configuration_versions` | immutable revision snapshots |
| `local_partnumber_books` | partnumber book headers |
| `local_partnumber_book_items` | PN -> LOT catalog payload |
| `pending_changes` | sync queue |
| `connection_settings` | encrypted MariaDB connection settings |
| `app_settings` | local app state |
| `local_schema_migrations` | applied local migration markers |
--- Rules:
- cache tables may be rebuilt if local migration recovery requires it;
- user-authored tables must not be dropped as a recovery shortcut;
- `local_pricelist_items` is the only valid runtime source of prices;
- configuration `items` and `vendor_spec` are stored as JSON payloads inside configuration rows.
### Key SQLite Indexes ## MariaDB
```sql MariaDB is the central sync database.
-- Pricelists
INDEX local_pricelist_items(pricelist_id)
UNIQUE INDEX local_pricelists(server_id)
INDEX local_pricelists(source, created_at) -- used for "latest by source" queries
-- latest-by-source runtime query also applies deterministic tie-break by id DESC
-- Configurations Runtime read permissions:
INDEX local_configurations(pricelist_id) - `lot`
INDEX local_configurations(warehouse_pricelist_id) - `qt_lot_metadata`
INDEX local_configurations(competitor_pricelist_id) - `qt_categories`
INDEX local_configurations(project_uuid, line_no) -- project ordering (Line column) - `qt_pricelists`
UNIQUE INDEX local_configurations(uuid) - `qt_pricelist_items`
``` - `stock_log`
- `qt_partnumber_books`
- `qt_partnumber_book_items`
--- Runtime read/write permissions:
- `qt_projects`
- `qt_configurations`
- `qt_client_schema_state`
- `qt_pricelist_sync_status`
### `items` JSON Structure in Configurations Insert-only tracking:
- `qt_vendor_partnumber_seen`
```json Rules:
{ - QuoteForge runtime must not depend on any removed legacy BOM tables;
"items": [ - stock enrichment happens during sync and is persisted into SQLite;
{ - normal UI requests must not query MariaDB tables directly.
"lot_name": "CPU_AMD_9654",
"quantity": 2,
"unit_price": 123456.78,
"section": "Processors"
}
]
}
```
Prices are stored inside the `items` JSON field and refreshed from the pricelist on configuration refresh.
---
## MariaDB (server-side, sync-only)
Database: `RFQ_LOG`
### Tables and Permissions
| Table | Purpose | Permissions |
|-------|---------|-------------|
| `lot` | Component catalog | SELECT |
| `qt_lot_metadata` | Extended component data | SELECT |
| `qt_categories` | Component categories | SELECT |
| `qt_pricelists` | Pricelists | SELECT |
| `qt_pricelist_items` | Pricelist line items | SELECT |
| `lot_partnumbers` | Partnumber → lot mapping (pricelist enrichment) | SELECT |
| `stock_log` | Latest stock qty by partnumber (pricelist enrichment) | SELECT |
| `qt_configurations` | Saved configurations (includes `line_no`) | SELECT, INSERT, UPDATE |
| `lot_partnumbers` | Partnumber → lot mapping (pricelist enrichment) | SELECT |
| `stock_log` | Latest stock qty by partnumber (pricelist enrichment) | SELECT |
| `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
```sql
GRANT SELECT ON RFQ_LOG.lot TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_categories TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelists TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.lot_partnumbers TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.stock_log TO '<DB_USER>'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO '<DB_USER>'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO '<DB_USER>'@'%';
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;
```
### Create a New User
```sql
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY '<DB_PASSWORD>';
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.lot_partnumbers TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.stock_log TO 'quote_user'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
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'@'%';
```
**Note:** If pricelists sync but show `0` positions (or logs contain `enriching pricelist items with stock` + `SELECT denied`), verify `SELECT` on `lot_partnumbers` and `stock_log` in addition to `qt_pricelist_items`.
**Note:** If you see `Access denied for user ...@'<ip>'`, check for conflicting user entries (user@localhost vs user@'%').
---
## Migrations ## Migrations
### SQLite Migrations (local) — три уровня, выполняются при каждом старте SQLite:
- schema creation and additive changes go through GORM `AutoMigrate`;
- data fixes, index repair, and one-off rewrites go through `runLocalMigrations`;
- local migration state is tracked in `local_schema_migrations`.
**1. GORM AutoMigrate** (`internal/localdb/localdb.go`) — первый и основной уровень. MariaDB:
Список Go-моделей передаётся в `db.AutoMigrate(...)`. GORM создаёт отсутствующие таблицы и добавляет новые колонки. Колонки и таблицы **не удаляет**. - SQL files live in `migrations/`;
→ Для добавления новой таблицы или колонки достаточно добавить модель/поле и включить модель в AutoMigrate. - they are applied by `go run ./cmd/qfs -migrate`.
**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
- `min_app_version` — minimum app version required for the migration
---
## DB Debugging
```bash
# Inspect schema
sqlite3 ~/.local/state/quoteforge/qfs.db ".schema local_components"
sqlite3 ~/.local/state/quoteforge/qfs.db ".schema local_configurations"
# Check pricelist item count
sqlite3 ~/.local/state/quoteforge/qfs.db "SELECT COUNT(*) FROM local_pricelist_items"
# Check pending sync queue
sqlite3 ~/.local/state/quoteforge/qfs.db "SELECT COUNT(*) FROM pending_changes"
```
+108 -146
View File
@@ -1,163 +1,125 @@
# 04 API and Web Routes # 04 - API
## API Endpoints ## Public web routes
### Setup | Route | Purpose |
| --- | --- |
| `/` | configurator |
| `/configs` | configuration list |
| `/configs/:uuid/revisions` | revision history page |
| `/projects` | project list |
| `/projects/:uuid` | project detail |
| `/pricelists` | pricelist list |
| `/pricelists/:id` | pricelist detail |
| `/partnumber-books` | partnumber book page |
| `/setup` | DB setup page |
| Method | Endpoint | Purpose | ## Setup and health
|--------|----------|---------|
| GET | `/setup` | Initial setup page |
| POST | `/setup` | Save connection settings |
| POST | `/setup/test` | Test MariaDB connection |
| GET | `/setup/status` | Setup status |
### Components | Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/health` | process health |
| `GET` | `/setup` | setup page |
| `POST` | `/setup` | save tested DB settings |
| `POST` | `/setup/test` | test DB connection |
| `GET` | `/setup/status` | setup status |
| `GET` | `/api/db-status` | current DB/sync status |
| `GET` | `/api/current-user` | local user identity |
| `GET` | `/api/ping` | lightweight API ping |
| Method | Endpoint | Purpose | `POST /api/restart` exists only in `debug` mode.
|--------|----------|---------|
| GET | `/api/components` | List components (metadata only) |
| GET | `/api/components/:lot_name` | Component by lot_name |
| GET | `/api/categories` | List categories |
### Quote ## Reference data
| Method | Endpoint | Purpose | | Method | Path | Purpose |
|--------|----------|---------| | --- | --- | --- |
| POST | `/api/quote/validate` | Validate line items | | `GET` | `/api/components` | list component metadata |
| POST | `/api/quote/calculate` | Calculate quote (prices from pricelist) | | `GET` | `/api/components/:lot_name` | one component |
| POST | `/api/quote/price-levels` | Prices by level (estimate/warehouse/competitor) | | `GET` | `/api/categories` | list categories |
| `GET` | `/api/pricelists` | list local pricelists |
| `GET` | `/api/pricelists/latest` | latest pricelist by source |
| `GET` | `/api/pricelists/:id` | pricelist header |
| `GET` | `/api/pricelists/:id/items` | pricelist rows |
| `GET` | `/api/pricelists/:id/lots` | lot names in a pricelist |
| `GET` | `/api/partnumber-books` | local partnumber books |
| `GET` | `/api/partnumber-books/:id` | book items by `server_id` |
### Pricelists (read-only) ## Quote and export
| Method | Endpoint | Purpose | | Method | Path | Purpose |
|--------|----------|---------| | --- | --- | --- |
| GET | `/api/pricelists` | List pricelists (`source`, `active_only`, pagination) | | `POST` | `/api/quote/validate` | validate config items |
| GET | `/api/pricelists/latest` | Latest pricelist by source | | `POST` | `/api/quote/calculate` | calculate quote totals |
| GET | `/api/pricelists/:id` | Pricelist by ID | | `POST` | `/api/quote/price-levels` | resolve estimate/warehouse/competitor prices |
| GET | `/api/pricelists/:id/items` | Pricelist line items | | `POST` | `/api/export/csv` | export a single configuration |
| GET | `/api/pricelists/:id/lots` | Lot names in pricelist | | `GET` | `/api/configs/:uuid/export` | export a stored configuration |
| `GET` | `/api/projects/:uuid/export` | legacy project BOM export |
| `POST` | `/api/projects/:uuid/export` | pricing-tab project export |
`GET /api/pricelists?active_only=true` returns only pricelists that have synced items (`item_count > 0`). ## Configurations
### Configurations | Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/api/configs` | list configurations |
| `POST` | `/api/configs/import` | import configurations from server |
| `POST` | `/api/configs` | create configuration |
| `POST` | `/api/configs/preview-article` | preview article |
| `GET` | `/api/configs/:uuid` | get configuration |
| `PUT` | `/api/configs/:uuid` | update configuration |
| `DELETE` | `/api/configs/:uuid` | archive configuration |
| `POST` | `/api/configs/:uuid/reactivate` | reactivate configuration |
| `PATCH` | `/api/configs/:uuid/rename` | rename configuration |
| `POST` | `/api/configs/:uuid/clone` | clone configuration |
| `POST` | `/api/configs/:uuid/refresh-prices` | refresh prices |
| `PATCH` | `/api/configs/:uuid/project` | move configuration to project |
| `GET` | `/api/configs/:uuid/versions` | list revisions |
| `GET` | `/api/configs/:uuid/versions/:version` | get one revision |
| `POST` | `/api/configs/:uuid/rollback` | rollback by creating a new head revision |
| `PATCH` | `/api/configs/:uuid/server-count` | update server count |
| `GET` | `/api/configs/:uuid/vendor-spec` | read vendor BOM |
| `PUT` | `/api/configs/:uuid/vendor-spec` | replace vendor BOM |
| `POST` | `/api/configs/:uuid/vendor-spec/resolve` | resolve PN -> LOT |
| `POST` | `/api/configs/:uuid/vendor-spec/apply` | apply BOM to cart |
| Method | Endpoint | Purpose | ## Projects
|--------|----------|---------|
| GET | `/api/configs` | List configurations |
| POST | `/api/configs` | Create configuration |
| GET | `/api/configs/:uuid` | Get configuration |
| PUT | `/api/configs/:uuid` | Update configuration |
| DELETE | `/api/configs/:uuid` | Archive configuration |
| POST | `/api/configs/:uuid/refresh-prices` | Refresh prices from pricelist |
| POST | `/api/configs/:uuid/clone` | Clone configuration |
| POST | `/api/configs/:uuid/reactivate` | Restore archived configuration |
| POST | `/api/configs/:uuid/rename` | Rename configuration |
| POST | `/api/configs/preview-article` | Preview generated article for a configuration |
| POST | `/api/configs/:uuid/rollback` | Roll back to a version |
| 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. | Method | Path | Purpose |
| --- | --- | --- |
| `GET` | `/api/projects` | paginated project list |
| `GET` | `/api/projects/all` | lightweight list for dropdowns |
| `POST` | `/api/projects` | create project |
| `GET` | `/api/projects/:uuid` | get project |
| `PUT` | `/api/projects/:uuid` | update project |
| `POST` | `/api/projects/:uuid/archive` | archive project |
| `POST` | `/api/projects/:uuid/reactivate` | reactivate project |
| `DELETE` | `/api/projects/:uuid` | delete project variant only |
| `GET` | `/api/projects/:uuid/configs` | list project configurations |
| `PATCH` | `/api/projects/:uuid/configs/reorder` | persist line order |
| `POST` | `/api/projects/:uuid/configs` | create configuration inside project |
| `POST` | `/api/projects/:uuid/configs/:config_uuid/clone` | clone config into project |
| `POST` | `/api/projects/:uuid/vendor-import` | import CFXML workspace into project |
### Projects Vendor import contract:
- multipart field name is `file`;
- file limit is `1 GiB`;
- oversized payloads are rejected before XML parsing.
| Method | Endpoint | Purpose | ## Sync
|--------|----------|---------|
| GET | `/api/projects` | List projects |
| POST | `/api/projects` | Create project |
| GET | `/api/projects/:uuid` | Get project |
| 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: | Method | Path | Purpose |
`line ASC`, then `created_at DESC`, then `id DESC`. | --- | --- | --- |
| `GET` | `/api/sync/status` | sync status |
| `GET` | `/api/sync/readiness` | sync readiness |
| `GET` | `/api/sync/info` | sync modal data |
| `GET` | `/api/sync/users-status` | remote user status |
| `GET` | `/api/sync/pending/count` | pending queue count |
| `GET` | `/api/sync/pending` | pending queue rows |
| `POST` | `/api/sync/components` | pull components |
| `POST` | `/api/sync/pricelists` | pull pricelists |
| `POST` | `/api/sync/partnumber-books` | pull partnumber books |
| `POST` | `/api/sync/partnumber-seen` | report unresolved vendor PN |
| `POST` | `/api/sync/all` | push and pull full sync |
| `POST` | `/api/sync/push` | push pending changes |
| `POST` | `/api/sync/repair` | repair broken pending rows |
### Sync When readiness is blocked, sync write endpoints return `423 Locked`.
| Method | Endpoint | Purpose | Flow |
|--------|----------|---------|------|
| GET | `/api/sync/status` | Overall sync status | read-only |
| GET | `/api/sync/readiness` | Preflight status (ready/blocked/unknown) | read-only |
| GET | `/api/sync/info` | Data for sync modal | read-only |
| GET | `/api/sync/users-status` | Users status | read-only |
| GET | `/api/sync/pending` | List pending changes | read-only |
| GET | `/api/sync/pending/count` | Count of pending changes | read-only |
| POST | `/api/sync/push` | Push pending → MariaDB | SQLite → MariaDB |
| POST | `/api/sync/components` | Pull components | MariaDB → SQLite |
| 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 |
|--------|----------|---------|
| POST | `/api/export/csv` | Export configuration to CSV |
**Export filename format:** `YYYY-MM-DD (ProjectCode) ConfigName Article.csv`
(uses `project.Code`, not `project.Name`)
---
## Web Routes
| Route | Page |
|-------|------|
| `/configs` | Configuration list |
| `/configurator` | Configurator |
| `/configs/:uuid/revisions` | Configuration revision history |
| `/projects` | Project list |
| `/projects/:uuid` | Project details |
| `/pricelists` | Pricelist list |
| `/pricelists/:id` | Pricelist details |
| `/partnumber-books` | Partnumber books (active book summary + snapshot history) |
| `/setup` | Connection settings |
---
## Rollback API (details)
```bash
POST /api/configs/:uuid/rollback
Content-Type: application/json
{
"target_version": 3,
"note": "optional comment"
}
```
Response: updated configuration with the new version.
+52 -108
View File
@@ -1,129 +1,73 @@
# 05 Configuration and Environment # 05 - Config
## File Paths ## Runtime files
### SQLite database (`qfs.db`) | Artifact | Default location |
| --- | --- |
| `qfs.db` | OS-specific user state directory |
| `config.yaml` | same state directory as `qfs.db` |
| `local_encryption.key` | same state directory as `qfs.db` |
| `backups/` | next to `qfs.db` unless overridden |
| OS | Default path | The runtime state directory can be overridden with `QFS_STATE_DIR`.
|----|-------------| Direct paths can be overridden with `QFS_DB_PATH` and `QFS_CONFIG_PATH`.
| macOS | `~/Library/Application Support/QuoteForge/qfs.db` |
| Linux | `$XDG_STATE_HOME/quoteforge/qfs.db` or `~/.local/state/quoteforge/qfs.db` |
| Windows | `%LOCALAPPDATA%\QuoteForge\qfs.db` |
Override: `-localdb <path>` or `QFS_DB_PATH`. ## Runtime config shape
### config.yaml Runtime keeps `config.yaml` intentionally small:
Searched in the same user-state directory as `qfs.db` by default.
If the file does not exist, it is created automatically.
If the format is outdated, it is automatically migrated to the runtime format (`server` + `logging` sections only).
Override: `-config <path>` or `QFS_CONFIG_PATH`.
**Important:** `config.yaml` is a runtime user file — it is **not stored in the repository**.
`config.example.yaml` is the only config template in the repo.
---
## config.yaml Structure
```yaml ```yaml
server: server:
host: "0.0.0.0" host: "127.0.0.1"
port: 8080 port: 8080
mode: "release" # release | debug mode: "release"
read_timeout: 30s
logging: write_timeout: 30s
level: "info" # debug | info | warn | error
format: "json" # json | text
output: "stdout" # stdout | stderr | /path/to/file
backup: backup:
time: "00:00" # HH:MM in local time time: "00:00"
logging:
level: "info"
format: "json"
output: "stdout"
``` ```
--- Rules:
- QuoteForge creates this file automatically if it does not exist;
- startup rewrites legacy config files into this minimal runtime shape;
- `server.host` must stay on loopback.
## Environment Variables Saved MariaDB credentials do not live in `config.yaml`.
They are stored in SQLite and encrypted with `local_encryption.key` unless `QUOTEFORGE_ENCRYPTION_KEY` overrides the key material.
| Variable | Description | Default | ## Environment variables
|----------|-------------|---------|
| `QFS_DB_PATH` | Full path to SQLite DB | OS-specific user state dir |
| `QFS_STATE_DIR` | State directory (if `QFS_DB_PATH` is not set) | OS-specific user state dir |
| `QFS_CONFIG_PATH` | Full path to `config.yaml` | OS-specific user state dir |
| `QFS_BACKUP_DIR` | Root directory for rotating backups | `<db dir>/backups` |
| `QFS_BACKUP_DISABLE` | Disable automatic backups | — |
| `QF_DB_HOST` | MariaDB host | localhost |
| `QF_DB_PORT` | MariaDB port | 3306 |
| `QF_DB_NAME` | Database name | RFQ_LOG |
| `QF_DB_USER` | DB user | — |
| `QF_DB_PASSWORD` | DB password | — |
| `QF_JWT_SECRET` | JWT secret | — |
| `QF_SERVER_PORT` | HTTP server port | 8080 |
`QFS_BACKUP_DISABLE` accepts: `1`, `true`, `yes`. | Variable | Purpose |
| --- | --- |
| `QFS_STATE_DIR` | override runtime state directory |
| `QFS_DB_PATH` | explicit SQLite path |
| `QFS_CONFIG_PATH` | explicit config path |
| `QFS_BACKUP_DIR` | explicit backup root |
| `QFS_BACKUP_DISABLE` | disable rotating backups |
| `QUOTEFORGE_ENCRYPTION_KEY` | override encryption key |
| `QF_SERVER_PORT` | override HTTP port |
--- `QFS_BACKUP_DISABLE` accepts `1`, `true`, or `yes`.
## CLI Flags ## CLI flags
| Flag | Description | | Flag | Purpose |
|------|-------------| | --- | --- |
| `-config <path>` | Path to config.yaml | | `-config <path>` | config file path |
| `-localdb <path>` | Path to SQLite DB | | `-localdb <path>` | SQLite path |
| `-reset-localdb` | Reset local DB (destructive!) | | `-reset-localdb` | destructive local DB reset |
| `-migrate` | Apply pending migrations and exit | | `-migrate` | apply server migrations and exit |
| `-version` | Print version and exit | | `-version` | print app version and exit |
--- ## First run
## Installation and First Run 1. runtime ensures `config.yaml` exists;
2. runtime opens the local SQLite database;
### Requirements 3. if no stored MariaDB credentials exist, `/setup` is served;
- Go 1.22 or higher 4. after setup, runtime works locally and sync uses saved DB settings in the background.
- MariaDB 11.x (or MySQL 8.x)
- ~50 MB disk space
### Steps
```bash
# 1. Clone the repository
git clone <repo-url>
cd quoteforge
# 2. Apply migrations
go run ./cmd/qfs -migrate
# 3. Start
go run ./cmd/qfs
# or
make run
```
Application is available at: http://localhost:8080
On first run, `/setup` opens for configuring the MariaDB connection.
### OPS Project Migrator
Migrates quotes whose names start with `OPS-xxxx` (where `x` is a digit) into a project named `OPS-xxxx`.
```bash
# Preview first (always)
go run ./cmd/migrate_ops_projects
# Apply
go run ./cmd/migrate_ops_projects -apply
# Apply without interactive confirmation
go run ./cmd/migrate_ops_projects -apply -yes
```
---
## Docker
```bash
docker build -t quoteforge .
docker-compose up -d
```
+42 -208
View File
@@ -1,221 +1,55 @@
# 06 Backup # 06 - Backup
## Overview ## Scope
Automatic rotating ZIP backup system for local data. QuoteForge creates rotating local ZIP backups of:
- a consistent SQLite snapshot saved as `qfs.db`;
- `config.yaml` when present.
**What is included in each archive:** The backup intentionally does not include `local_encryption.key`.
- SQLite DB (`qfs.db`)
- SQLite sidecars (`qfs.db-wal`, `qfs.db-shm`) if present
- `config.yaml` if present
**Archive name format:** `qfs-backp-YYYY-MM-DD.zip` ## Location and naming
Default root:
- `<db dir>/backups`
Subdirectories:
- `daily/`
- `weekly/`
- `monthly/`
- `yearly/`
Archive name:
- `qfs-backp-YYYY-MM-DD.zip`
## Retention
**Retention policy:**
| Period | Keep | | Period | Keep |
|--------|------| | --- | --- |
| Daily | 7 archives | | Daily | 7 |
| Weekly | 4 archives | | Weekly | 4 |
| Monthly | 12 archives | | Monthly | 12 |
| Yearly | 10 archives | | Yearly | 10 |
**Directories:** `<backup root>/daily`, `/weekly`, `/monthly`, `/yearly`
---
## Configuration
```yaml
backup:
time: "00:00" # Trigger time in local time (HH:MM format)
```
**Environment variables:**
- `QFS_BACKUP_DIR` — backup root directory (default: `<db dir>/backups`)
- `QFS_BACKUP_DISABLE` — disable backups (`1/true/yes`)
---
## Behavior ## Behavior
- **At startup:** if no backup exists for the current period, one is created immediately - on startup, QuoteForge creates a backup if the current period has none yet;
- **Daily:** at the configured time, a new backup is created - a daily scheduler creates the next backup at `backup.time`;
- **Deduplication:** prevented via a `.period.json` marker file in each period directory - duplicate snapshots inside the same period are prevented by a period marker file;
- **Rotation:** excess old archives are deleted automatically - old archives are pruned automatically.
--- ## Safety rules
## Implementation - backup root must be outside the git worktree;
- backup creation is blocked if the resolved backup root sits inside the repository;
- SQLite snapshot must be created from a consistent database copy, not by copying live WAL files directly;
- restore to another machine requires re-entering DB credentials unless the encryption key is migrated separately.
Module: `internal/appstate/backup.go` ## Restore
Main function: 1. stop QuoteForge;
```go 2. unpack the chosen archive outside the repository;
func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error) 3. replace `qfs.db`;
``` 4. replace `config.yaml` if needed;
5. restart the app;
Scheduler (in `main.go`): 6. re-enter MariaDB credentials if the original encryption key is unavailable.
```go
func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string)
```
### Config struct
```go
type BackupConfig struct {
Time string `yaml:"time"`
}
// Default: "00:00"
```
---
## Implementation Notes
- `backup.time` is in **local time** without timezone offset parsing
- `.period.json` is the marker that prevents duplicate backups within the same period
- Archive filenames contain only the date; uniqueness is ensured by per-period directories + the period marker
- When changing naming or retention: update both the filename logic and the prune logic together
---
## Full Listing: `internal/appstate/backup.go`
```go
package appstate
import (
"archive/zip"
"encoding/json"
"fmt"
"io"
"os"
"path/filepath"
"sort"
"strings"
"time"
)
type backupPeriod struct {
name string
retention int
key func(time.Time) string
date func(time.Time) string
}
var backupPeriods = []backupPeriod{
{
name: "daily",
retention: 7,
key: func(t time.Time) string { return t.Format("2006-01-02") },
date: func(t time.Time) string { return t.Format("2006-01-02") },
},
{
name: "weekly",
retention: 4,
key: func(t time.Time) string {
y, w := t.ISOWeek()
return fmt.Sprintf("%04d-W%02d", y, w)
},
date: func(t time.Time) string { return t.Format("2006-01-02") },
},
{
name: "monthly",
retention: 12,
key: func(t time.Time) string { return t.Format("2006-01") },
date: func(t time.Time) string { return t.Format("2006-01-02") },
},
{
name: "yearly",
retention: 10,
key: func(t time.Time) string { return t.Format("2006") },
date: func(t time.Time) string { return t.Format("2006-01-02") },
},
}
func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error) {
if isBackupDisabled() || dbPath == "" {
return nil, nil
}
if _, err := os.Stat(dbPath); os.IsNotExist(err) {
return nil, nil
}
root := resolveBackupRoot(dbPath)
now := time.Now()
created := make([]string, 0)
for _, period := range backupPeriods {
newFiles, err := ensurePeriodBackup(root, period, now, dbPath, configPath)
if err != nil {
return created, err
}
created = append(created, newFiles...)
}
return created, nil
}
```
---
## Full Listing: Scheduler Hook (`main.go`)
```go
func startBackupScheduler(ctx context.Context, cfg *config.Config, dbPath, configPath string) {
if cfg == nil {
return
}
hour, minute, err := parseBackupTime(cfg.Backup.Time)
if err != nil {
slog.Warn("invalid backup time; using 00:00", "value", cfg.Backup.Time, "error", err)
hour, minute = 0, 0
}
// Startup check: create backup immediately if none exists for current periods
if created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath); backupErr != nil {
slog.Error("local backup failed", "error", backupErr)
} else {
for _, path := range created {
slog.Info("local backup completed", "archive", path)
}
}
for {
next := nextBackupTime(time.Now(), hour, minute)
timer := time.NewTimer(time.Until(next))
select {
case <-ctx.Done():
timer.Stop()
return
case <-timer.C:
start := time.Now()
created, backupErr := appstate.EnsureRotatingLocalBackup(dbPath, configPath)
duration := time.Since(start)
if backupErr != nil {
slog.Error("local backup failed", "error", backupErr, "duration", duration)
} else {
for _, path := range created {
slog.Info("local backup completed", "archive", path, "duration", duration)
}
}
}
}
}
func parseBackupTime(value string) (int, int, error) {
if strings.TrimSpace(value) == "" {
return 0, 0, fmt.Errorf("empty backup time")
}
parsed, err := time.Parse("15:04", value)
if err != nil {
return 0, 0, err
}
return parsed.Hour(), parsed.Minute(), nil
}
func nextBackupTime(now time.Time, hour, minute int) time.Time {
target := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location())
if !now.Before(target) {
target = target.Add(24 * time.Hour)
}
return target
}
```
+21 -123
View File
@@ -1,136 +1,34 @@
# 07 Development # 07 - Development
## Commands ## Common commands
```bash ```bash
# Run (dev)
go run ./cmd/qfs go run ./cmd/qfs
make run go run ./cmd/qfs -migrate
# Build
make build-release # Optimized build with version info
CGO_ENABLED=0 go build -o bin/qfs ./cmd/qfs
# Cross-platform build
make build-all # Linux, macOS, Windows
make build-windows # Windows only
# Verification
go build ./cmd/qfs # Must compile without errors
go vet ./... # Linter
# Tests
go test ./... go test ./...
make test go vet ./...
make build-release
# Utilities make install-hooks
make install-hooks # Git hooks (block committing secrets)
make clean # Clean bin/
make help # All available commands
``` ```
---
## Code Style
- **Formatting:** `gofmt` (mandatory)
- **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
---
## Guardrails ## Guardrails
### What Must Never Be Restored - run `gofmt` before commit;
- use `slog` for server logging;
- keep runtime business logic SQLite-only;
- limit MariaDB access to sync, setup, and migration tooling;
- keep `config.yaml` out of git and use `config.example.yaml` only as a template;
- update `bible-local/` in the same commit as architecture changes.
The following components were **intentionally removed** and must not be brought back: ## Removed features that must not return
- cron jobs
- importer utility
- admin pricing UI/API
- alerts
- stock import
### Configuration Files - admin pricing UI/API;
- alerts and notification workflows;
- stock import tooling;
- cron jobs;
- standalone importer utility.
- `config.yaml` — runtime user file, **not stored in the repository** ## Release notes
- `config.example.yaml` — the only config template in the repo
### Sync and Local-First Release history belongs under `releases/<version>/RELEASE_NOTES.md`.
Do not keep temporary change summaries in the repository root.
- Any sync changes must preserve local-first behavior
- Local CRUD must not be blocked when MariaDB is unavailable
### Formats and UI
- **CSV export:** filename must use **project code** (`project.Code`), not project name
Format: `YYYY-MM-DD (ProjectCode) ConfigName Article.csv`
- **Breadcrumbs UI:** names longer than 16 characters must be truncated with an ellipsis
### Architecture Documentation
- **Every architectural decision must be recorded in `bible/`**
- The corresponding Bible file must be updated **in the same commit** as the code change
- On every user-requested commit, review and update the Bible in that same commit
---
## Common Tasks
### Add a Field to Configuration
1. Add the field to `LocalConfiguration` struct (`internal/models/`)
2. Add GORM tags for the DB column
3. Write a SQL migration (`migrations/`)
4. Update `ConfigurationToLocal` / `LocalToConfiguration` converters
5. Update API handlers and services
### Add a Field to Component
1. Add the field to `LocalComponent` struct (`internal/models/`)
2. Update the SQL query in `SyncComponents()`
3. Update the `componentRow` struct to match
4. Update converter functions
### Add a Pricelist Price Lookup
```go
// Modern pattern
price, found := s.lookupPriceByPricelistID(pricelistID, lotName)
if found && price > 0 {
// use price
}
```
---
## Known Gotchas
1. **`CurrentPrice` removed from components** — any code using it will fail to compile
2. **`HasPrice` filter removed** — `component.go ListComponents` no longer supports this filter
3. **Quote calculation:** always offline-first (SQLite); online path is separate
4. **Items JSON:** prices are stored in the `items` field of the configuration, not fetched from components
5. **Migrations are additive:** already-applied migrations are skipped (checked by `id` in `local_schema_migrations`)
6. **`SyncedAt` removed:** last component sync time is now in `app_settings` (key=`last_component_sync`)
---
## Debugging Price Issues
**Problem: quote returns no prices**
1. Check that `pricelist_id` is set on the configuration
2. Check that pricelist items exist: `SELECT COUNT(*) FROM local_pricelist_items`
3. Check `lookupPriceByPricelistID()` in `quote.go`
4. Verify the correct source is used (estimate/warehouse/competitor)
**Problem: component sync not working**
1. Components sync as metadata only — no prices
2. Prices come via a separate pricelist sync
3. Check `SyncComponents()` and the MariaDB query
**Problem: configuration refresh does not update prices**
1. Refresh uses the latest estimate pricelist by default
2. Latest resolution ignores pricelists without items (`EXISTS local_pricelist_items`)
3. Old prices in `config.items` are preserved if a line item is not found in the pricelist
4. To force a pricelist update: set `configuration.pricelist_id`
5. In configurator, `Авто` must remain auto-mode (runtime resolved ID must not be persisted as explicit selection)
+43 -343
View File
@@ -1,364 +1,64 @@
# 09 Vendor Spec (BOM Import) # 09 - Vendor BOM
## Overview ## Storage contract
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). Vendor BOM is stored in `local_configurations.vendor_spec` and synced with `qt_configurations.vendor_spec`.
--- Each row uses this canonical shape:
## 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 ```json
{ {
"vendor_partnumber": "SYS-821GE-TNHR", "sort_order": 10,
"quantity": 3, "vendor_partnumber": "ABC-123",
"quantity": 2,
"description": "row description",
"unit_price": 4500.0,
"total_price": 9000.0,
"lot_mappings": [ "lot_mappings": [
{ "lot_name": "CHASSIS_X13_8GPU", "quantity_per_pn": 1 }, { "lot_name": "LOT_A", "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: Rules:
- `lot_mappings[]` is the only persisted PN -> LOT mapping contract;
- QuoteForge does not use legacy BOM tables;
- apply flow rebuilds cart rows from `lot_mappings[]`.
- `CHASSIS_X13_8GPU``3 * 1 = 3` ## Partnumber books
- `PS_3000W_Titanium``3 * 2 = 6`
- `RAILKIT_X13``3 * 1 = 3`
--- Partnumber books are pull-only snapshots from PriceForge.
## Partnumber Books (Snapshots) Local tables:
- `local_partnumber_books`
- `local_partnumber_book_items`
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. Server tables:
- `qt_partnumber_books`
- `qt_partnumber_book_items`
### SQLite (local mirror) Resolution flow:
1. load the active local book;
2. find `vendor_partnumber`;
3. copy `lots_json` into `lot_mappings[]`;
4. keep unresolved rows editable in the UI.
```sql ## CFXML import
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 ( `POST /api/projects/:uuid/vendor-import` imports one vendor workspace into an existing project.
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` Rules:
- accepted file field is `file`;
- maximum file size is `1 GiB`;
- one `ProprietaryGroupIdentifier` becomes one QuoteForge configuration;
- software rows stay inside their hardware group and never become standalone configurations;
- primary group row is selected structurally, without vendor-specific SKU hardcoding;
- imported configuration order follows workspace order.
**Schema creation:** GORM AutoMigrate (not `runLocalMigrations`). Imported configuration fields:
- `name` from primary row `ProductName`
- `server_count` from primary row `Quantity`
- `server_model` from primary row `ProductDescription`
- `article` or `support_code` from `ProprietaryProductIdentifier`
### MariaDB (managed exclusively by PriceForge) Imported BOM rows become `vendor_spec` rows and are resolved through the active local partnumber book when possible.
```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 |
+23 -48
View File
@@ -1,55 +1,30 @@
# QuoteForge Bible — Architectural Documentation # QuoteForge Bible
The single source of truth for architecture, schemas, and patterns. Project-specific architecture and operational contracts.
--- ## Files
## Table of Contents | File | Scope |
| --- | --- |
| [01-overview.md](01-overview.md) | Product scope, runtime model, repository map |
| [02-architecture.md](02-architecture.md) | Local-first rules, sync, pricing, versioning |
| [03-database.md](03-database.md) | SQLite and MariaDB data model, permissions, migrations |
| [04-api.md](04-api.md) | HTTP routes and API contract |
| [05-config.md](05-config.md) | Runtime config, paths, env vars, startup behavior |
| [06-backup.md](06-backup.md) | Backup contract and restore workflow |
| [07-dev.md](07-dev.md) | Development commands and guardrails |
| [09-vendor-spec.md](09-vendor-spec.md) | Vendor BOM and CFXML import contract |
| File | Topic | ## Rules
|------|-------|
| [01-overview.md](01-overview.md) | Product: purpose, features, tech stack, repository structure |
| [02-architecture.md](02-architecture.md) | Architecture: local-first, sync, pricing, versioning |
| [03-database.md](03-database.md) | DB schemas: SQLite + MariaDB, permissions, indexes |
| [04-api.md](04-api.md) | API endpoints and web routes |
| [05-config.md](05-config.md) | Configuration, environment variables, paths, installation |
| [06-backup.md](06-backup.md) | Backup: implementation, rotation policy |
| [07-dev.md](07-dev.md) | Development: commands, code style, guardrails |
--- - `bible-local/` is the source of truth for QuoteForge-specific behavior.
- Keep these files in English.
- Update the matching file in the same commit as any architectural change.
- Remove stale documentation instead of preserving history in place.
## Bible Rules ## Quick reference
> **Every architectural decision must be recorded in the Bible.** - Local DB path: see [05-config.md](05-config.md)
> - Runtime bind: loopback only
> Any change to DB schema, data access patterns, sync behavior, API contracts, - Local backups: see [06-backup.md](06-backup.md)
> configuration format, or any other system-level aspect — the corresponding `bible/` file - Release notes: `releases/<version>/RELEASE_NOTES.md`
> **must be updated in the same commit** as the code.
>
> On every user-requested commit, the Bible must be reviewed and updated in that commit.
>
> The Bible is the single source of truth for architecture. Outdated documentation is worse than none.
> **Documentation language: English.**
>
> All files in `bible/` are written and updated **in English only**.
> Mixing languages is not allowed.
---
## Quick Reference
**Where is user data stored?**
SQLite → `~/Library/Application Support/QuoteForge/qfs.db` (macOS). MariaDB is sync-only.
**How to look up a price for a line item?**
`local_pricelist_items` → by `pricelist_id` from config + `lot_name`. Prices are **never** taken from `local_components`.
**Pre-commit check?**
`go build ./cmd/qfs && go vet ./...`
**What must never be restored?**
cron jobs, admin pricing, alerts, stock import, importer utility — all removed intentionally.
**Where is the release changelog?**
`releases/memory/v{major}.{minor}.{patch}.md`
+25
View File
@@ -64,3 +64,28 @@ logging:
t.Fatalf("migrated config did not preserve logging level:\n%s", text) t.Fatalf("migrated config did not preserve logging level:\n%s", text)
} }
} }
func TestEnsureLoopbackServerHost(t *testing.T) {
t.Parallel()
cases := []struct {
host string
wantErr bool
}{
{host: "127.0.0.1", wantErr: false},
{host: "localhost", wantErr: false},
{host: "::1", wantErr: false},
{host: "0.0.0.0", wantErr: true},
{host: "192.168.1.10", wantErr: true},
}
for _, tc := range cases {
err := ensureLoopbackServerHost(tc.host)
if tc.wantErr && err == nil {
t.Fatalf("expected error for host %q", tc.host)
}
if !tc.wantErr && err != nil {
t.Fatalf("unexpected error for host %q: %v", tc.host, err)
}
}
}
+184 -227
View File
@@ -6,9 +6,11 @@ import (
"errors" "errors"
"flag" "flag"
"fmt" "fmt"
"io"
"io/fs" "io/fs"
"log/slog" "log/slog"
"math" "math"
"net"
"net/http" "net/http"
"os" "os"
"os/exec" "os/exec"
@@ -31,7 +33,6 @@ import (
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/middleware" "git.mchus.pro/mchus/quoteforge/internal/middleware"
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services" "git.mchus.pro/mchus/quoteforge/internal/services"
"git.mchus.pro/mchus/quoteforge/internal/services/sync" "git.mchus.pro/mchus/quoteforge/internal/services/sync"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -43,11 +44,16 @@ import (
// Version is set via ldflags during build // Version is set via ldflags during build
var Version = "dev" var Version = "dev"
var errVendorImportTooLarge = errors.New("vendor workspace file exceeds 1 GiB limit")
const backgroundSyncInterval = 5 * time.Minute const backgroundSyncInterval = 5 * time.Minute
const onDemandPullCooldown = 30 * time.Second const onDemandPullCooldown = 30 * time.Second
const startupConsoleWarning = "Не закрывайте консоль иначе приложение не будет работать" const startupConsoleWarning = "Не закрывайте консоль иначе приложение не будет работать"
var vendorImportMaxBytes int64 = 1 << 30
const vendorImportMultipartOverheadBytes int64 = 8 << 20
func main() { func main() {
showStartupConsoleWarning() showStartupConsoleWarning()
@@ -142,6 +148,10 @@ func main() {
} }
} }
setConfigDefaults(cfg) setConfigDefaults(cfg)
if err := ensureLoopbackServerHost(cfg.Server.Host); err != nil {
slog.Error("invalid server host", "host", cfg.Server.Host, "error", err)
os.Exit(1)
}
if err := migrateConfigFileToRuntimeShape(resolvedConfigPath, cfg); err != nil { if err := migrateConfigFileToRuntimeShape(resolvedConfigPath, cfg); err != nil {
slog.Error("failed to migrate config file format", "path", resolvedConfigPath, "error", err) slog.Error("failed to migrate config file format", "path", resolvedConfigPath, "error", err)
os.Exit(1) os.Exit(1)
@@ -150,29 +160,25 @@ func main() {
setupLogger(cfg.Logging) setupLogger(cfg.Logging)
// Create connection manager and try to connect immediately if settings exist // Create connection manager. Runtime stays local-first; MariaDB is used on demand by sync/setup only.
connMgr := db.NewConnectionManager(local) connMgr := db.NewConnectionManager(local)
dbUser := local.GetDBUser() dbUser := local.GetDBUser()
// Try to connect to MariaDB on startup
mariaDB, err := connMgr.GetDB()
if err != nil {
slog.Warn("failed to connect to MariaDB on startup, starting in offline mode", "error", err)
mariaDB = nil
} else {
slog.Info("successfully connected to MariaDB on startup")
}
slog.Info("starting QuoteForge server", slog.Info("starting QuoteForge server",
"version", Version, "version", Version,
"host", cfg.Server.Host, "host", cfg.Server.Host,
"port", cfg.Server.Port, "port", cfg.Server.Port,
"db_user", dbUser, "db_user", dbUser,
"online", mariaDB != nil, "online", false,
) )
if *migrate { if *migrate {
mariaDB, err := connMgr.GetDB()
if err != nil {
slog.Error("cannot run migrations: database not available", "error", err)
os.Exit(1)
}
if mariaDB == nil { if mariaDB == nil {
slog.Error("cannot run migrations: database not available") slog.Error("cannot run migrations: database not available")
os.Exit(1) os.Exit(1)
@@ -189,39 +195,10 @@ func main() {
slog.Info("migrations completed") slog.Info("migrations completed")
} }
// Always apply SQL migrations on startup when database is available.
// This keeps schema in sync for long-running installations without manual steps.
// If current DB user does not have enough privileges, continue startup in normal mode.
if mariaDB != nil {
sqlMigrationsPath := filepath.Join("migrations")
needsMigrations, err := models.NeedsSQLMigrations(mariaDB, sqlMigrationsPath)
if err != nil {
if models.IsMigrationPermissionError(err) {
slog.Info("startup SQL migrations skipped: insufficient database privileges", "path", sqlMigrationsPath, "error", err)
} else {
slog.Error("startup SQL migrations check failed", "path", sqlMigrationsPath, "error", err)
os.Exit(1)
}
} else if needsMigrations {
if err := models.RunSQLMigrations(mariaDB, sqlMigrationsPath); err != nil {
if models.IsMigrationPermissionError(err) {
slog.Info("startup SQL migrations skipped: insufficient database privileges", "path", sqlMigrationsPath, "error", err)
} else {
slog.Error("startup SQL migrations failed", "path", sqlMigrationsPath, "error", err)
os.Exit(1)
}
} else {
slog.Info("startup SQL migrations applied", "path", sqlMigrationsPath)
}
} else {
slog.Debug("startup SQL migrations not needed", "path", sqlMigrationsPath)
}
}
gin.SetMode(cfg.Server.Mode) gin.SetMode(cfg.Server.Mode)
restartSig := make(chan struct{}, 1) restartSig := make(chan struct{}, 1)
router, syncService, err := setupRouter(cfg, local, connMgr, mariaDB, dbUser, restartSig) router, syncService, err := setupRouter(cfg, local, connMgr, dbUser, restartSig)
if err != nil { if err != nil {
slog.Error("failed to setup router", "error", err) slog.Error("failed to setup router", "error", err)
os.Exit(1) os.Exit(1)
@@ -352,29 +329,40 @@ func setConfigDefaults(cfg *config.Config) {
if cfg.Server.WriteTimeout == 0 { if cfg.Server.WriteTimeout == 0 {
cfg.Server.WriteTimeout = 30 * time.Second cfg.Server.WriteTimeout = 30 * time.Second
} }
if cfg.Pricing.DefaultMethod == "" {
cfg.Pricing.DefaultMethod = "weighted_median"
}
if cfg.Pricing.DefaultPeriodDays == 0 {
cfg.Pricing.DefaultPeriodDays = 90
}
if cfg.Pricing.FreshnessGreenDays == 0 {
cfg.Pricing.FreshnessGreenDays = 30
}
if cfg.Pricing.FreshnessYellowDays == 0 {
cfg.Pricing.FreshnessYellowDays = 60
}
if cfg.Pricing.FreshnessRedDays == 0 {
cfg.Pricing.FreshnessRedDays = 90
}
if cfg.Pricing.MinQuotesForMedian == 0 {
cfg.Pricing.MinQuotesForMedian = 3
}
if cfg.Backup.Time == "" { if cfg.Backup.Time == "" {
cfg.Backup.Time = "00:00" cfg.Backup.Time = "00:00"
} }
} }
func ensureLoopbackServerHost(host string) error {
trimmed := strings.TrimSpace(host)
if trimmed == "" {
return fmt.Errorf("server.host must not be empty")
}
if strings.EqualFold(trimmed, "localhost") {
return nil
}
ip := net.ParseIP(strings.Trim(trimmed, "[]"))
if ip != nil && ip.IsLoopback() {
return nil
}
return fmt.Errorf("QuoteForge local client must bind to localhost only")
}
func vendorImportBodyLimit() int64 {
return vendorImportMaxBytes + vendorImportMultipartOverheadBytes
}
func isVendorImportTooLarge(fileSize int64, err error) bool {
if fileSize > vendorImportMaxBytes {
return true
}
var maxBytesErr *http.MaxBytesError
return errors.As(err, &maxBytesErr)
}
func ensureDefaultConfigFile(configPath string) error { func ensureDefaultConfigFile(configPath string) error {
if strings.TrimSpace(configPath) == "" { if strings.TrimSpace(configPath) == "" {
return fmt.Errorf("config path is empty") return fmt.Errorf("config path is empty")
@@ -671,46 +659,14 @@ func setupDatabaseFromDSN(dsn string) (*gorm.DB, error) {
return db, nil return db, nil
} }
func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.ConnectionManager, mariaDB *gorm.DB, dbUsername string, restartSig chan struct{}) (*gin.Engine, *sync.Service, error) { func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.ConnectionManager, dbUsername string, restartSig chan struct{}) (*gin.Engine, *sync.Service, error) {
// mariaDB may be nil if we're in offline mode
// Repositories
var componentRepo *repository.ComponentRepository
var categoryRepo *repository.CategoryRepository
var statsRepo *repository.StatsRepository
var pricelistRepo *repository.PricelistRepository
// Only initialize repositories if we have a database connection
if mariaDB != nil {
componentRepo = repository.NewComponentRepository(mariaDB)
categoryRepo = repository.NewCategoryRepository(mariaDB)
statsRepo = repository.NewStatsRepository(mariaDB)
pricelistRepo = repository.NewPricelistRepository(mariaDB)
} else {
// In offline mode, we'll use nil repositories or handle them differently
// This is handled in the sync service and other components
}
// Services
var componentService *services.ComponentService
var quoteService *services.QuoteService
var exportService *services.ExportService
var syncService *sync.Service var syncService *sync.Service
var projectService *services.ProjectService var projectService *services.ProjectService
// Sync service always uses ConnectionManager (works offline and online)
syncService = sync.NewService(connMgr, local) syncService = sync.NewService(connMgr, local)
componentService := services.NewComponentService(nil, nil, nil)
if mariaDB != nil { quoteService := services.NewQuoteService(nil, nil, nil, local, nil)
componentService = services.NewComponentService(componentRepo, categoryRepo, statsRepo) exportService := services.NewExportService(cfg.Export, nil, local)
quoteService = services.NewQuoteService(componentRepo, statsRepo, pricelistRepo, local, nil)
exportService = services.NewExportService(cfg.Export, categoryRepo, local)
} else {
// In offline mode, we still need to create services that don't require DB.
componentService = services.NewComponentService(nil, nil, nil)
quoteService = services.NewQuoteService(nil, nil, nil, local, nil)
exportService = services.NewExportService(cfg.Export, nil, local)
}
// isOnline function for local-first architecture // isOnline function for local-first architecture
isOnline := func() bool { isOnline := func() bool {
@@ -731,16 +687,6 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err := local.BackfillConfigurationProjects(dbUsername); err != nil { if err := local.BackfillConfigurationProjects(dbUsername); err != nil {
slog.Warn("failed to backfill local configuration projects", "error", err) slog.Warn("failed to backfill local configuration projects", "error", err)
} }
if mariaDB != nil {
serverProjectRepo := repository.NewProjectRepository(mariaDB)
if removed, err := serverProjectRepo.PurgeEmptyNamelessProjects(); err == nil && removed > 0 {
slog.Info("purged empty nameless server projects", "removed", removed)
}
if err := serverProjectRepo.EnsureSystemProjectsAndBackfillConfigurations(); err != nil {
slog.Warn("failed to backfill server configuration projects", "error", err)
}
}
type pullState struct { type pullState struct {
mu syncpkg.Mutex mu syncpkg.Mutex
running bool running bool
@@ -818,10 +764,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
// Handlers // Handlers
componentHandler := handlers.NewComponentHandler(componentService, local) componentHandler := handlers.NewComponentHandler(componentService, local)
quoteHandler := handlers.NewQuoteHandler(quoteService) quoteHandler := handlers.NewQuoteHandler(quoteService)
exportHandler := handlers.NewExportHandler(exportService, configService, projectService) exportHandler := handlers.NewExportHandler(exportService, configService, projectService, dbUsername)
pricelistHandler := handlers.NewPricelistHandler(local) pricelistHandler := handlers.NewPricelistHandler(local)
vendorSpecHandler := handlers.NewVendorSpecHandler(local) vendorSpecHandler := handlers.NewVendorSpecHandler(local)
partnumberBooksHandler := handlers.NewPartnumberBooksHandler(local) partnumberBooksHandler := handlers.NewPartnumberBooksHandler(local)
respondError := handlers.RespondError
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval) syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
if err != nil { if err != nil {
return nil, nil, fmt.Errorf("creating sync handler: %w", err) return nil, nil, fmt.Errorf("creating sync handler: %w", err)
@@ -834,13 +781,14 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
} }
// Web handler (templates) // Web handler (templates)
webHandler, err := handlers.NewWebHandler(templatesPath, componentService) webHandler, err := handlers.NewWebHandler(templatesPath, local)
if err != nil { if err != nil {
return nil, nil, err return nil, nil, err
} }
// Router // Router
router := gin.New() router := gin.New()
router.MaxMultipartMemory = vendorImportBodyLimit()
router.Use(gin.Recovery()) router.Use(gin.Recovery())
router.Use(requestLogger()) router.Use(requestLogger())
router.Use(middleware.CORS()) router.Use(middleware.CORS())
@@ -861,17 +809,17 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
}) })
}) })
// Restart endpoint (for development purposes) // Restart endpoint is intentionally debug-only.
router.POST("/api/restart", func(c *gin.Context) { if cfg.Server.Mode == "debug" {
// This will cause the server to restart by exiting router.POST("/api/restart", func(c *gin.Context) {
// The restartProcess function will be called to restart the process slog.Info("Restart requested via API")
slog.Info("Restart requested via API") go func() {
go func() { time.Sleep(100 * time.Millisecond)
time.Sleep(100 * time.Millisecond) restartProcess()
restartProcess() }()
}() c.JSON(http.StatusOK, gin.H{"message": "restarting..."})
c.JSON(http.StatusOK, gin.H{"message": "restarting..."}) })
}) }
// DB status endpoint // DB status endpoint
router.GET("/api/db-status", func(c *gin.Context) { router.GET("/api/db-status", func(c *gin.Context) {
@@ -890,20 +838,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
} }
} }
// Optional diagnostics mode with server table counts. // Runtime diagnostics stay local-only. Server table counts are intentionally unavailable here.
if includeCounts && status.IsConnected { if !includeCounts || !status.IsConnected {
if db, err := connMgr.GetDB(); err == nil && db != nil {
_ = db.Table("lot").Count(&lotCount)
_ = db.Table("lot_log").Count(&lotLogCount)
_ = db.Table("qt_lot_metadata").Count(&metadataCount)
} else if err != nil {
dbOK = false
dbError = err.Error()
} else {
dbOK = false
dbError = "Database not connected (offline mode)"
}
} else {
lotCount = 0 lotCount = 0
lotLogCount = 0 lotLogCount = 0
metadataCount = 0 metadataCount = 0
@@ -919,11 +855,10 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
}) })
}) })
// Current user info (DB user, not app user) // Current user info (local DB username)
router.GET("/api/current-user", func(c *gin.Context) { router.GET("/api/current-user", func(c *gin.Context) {
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"username": local.GetDBUser(), "username": local.GetDBUser(),
"role": "db_user",
}) })
}) })
@@ -1016,7 +951,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
cfgs, total, err := configService.ListAllWithStatus(page, perPage, status, search) cfgs, total, err := configService.ListAllWithStatus(page, perPage, status, search)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
@@ -1037,7 +972,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database is offline"}) c.JSON(http.StatusServiceUnavailable, gin.H{"error": "Database is offline"})
return return
} }
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
c.JSON(http.StatusOK, result) c.JSON(http.StatusOK, result)
@@ -1046,13 +981,13 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
configs.POST("", func(c *gin.Context) { configs.POST("", func(c *gin.Context) {
var req services.CreateConfigRequest var req services.CreateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) respondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
config, err := configService.Create(dbUsername, &req) config, err := configService.Create(dbUsername, &req)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
@@ -1062,12 +997,12 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
configs.POST("/preview-article", func(c *gin.Context) { configs.POST("/preview-article", func(c *gin.Context) {
var req services.ArticlePreviewRequest var req services.ArticlePreviewRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) respondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
result, err := configService.BuildArticlePreview(&req) result, err := configService.BuildArticlePreview(&req)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) respondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -1090,7 +1025,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
uuid := c.Param("uuid") uuid := c.Param("uuid")
var req services.CreateConfigRequest var req services.CreateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) respondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
@@ -1098,13 +1033,13 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, services.ErrConfigNotFound): case errors.Is(err, services.ErrConfigNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) respondError(c, http.StatusNotFound, "resource not found", err)
case errors.Is(err, services.ErrProjectNotFound): case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) respondError(c, http.StatusNotFound, "resource not found", err)
case errors.Is(err, services.ErrProjectForbidden): case errors.Is(err, services.ErrProjectForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) respondError(c, http.StatusForbidden, "access denied", err)
default: default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
} }
return return
} }
@@ -1115,7 +1050,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
configs.DELETE("/:uuid", func(c *gin.Context) { configs.DELETE("/:uuid", func(c *gin.Context) {
uuid := c.Param("uuid") uuid := c.Param("uuid")
if err := configService.DeleteNoAuth(uuid); err != nil { if err := configService.DeleteNoAuth(uuid); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
c.JSON(http.StatusOK, gin.H{"message": "archived"}) c.JSON(http.StatusOK, gin.H{"message": "archived"})
@@ -1125,7 +1060,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
uuid := c.Param("uuid") uuid := c.Param("uuid")
config, err := configService.ReactivateNoAuth(uuid) config, err := configService.ReactivateNoAuth(uuid)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
@@ -1140,13 +1075,13 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
Name string `json:"name"` Name string `json:"name"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) respondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
config, err := configService.RenameNoAuth(uuid, req.Name) config, err := configService.RenameNoAuth(uuid, req.Name)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
@@ -1160,7 +1095,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
FromVersion int `json:"from_version"` FromVersion int `json:"from_version"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) respondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
@@ -1170,7 +1105,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
return return
} }
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
@@ -1181,7 +1116,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
uuid := c.Param("uuid") uuid := c.Param("uuid")
config, err := configService.RefreshPricesNoAuth(uuid) config, err := configService.RefreshPricesNoAuth(uuid)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
c.JSON(http.StatusOK, config) c.JSON(http.StatusOK, config)
@@ -1193,20 +1128,20 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
ProjectUUID string `json:"project_uuid"` ProjectUUID string `json:"project_uuid"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) respondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
updated, err := configService.SetProjectNoAuth(uuid, req.ProjectUUID) updated, err := configService.SetProjectNoAuth(uuid, req.ProjectUUID)
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, services.ErrConfigNotFound): case errors.Is(err, services.ErrConfigNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) respondError(c, http.StatusNotFound, "resource not found", err)
case errors.Is(err, services.ErrProjectNotFound): case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) respondError(c, http.StatusNotFound, "resource not found", err)
case errors.Is(err, services.ErrProjectForbidden): case errors.Is(err, services.ErrProjectForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) respondError(c, http.StatusForbidden, "access denied", err)
default: default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
} }
return return
} }
@@ -1235,7 +1170,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
case errors.Is(err, services.ErrInvalidVersionNumber): case errors.Is(err, services.ErrInvalidVersionNumber):
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid paging params"}) c.JSON(http.StatusBadRequest, gin.H{"error": "invalid paging params"})
default: default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
} }
return return
} }
@@ -1263,7 +1198,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
case errors.Is(err, services.ErrConfigVersionNotFound): case errors.Is(err, services.ErrConfigVersionNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": "version not found"}) c.JSON(http.StatusNotFound, gin.H{"error": "version not found"})
default: default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
} }
return return
} }
@@ -1278,7 +1213,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
Note string `json:"note"` Note string `json:"note"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) respondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
if req.TargetVersion <= 0 { if req.TargetVersion <= 0 {
@@ -1296,7 +1231,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
case errors.Is(err, services.ErrVersionConflict): case errors.Is(err, services.ErrVersionConflict):
c.JSON(http.StatusConflict, gin.H{"error": "version conflict"}) c.JSON(http.StatusConflict, gin.H{"error": "version conflict"})
default: default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
} }
return return
} }
@@ -1331,12 +1266,12 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
ServerCount int `json:"server_count" binding:"required,min=1"` ServerCount int `json:"server_count" binding:"required,min=1"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) respondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
config, err := configService.UpdateServerCount(uuid, req.ServerCount) config, err := configService.UpdateServerCount(uuid, req.ServerCount)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
c.JSON(http.StatusOK, config) c.JSON(http.StatusOK, config)
@@ -1381,7 +1316,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
allProjects, err := projectService.ListByUser(dbUsername, true) allProjects, err := projectService.ListByUser(dbUsername, true)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
@@ -1515,7 +1450,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
projects.GET("/all", func(c *gin.Context) { projects.GET("/all", func(c *gin.Context) {
allProjects, err := projectService.ListByUser(dbUsername, true) allProjects, err := projectService.ListByUser(dbUsername, true)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
@@ -1545,7 +1480,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
projects.POST("", func(c *gin.Context) { projects.POST("", func(c *gin.Context) {
var req services.CreateProjectRequest var req services.CreateProjectRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) respondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
if strings.TrimSpace(req.Code) == "" { if strings.TrimSpace(req.Code) == "" {
@@ -1556,9 +1491,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, services.ErrProjectCodeExists): case errors.Is(err, services.ErrProjectCodeExists):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) respondError(c, http.StatusConflict, "conflict detected", err)
default: default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
} }
return return
} }
@@ -1570,11 +1505,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, services.ErrProjectNotFound): case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) respondError(c, http.StatusNotFound, "resource not found", err)
case errors.Is(err, services.ErrProjectForbidden): case errors.Is(err, services.ErrProjectForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) respondError(c, http.StatusForbidden, "access denied", err)
default: default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
} }
return return
} }
@@ -1584,20 +1519,20 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
projects.PUT("/:uuid", func(c *gin.Context) { projects.PUT("/:uuid", func(c *gin.Context) {
var req services.UpdateProjectRequest var req services.UpdateProjectRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) respondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
project, err := projectService.Update(c.Param("uuid"), dbUsername, &req) project, err := projectService.Update(c.Param("uuid"), dbUsername, &req)
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, services.ErrProjectCodeExists): case errors.Is(err, services.ErrProjectCodeExists):
c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) respondError(c, http.StatusConflict, "conflict detected", err)
case errors.Is(err, services.ErrProjectNotFound): case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) respondError(c, http.StatusNotFound, "resource not found", err)
case errors.Is(err, services.ErrProjectForbidden): case errors.Is(err, services.ErrProjectForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) respondError(c, http.StatusForbidden, "access denied", err)
default: default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
} }
return return
} }
@@ -1608,11 +1543,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err := projectService.Archive(c.Param("uuid"), dbUsername); err != nil { if err := projectService.Archive(c.Param("uuid"), dbUsername); err != nil {
switch { switch {
case errors.Is(err, services.ErrProjectNotFound): case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) respondError(c, http.StatusNotFound, "resource not found", err)
case errors.Is(err, services.ErrProjectForbidden): case errors.Is(err, services.ErrProjectForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) respondError(c, http.StatusForbidden, "access denied", err)
default: default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
} }
return return
} }
@@ -1623,11 +1558,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err := projectService.Reactivate(c.Param("uuid"), dbUsername); err != nil { if err := projectService.Reactivate(c.Param("uuid"), dbUsername); err != nil {
switch { switch {
case errors.Is(err, services.ErrProjectNotFound): case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) respondError(c, http.StatusNotFound, "resource not found", err)
case errors.Is(err, services.ErrProjectForbidden): case errors.Is(err, services.ErrProjectForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) respondError(c, http.StatusForbidden, "access denied", err)
default: default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
} }
return return
} }
@@ -1638,13 +1573,13 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err := projectService.DeleteVariant(c.Param("uuid"), dbUsername); err != nil { if err := projectService.DeleteVariant(c.Param("uuid"), dbUsername); err != nil {
switch { switch {
case errors.Is(err, services.ErrCannotDeleteMainVariant): case errors.Is(err, services.ErrCannotDeleteMainVariant):
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) respondError(c, http.StatusBadRequest, "invalid request", err)
case errors.Is(err, services.ErrProjectNotFound): case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) respondError(c, http.StatusNotFound, "resource not found", err)
case errors.Is(err, services.ErrProjectForbidden): case errors.Is(err, services.ErrProjectForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) respondError(c, http.StatusForbidden, "access denied", err)
default: default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
} }
return return
} }
@@ -1664,11 +1599,11 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, services.ErrProjectNotFound): case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) respondError(c, http.StatusNotFound, "resource not found", err)
case errors.Is(err, services.ErrProjectForbidden): case errors.Is(err, services.ErrProjectForbidden):
c.JSON(http.StatusForbidden, gin.H{"error": err.Error()}) respondError(c, http.StatusForbidden, "access denied", err)
default: default:
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
} }
return return
} }
@@ -1681,7 +1616,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
OrderedUUIDs []string `json:"ordered_uuids"` OrderedUUIDs []string `json:"ordered_uuids"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) respondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
if len(req.OrderedUUIDs) == 0 { if len(req.OrderedUUIDs) == 0 {
@@ -1693,9 +1628,9 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
if err != nil { if err != nil {
switch { switch {
case errors.Is(err, services.ErrProjectNotFound): case errors.Is(err, services.ErrProjectNotFound):
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) respondError(c, http.StatusNotFound, "resource not found", err)
default: default:
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) respondError(c, http.StatusBadRequest, "invalid request", err)
} }
return return
} }
@@ -1716,7 +1651,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
projects.POST("/:uuid/configs", func(c *gin.Context) { projects.POST("/:uuid/configs", func(c *gin.Context) {
var req services.CreateConfigRequest var req services.CreateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) respondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
projectUUID := c.Param("uuid") projectUUID := c.Param("uuid")
@@ -1724,27 +1659,79 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
config, err := configService.Create(dbUsername, &req) config, err := configService.Create(dbUsername, &req)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
c.JSON(http.StatusCreated, config) c.JSON(http.StatusCreated, config)
}) })
projects.POST("/:uuid/vendor-import", func(c *gin.Context) {
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, vendorImportBodyLimit())
fileHeader, err := c.FormFile("file")
if err != nil {
if isVendorImportTooLarge(0, err) {
respondError(c, http.StatusBadRequest, "vendor workspace file exceeds 1 GiB limit", errVendorImportTooLarge)
return
}
respondError(c, http.StatusBadRequest, "file is required", err)
return
}
if isVendorImportTooLarge(fileHeader.Size, nil) {
respondError(c, http.StatusBadRequest, "vendor workspace file exceeds 1 GiB limit", errVendorImportTooLarge)
return
}
file, err := fileHeader.Open()
if err != nil {
respondError(c, http.StatusBadRequest, "failed to open uploaded file", err)
return
}
defer file.Close()
data, err := io.ReadAll(io.LimitReader(file, vendorImportMaxBytes+1))
if err != nil {
respondError(c, http.StatusBadRequest, "failed to read uploaded file", err)
return
}
if int64(len(data)) > vendorImportMaxBytes {
respondError(c, http.StatusBadRequest, "vendor workspace file exceeds 1 GiB limit", errVendorImportTooLarge)
return
}
if !services.IsCFXMLWorkspace(data) {
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported vendor export format"})
return
}
result, err := configService.ImportVendorWorkspaceToProject(c.Param("uuid"), fileHeader.Filename, data, dbUsername)
if err != nil {
switch {
case errors.Is(err, services.ErrProjectNotFound):
respondError(c, http.StatusNotFound, "resource not found", err)
default:
respondError(c, http.StatusBadRequest, "invalid request", err)
}
return
}
c.JSON(http.StatusCreated, result)
})
projects.GET("/:uuid/export", exportHandler.ExportProjectCSV) projects.GET("/:uuid/export", exportHandler.ExportProjectCSV)
projects.POST("/:uuid/export", exportHandler.ExportProjectPricingCSV)
projects.POST("/:uuid/configs/:config_uuid/clone", func(c *gin.Context) { projects.POST("/:uuid/configs/:config_uuid/clone", func(c *gin.Context) {
var req struct { var req struct {
Name string `json:"name"` Name string `json:"name"`
} }
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) respondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
projectUUID := c.Param("uuid") projectUUID := c.Param("uuid")
config, err := configService.CloneNoAuthToProject(c.Param("config_uuid"), req.Name, dbUsername, &projectUUID) config, err := configService.CloneNoAuthToProject(c.Param("config_uuid"), req.Name, dbUsername, &projectUUID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) respondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
c.JSON(http.StatusCreated, config) c.JSON(http.StatusCreated, config)
@@ -1818,22 +1805,12 @@ func requestLogger() gin.HandlerFunc {
path := c.Request.URL.Path path := c.Request.URL.Path
query := c.Request.URL.RawQuery query := c.Request.URL.RawQuery
blw := &captureResponseWriter{
ResponseWriter: c.Writer,
body: bytes.NewBuffer(nil),
}
c.Writer = blw
c.Next() c.Next()
latency := time.Since(start) latency := time.Since(start)
status := c.Writer.Status() status := c.Writer.Status()
if status >= http.StatusBadRequest { if status >= http.StatusBadRequest {
responseBody := strings.TrimSpace(blw.body.String())
if len(responseBody) > 2048 {
responseBody = responseBody[:2048] + "...(truncated)"
}
errText := strings.TrimSpace(c.Errors.String()) errText := strings.TrimSpace(c.Errors.String())
slog.Error("request failed", slog.Error("request failed",
@@ -1844,7 +1821,6 @@ func requestLogger() gin.HandlerFunc {
"latency", latency, "latency", latency,
"ip", c.ClientIP(), "ip", c.ClientIP(),
"errors", errText, "errors", errText,
"response", responseBody,
) )
return return
} }
@@ -1859,22 +1835,3 @@ func requestLogger() gin.HandlerFunc {
) )
} }
} }
type captureResponseWriter struct {
gin.ResponseWriter
body *bytes.Buffer
}
func (w *captureResponseWriter) Write(b []byte) (int, error) {
if len(b) > 0 {
_, _ = w.body.Write(b)
}
return w.ResponseWriter.Write(b)
}
func (w *captureResponseWriter) WriteString(s string) (int, error) {
if s != "" {
_, _ = w.body.WriteString(s)
}
return w.ResponseWriter.WriteString(s)
}
+48
View File
@@ -0,0 +1,48 @@
package main
import (
"bytes"
"errors"
"log/slog"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestRequestLoggerDoesNotLogResponseBody(t *testing.T) {
gin.SetMode(gin.TestMode)
var logBuffer bytes.Buffer
previousLogger := slog.Default()
slog.SetDefault(slog.New(slog.NewTextHandler(&logBuffer, &slog.HandlerOptions{})))
defer slog.SetDefault(previousLogger)
router := gin.New()
router.Use(requestLogger())
router.GET("/fail", func(c *gin.Context) {
_ = c.Error(errors.New("root cause"))
c.JSON(http.StatusBadRequest, gin.H{"error": "do not log this body"})
})
rec := httptest.NewRecorder()
req := httptest.NewRequest(http.MethodGet, "/fail?debug=1", nil)
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", rec.Code)
}
logOutput := logBuffer.String()
if !strings.Contains(logOutput, "request failed") {
t.Fatalf("expected request failure log, got %q", logOutput)
}
if strings.Contains(logOutput, "do not log this body") {
t.Fatalf("response body leaked into logs: %q", logOutput)
}
if !strings.Contains(logOutput, "root cause") {
t.Fatalf("expected error details in logs, got %q", logOutput)
}
}
+87 -3
View File
@@ -3,10 +3,12 @@ package main
import ( import (
"bytes" "bytes"
"encoding/json" "encoding/json"
"mime/multipart"
"net/http" "net/http"
"net/http/httptest" "net/http/httptest"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"git.mchus.pro/mchus/quoteforge/internal/config" "git.mchus.pro/mchus/quoteforge/internal/config"
@@ -37,7 +39,7 @@ func TestConfigurationVersioningAPI(t *testing.T) {
cfg := &config.Config{} cfg := &config.Config{}
setConfigDefaults(cfg) setConfigDefaults(cfg)
router, _, err := setupRouter(cfg, local, connMgr, nil, "tester", nil) router, _, err := setupRouter(cfg, local, connMgr, "tester", nil)
if err != nil { if err != nil {
t.Fatalf("setup router: %v", err) t.Fatalf("setup router: %v", err)
} }
@@ -144,7 +146,7 @@ func TestProjectArchiveHidesConfigsAndCloneIntoProject(t *testing.T) {
cfg := &config.Config{} cfg := &config.Config{}
setConfigDefaults(cfg) setConfigDefaults(cfg)
router, _, err := setupRouter(cfg, local, connMgr, nil, "tester", nil) router, _, err := setupRouter(cfg, local, connMgr, "tester", nil)
if err != nil { if err != nil {
t.Fatalf("setup router: %v", err) t.Fatalf("setup router: %v", err)
} }
@@ -238,7 +240,7 @@ func TestConfigMoveToProjectEndpoint(t *testing.T) {
local, connMgr, _ := newAPITestStack(t) local, connMgr, _ := newAPITestStack(t)
cfg := &config.Config{} cfg := &config.Config{}
setConfigDefaults(cfg) setConfigDefaults(cfg)
router, _, err := setupRouter(cfg, local, connMgr, nil, "tester", nil) router, _, err := setupRouter(cfg, local, connMgr, "tester", nil)
if err != nil { if err != nil {
t.Fatalf("setup router: %v", err) t.Fatalf("setup router: %v", err)
} }
@@ -290,6 +292,88 @@ func TestConfigMoveToProjectEndpoint(t *testing.T) {
} }
} }
func TestVendorImportRejectsOversizedUpload(t *testing.T) {
moveToRepoRoot(t)
prevLimit := vendorImportMaxBytes
vendorImportMaxBytes = 128
defer func() { vendorImportMaxBytes = prevLimit }()
local, connMgr, _ := newAPITestStack(t)
cfg := &config.Config{}
setConfigDefaults(cfg)
router, _, err := setupRouter(cfg, local, connMgr, "tester", nil)
if err != nil {
t.Fatalf("setup router: %v", err)
}
createProjectReq := httptest.NewRequest(http.MethodPost, "/api/projects", bytes.NewReader([]byte(`{"name":"Import Project","code":"IMP"}`)))
createProjectReq.Header.Set("Content-Type", "application/json")
createProjectRec := httptest.NewRecorder()
router.ServeHTTP(createProjectRec, createProjectReq)
if createProjectRec.Code != http.StatusCreated {
t.Fatalf("create project status=%d body=%s", createProjectRec.Code, createProjectRec.Body.String())
}
var project models.Project
if err := json.Unmarshal(createProjectRec.Body.Bytes(), &project); err != nil {
t.Fatalf("unmarshal project: %v", err)
}
var body bytes.Buffer
writer := multipart.NewWriter(&body)
part, err := writer.CreateFormFile("file", "huge.xml")
if err != nil {
t.Fatalf("create form file: %v", err)
}
payload := "<CFXML>" + strings.Repeat("A", int(vendorImportMaxBytes)+1) + "</CFXML>"
if _, err := part.Write([]byte(payload)); err != nil {
t.Fatalf("write multipart payload: %v", err)
}
if err := writer.Close(); err != nil {
t.Fatalf("close multipart writer: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/projects/"+project.UUID+"/vendor-import", &body)
req.Header.Set("Content-Type", writer.FormDataContentType())
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for oversized upload, got %d body=%s", rec.Code, rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "1 GiB") {
t.Fatalf("expected size limit message, got %s", rec.Body.String())
}
}
func TestCreateConfigMalformedJSONReturnsGenericError(t *testing.T) {
moveToRepoRoot(t)
local, connMgr, _ := newAPITestStack(t)
cfg := &config.Config{}
setConfigDefaults(cfg)
router, _, err := setupRouter(cfg, local, connMgr, "tester", nil)
if err != nil {
t.Fatalf("setup router: %v", err)
}
req := httptest.NewRequest(http.MethodPost, "/api/configs", bytes.NewReader([]byte(`{"name":`)))
req.Header.Set("Content-Type", "application/json")
rec := httptest.NewRecorder()
router.ServeHTTP(rec, req)
if rec.Code != http.StatusBadRequest {
t.Fatalf("expected 400 for malformed json, got %d body=%s", rec.Code, rec.Body.String())
}
if strings.Contains(strings.ToLower(rec.Body.String()), "unexpected eof") {
t.Fatalf("expected sanitized error body, got %s", rec.Body.String())
}
if !strings.Contains(rec.Body.String(), "invalid request") {
t.Fatalf("expected generic invalid request message, got %s", rec.Body.String())
}
}
func newAPITestStack(t *testing.T) (*localdb.LocalDB, *db.ConnectionManager, *services.LocalConfigurationService) { func newAPITestStack(t *testing.T) (*localdb.LocalDB, *db.ConnectionManager, *services.LocalConfigurationService) {
t.Helper() t.Helper()
+6 -49
View File
@@ -1,61 +1,18 @@
# QuoteForge Configuration # QuoteForge runtime config
# Copy this file to config.yaml and update values # Runtime creates a minimal config automatically on first start.
# This file is only a reference template.
server: server:
host: "127.0.0.1" # Use 0.0.0.0 to listen on all interfaces host: "127.0.0.1" # Loopback only; remote HTTP binding is unsupported
port: 8080 port: 8080
mode: "release" # debug | release mode: "release" # debug | release
read_timeout: "30s" read_timeout: "30s"
write_timeout: "30s" write_timeout: "30s"
database:
host: "localhost"
port: 3306
name: "RFQ_LOG"
user: "quoteforge"
password: "CHANGE_ME"
max_open_conns: 25
max_idle_conns: 5
conn_max_lifetime: "5m"
auth:
jwt_secret: "CHANGE_ME_MIN_32_CHARACTERS_LONG"
token_expiry: "24h"
refresh_expiry: "168h" # 7 days
pricing:
default_method: "weighted_median" # median | average | weighted_median
default_period_days: 90
freshness_green_days: 30
freshness_yellow_days: 60
freshness_red_days: 90
min_quotes_for_median: 3
popularity_decay_days: 180
export:
temp_dir: "/tmp/quoteforge-exports"
max_file_age: "1h"
company_name: "Your Company Name"
backup: backup:
time: "00:00" time: "00:00"
alerts:
enabled: true
check_interval: "1h"
high_demand_threshold: 5 # КП за 30 дней
trending_threshold_percent: 50 # % роста для алерта
notifications:
email_enabled: false
smtp_host: "smtp.example.com"
smtp_port: 587
smtp_user: ""
smtp_password: ""
from_address: "quoteforge@example.com"
logging: logging:
level: "info" # debug | info | warn | error level: "info" # debug | info | warn | error
format: "json" # json | text format: "json" # json | text
output: "stdout" # stdout | file output: "stdout" # stdout | stderr | /path/to/file
file_path: "/var/log/quoteforge/app.log"
+2 -3
View File
@@ -5,9 +5,8 @@ go 1.24.0
require ( require (
github.com/gin-gonic/gin v1.9.1 github.com/gin-gonic/gin v1.9.1
github.com/glebarez/sqlite v1.11.0 github.com/glebarez/sqlite v1.11.0
github.com/golang-jwt/jwt/v5 v5.3.0 github.com/go-sql-driver/mysql v1.7.1
github.com/google/uuid v1.6.0 github.com/google/uuid v1.6.0
golang.org/x/crypto v0.43.0
gopkg.in/yaml.v3 v3.0.1 gopkg.in/yaml.v3 v3.0.1
gorm.io/driver/mysql v1.5.2 gorm.io/driver/mysql v1.5.2
gorm.io/gorm v1.25.7 gorm.io/gorm v1.25.7
@@ -23,7 +22,6 @@ require (
github.com/go-playground/locales v0.14.1 // indirect github.com/go-playground/locales v0.14.1 // indirect
github.com/go-playground/universal-translator v0.18.1 // indirect github.com/go-playground/universal-translator v0.18.1 // indirect
github.com/go-playground/validator/v10 v10.14.0 // indirect github.com/go-playground/validator/v10 v10.14.0 // indirect
github.com/go-sql-driver/mysql v1.7.1 // indirect
github.com/goccy/go-json v0.10.2 // indirect github.com/goccy/go-json v0.10.2 // indirect
github.com/jinzhu/inflection v1.0.0 // indirect github.com/jinzhu/inflection v1.0.0 // indirect
github.com/jinzhu/now v1.1.5 // indirect github.com/jinzhu/now v1.1.5 // indirect
@@ -39,6 +37,7 @@ require (
github.com/twitchyliquid64/golang-asm v0.15.1 // indirect github.com/twitchyliquid64/golang-asm v0.15.1 // indirect
github.com/ugorji/go/codec v1.2.11 // indirect github.com/ugorji/go/codec v1.2.11 // indirect
golang.org/x/arch v0.3.0 // indirect golang.org/x/arch v0.3.0 // indirect
golang.org/x/crypto v0.43.0 // indirect
golang.org/x/net v0.46.0 // indirect golang.org/x/net v0.46.0 // indirect
golang.org/x/sys v0.37.0 // indirect golang.org/x/sys v0.37.0 // indirect
golang.org/x/text v0.30.0 // indirect golang.org/x/text v0.30.0 // indirect
-2
View File
@@ -32,8 +32,6 @@ github.com/go-sql-driver/mysql v1.7.1 h1:lUIinVbN1DY0xBg0eMOzmmtGoHwWBbvnWubQUrt
github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI= github.com/go-sql-driver/mysql v1.7.1/go.mod h1:OXbVy3sEdcQ2Doequ6Z5BW6fXNQTmx+9S1MCJN5yJMI=
github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU= github.com/goccy/go-json v0.10.2 h1:CrxCmQqYDkv1z7lO7Wbh2HN93uovUHgrECaO5ZrCXAU=
github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I= github.com/goccy/go-json v0.10.2/go.mod h1:6MelG93GURQebXPDq3khkgXZkazVtN9CRI+MGFi0w8I=
github.com/golang-jwt/jwt/v5 v5.3.0 h1:pv4AsKCKKZuqlgs5sUmn4x8UlGa0kEVt/puTpKx9vvo=
github.com/golang-jwt/jwt/v5 v5.3.0/go.mod h1:fxCRLWMO43lRc8nhHWY6LGqRcf+1gQWArsqaEUEa5bE=
github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk= github.com/golang/protobuf v1.5.0/go.mod h1:FsONVRAS9T7sI+LIUmWTfcYkHO4aIWwzhcaSAoJOfIk=
github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU= github.com/google/go-cmp v0.5.5 h1:Khx7svrCpmxxtHBq5j2mp/xVjsi8hQMfNLvJFAlrGgU=
github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE= github.com/google/go-cmp v0.5.5/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
+124 -4
View File
@@ -10,6 +10,10 @@ import (
"sort" "sort"
"strings" "strings"
"time" "time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
) )
type backupPeriod struct { type backupPeriod struct {
@@ -88,6 +92,9 @@ func EnsureRotatingLocalBackup(dbPath, configPath string) ([]string, error) {
} }
root := resolveBackupRoot(dbPath) root := resolveBackupRoot(dbPath)
if err := validateBackupRoot(root); err != nil {
return nil, err
}
now := backupNow() now := backupNow()
created := make([]string, 0) created := make([]string, 0)
@@ -111,6 +118,40 @@ func resolveBackupRoot(dbPath string) string {
return filepath.Join(filepath.Dir(dbPath), "backups") return filepath.Join(filepath.Dir(dbPath), "backups")
} }
func validateBackupRoot(root string) error {
absRoot, err := filepath.Abs(root)
if err != nil {
return fmt.Errorf("resolve backup root: %w", err)
}
if gitRoot, ok := findGitWorktreeRoot(absRoot); ok {
return fmt.Errorf("backup root must stay outside git worktree: %s is inside %s", absRoot, gitRoot)
}
return nil
}
func findGitWorktreeRoot(path string) (string, bool) {
current := filepath.Clean(path)
info, err := os.Stat(current)
if err == nil && !info.IsDir() {
current = filepath.Dir(current)
}
for {
gitPath := filepath.Join(current, ".git")
if _, err := os.Stat(gitPath); err == nil {
return current, true
}
parent := filepath.Dir(current)
if parent == current {
return "", false
}
current = parent
}
}
func isBackupDisabled() bool { func isBackupDisabled() bool {
val := strings.ToLower(strings.TrimSpace(os.Getenv(envBackupDisable))) val := strings.ToLower(strings.TrimSpace(os.Getenv(envBackupDisable)))
return val == "1" || val == "true" || val == "yes" return val == "1" || val == "true" || val == "yes"
@@ -213,6 +254,12 @@ func pruneOldBackups(periodDir string, keep int) error {
} }
func createBackupArchive(destPath, dbPath, configPath string) error { func createBackupArchive(destPath, dbPath, configPath string) error {
snapshotPath, cleanup, err := createSQLiteSnapshot(dbPath)
if err != nil {
return err
}
defer cleanup()
file, err := os.Create(destPath) file, err := os.Create(destPath)
if err != nil { if err != nil {
return err return err
@@ -220,12 +267,10 @@ func createBackupArchive(destPath, dbPath, configPath string) error {
defer file.Close() defer file.Close()
zipWriter := zip.NewWriter(file) zipWriter := zip.NewWriter(file)
if err := addZipFile(zipWriter, dbPath); err != nil { if err := addZipFileAs(zipWriter, snapshotPath, filepath.Base(dbPath)); err != nil {
_ = zipWriter.Close() _ = zipWriter.Close()
return err return err
} }
_ = addZipOptionalFile(zipWriter, dbPath+"-wal")
_ = addZipOptionalFile(zipWriter, dbPath+"-shm")
if strings.TrimSpace(configPath) != "" { if strings.TrimSpace(configPath) != "" {
_ = addZipOptionalFile(zipWriter, configPath) _ = addZipOptionalFile(zipWriter, configPath)
@@ -237,6 +282,77 @@ func createBackupArchive(destPath, dbPath, configPath string) error {
return file.Sync() return file.Sync()
} }
func createSQLiteSnapshot(dbPath string) (string, func(), error) {
tempFile, err := os.CreateTemp("", "qfs-backup-*.db")
if err != nil {
return "", func() {}, err
}
tempPath := tempFile.Name()
if err := tempFile.Close(); err != nil {
_ = os.Remove(tempPath)
return "", func() {}, err
}
if err := os.Remove(tempPath); err != nil && !os.IsNotExist(err) {
return "", func() {}, err
}
cleanup := func() {
_ = os.Remove(tempPath)
}
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
cleanup()
return "", func() {}, err
}
sqlDB, err := db.DB()
if err != nil {
cleanup()
return "", func() {}, err
}
defer sqlDB.Close()
if err := db.Exec("PRAGMA busy_timeout = 5000").Error; err != nil {
cleanup()
return "", func() {}, fmt.Errorf("configure sqlite busy_timeout: %w", err)
}
literalPath := strings.ReplaceAll(tempPath, "'", "''")
if err := vacuumIntoWithRetry(db, literalPath); err != nil {
cleanup()
return "", func() {}, err
}
return tempPath, cleanup, nil
}
func vacuumIntoWithRetry(db *gorm.DB, literalPath string) error {
var lastErr error
for attempt := 0; attempt < 3; attempt++ {
if err := db.Exec("VACUUM INTO '" + literalPath + "'").Error; err != nil {
lastErr = err
if !isSQLiteBusyError(err) {
return fmt.Errorf("create sqlite snapshot: %w", err)
}
time.Sleep(time.Duration(attempt+1) * 250 * time.Millisecond)
continue
}
return nil
}
return fmt.Errorf("create sqlite snapshot after retries: %w", lastErr)
}
func isSQLiteBusyError(err error) bool {
if err == nil {
return false
}
lower := strings.ToLower(err.Error())
return strings.Contains(lower, "database is locked") || strings.Contains(lower, "database is busy")
}
func addZipOptionalFile(writer *zip.Writer, path string) error { func addZipOptionalFile(writer *zip.Writer, path string) error {
if _, err := os.Stat(path); err != nil { if _, err := os.Stat(path); err != nil {
return nil return nil
@@ -245,6 +361,10 @@ func addZipOptionalFile(writer *zip.Writer, path string) error {
} }
func addZipFile(writer *zip.Writer, path string) error { func addZipFile(writer *zip.Writer, path string) error {
return addZipFileAs(writer, path, filepath.Base(path))
}
func addZipFileAs(writer *zip.Writer, path string, archiveName string) error {
in, err := os.Open(path) in, err := os.Open(path)
if err != nil { if err != nil {
return err return err
@@ -260,7 +380,7 @@ func addZipFile(writer *zip.Writer, path string) error {
if err != nil { if err != nil {
return err return err
} }
header.Name = filepath.Base(path) header.Name = archiveName
header.Method = zip.Deflate header.Method = zip.Deflate
out, err := writer.CreateHeader(header) out, err := writer.CreateHeader(header)
+80 -6
View File
@@ -1,10 +1,15 @@
package appstate package appstate
import ( import (
"archive/zip"
"os" "os"
"path/filepath" "path/filepath"
"strings"
"testing" "testing"
"time" "time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
) )
func TestEnsureRotatingLocalBackupCreatesAndRotates(t *testing.T) { func TestEnsureRotatingLocalBackupCreatesAndRotates(t *testing.T) {
@@ -12,8 +17,8 @@ func TestEnsureRotatingLocalBackupCreatesAndRotates(t *testing.T) {
dbPath := filepath.Join(temp, "qfs.db") dbPath := filepath.Join(temp, "qfs.db")
cfgPath := filepath.Join(temp, "config.yaml") cfgPath := filepath.Join(temp, "config.yaml")
if err := os.WriteFile(dbPath, []byte("db"), 0644); err != nil { if err := writeTestSQLiteDB(dbPath); err != nil {
t.Fatalf("write db: %v", err) t.Fatalf("write sqlite db: %v", err)
} }
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil { if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
t.Fatalf("write config: %v", err) t.Fatalf("write config: %v", err)
@@ -35,6 +40,7 @@ func TestEnsureRotatingLocalBackupCreatesAndRotates(t *testing.T) {
if _, err := os.Stat(dailyArchive); err != nil { if _, err := os.Stat(dailyArchive); err != nil {
t.Fatalf("daily archive missing: %v", err) t.Fatalf("daily archive missing: %v", err)
} }
assertZipContains(t, dailyArchive, "qfs.db", "config.yaml")
backupNow = func() time.Time { return time.Date(2026, 2, 12, 10, 0, 0, 0, time.UTC) } backupNow = func() time.Time { return time.Date(2026, 2, 12, 10, 0, 0, 0, time.UTC) }
created, err = EnsureRotatingLocalBackup(dbPath, cfgPath) created, err = EnsureRotatingLocalBackup(dbPath, cfgPath)
@@ -56,8 +62,8 @@ func TestEnsureRotatingLocalBackupEnvControls(t *testing.T) {
dbPath := filepath.Join(temp, "qfs.db") dbPath := filepath.Join(temp, "qfs.db")
cfgPath := filepath.Join(temp, "config.yaml") cfgPath := filepath.Join(temp, "config.yaml")
if err := os.WriteFile(dbPath, []byte("db"), 0644); err != nil { if err := writeTestSQLiteDB(dbPath); err != nil {
t.Fatalf("write db: %v", err) t.Fatalf("write sqlite db: %v", err)
} }
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil { if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
t.Fatalf("write config: %v", err) t.Fatalf("write config: %v", err)
@@ -69,7 +75,7 @@ func TestEnsureRotatingLocalBackupEnvControls(t *testing.T) {
if _, err := EnsureRotatingLocalBackup(dbPath, cfgPath); err != nil { if _, err := EnsureRotatingLocalBackup(dbPath, cfgPath); err != nil {
t.Fatalf("backup with env: %v", err) t.Fatalf("backup with env: %v", err)
} }
if _, err := os.Stat(filepath.Join(backupRoot, "daily", "meta.json")); err != nil { if _, err := os.Stat(filepath.Join(backupRoot, "daily", ".period.json")); err != nil {
t.Fatalf("expected backup in custom dir: %v", err) t.Fatalf("expected backup in custom dir: %v", err)
} }
@@ -77,7 +83,75 @@ func TestEnsureRotatingLocalBackupEnvControls(t *testing.T) {
if _, err := EnsureRotatingLocalBackup(dbPath, cfgPath); err != nil { if _, err := EnsureRotatingLocalBackup(dbPath, cfgPath); err != nil {
t.Fatalf("backup disabled: %v", err) t.Fatalf("backup disabled: %v", err)
} }
if _, err := os.Stat(filepath.Join(backupRoot, "daily", "meta.json")); err != nil { if _, err := os.Stat(filepath.Join(backupRoot, "daily", ".period.json")); err != nil {
t.Fatalf("backup should remain from previous run: %v", err) t.Fatalf("backup should remain from previous run: %v", err)
} }
} }
func TestEnsureRotatingLocalBackupRejectsGitWorktree(t *testing.T) {
temp := t.TempDir()
repoRoot := filepath.Join(temp, "repo")
if err := os.MkdirAll(filepath.Join(repoRoot, ".git"), 0755); err != nil {
t.Fatalf("mkdir git dir: %v", err)
}
dbPath := filepath.Join(repoRoot, "data", "qfs.db")
cfgPath := filepath.Join(repoRoot, "data", "config.yaml")
if err := os.MkdirAll(filepath.Dir(dbPath), 0755); err != nil {
t.Fatalf("mkdir data dir: %v", err)
}
if err := writeTestSQLiteDB(dbPath); err != nil {
t.Fatalf("write sqlite db: %v", err)
}
if err := os.WriteFile(cfgPath, []byte("cfg"), 0644); err != nil {
t.Fatalf("write cfg: %v", err)
}
_, err := EnsureRotatingLocalBackup(dbPath, cfgPath)
if err == nil {
t.Fatal("expected git worktree backup root to be rejected")
}
if !strings.Contains(err.Error(), "outside git worktree") {
t.Fatalf("unexpected error: %v", err)
}
}
func writeTestSQLiteDB(path string) error {
db, err := gorm.Open(sqlite.Open(path), &gorm.Config{})
if err != nil {
return err
}
sqlDB, err := db.DB()
if err != nil {
return err
}
defer sqlDB.Close()
return db.Exec(`
CREATE TABLE sample_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
name TEXT NOT NULL
);
INSERT INTO sample_items(name) VALUES ('backup');
`).Error
}
func assertZipContains(t *testing.T, archivePath string, expected ...string) {
t.Helper()
reader, err := zip.OpenReader(archivePath)
if err != nil {
t.Fatalf("open archive: %v", err)
}
defer reader.Close()
found := make(map[string]bool, len(reader.File))
for _, file := range reader.File {
found[file.Name] = true
}
for _, name := range expected {
if !found[name] {
t.Fatalf("archive %s missing %s", archivePath, name)
}
}
}
+4 -1
View File
@@ -195,6 +195,9 @@ func buildGPUSegment(items []models.ConfigItem, cats map[string]string) string {
if !ok || group != GroupGPU { if !ok || group != GroupGPU {
continue continue
} }
if strings.HasPrefix(strings.ToUpper(it.LotName), "MB_") {
continue
}
model := parseGPUModel(it.LotName) model := parseGPUModel(it.LotName)
if model == "" { if model == "" {
model = "UNK" model = "UNK"
@@ -332,7 +335,7 @@ func parseGPUModel(lotName string) string {
continue continue
} }
switch p { switch p {
case "NV", "NVIDIA", "AMD", "RADEON", "PCIE", "PCI", "SXM", "SXMX": case "NV", "NVIDIA", "INTEL", "AMD", "RADEON", "PCIE", "PCI", "SXM", "SXMX":
continue continue
default: default:
if strings.Contains(p, "GB") { if strings.Contains(p, "GB") {
+9 -114
View File
@@ -7,20 +7,14 @@ import (
"strconv" "strconv"
"time" "time"
mysqlDriver "github.com/go-sql-driver/mysql"
"gopkg.in/yaml.v3" "gopkg.in/yaml.v3"
) )
type Config struct { type Config struct {
Server ServerConfig `yaml:"server"` Server ServerConfig `yaml:"server"`
Database DatabaseConfig `yaml:"database"` Export ExportConfig `yaml:"export"`
Auth AuthConfig `yaml:"auth"` Logging LoggingConfig `yaml:"logging"`
Pricing PricingConfig `yaml:"pricing"` Backup BackupConfig `yaml:"backup"`
Export ExportConfig `yaml:"export"`
Alerts AlertsConfig `yaml:"alerts"`
Notifications NotificationsConfig `yaml:"notifications"`
Logging LoggingConfig `yaml:"logging"`
Backup BackupConfig `yaml:"backup"`
} }
type ServerConfig struct { type ServerConfig struct {
@@ -31,70 +25,6 @@ type ServerConfig struct {
WriteTimeout time.Duration `yaml:"write_timeout"` WriteTimeout time.Duration `yaml:"write_timeout"`
} }
type DatabaseConfig struct {
Host string `yaml:"host"`
Port int `yaml:"port"`
Name string `yaml:"name"`
User string `yaml:"user"`
Password string `yaml:"password"`
MaxOpenConns int `yaml:"max_open_conns"`
MaxIdleConns int `yaml:"max_idle_conns"`
ConnMaxLifetime time.Duration `yaml:"conn_max_lifetime"`
}
func (d *DatabaseConfig) DSN() string {
cfg := mysqlDriver.NewConfig()
cfg.User = d.User
cfg.Passwd = d.Password
cfg.Net = "tcp"
cfg.Addr = net.JoinHostPort(d.Host, strconv.Itoa(d.Port))
cfg.DBName = d.Name
cfg.ParseTime = true
cfg.Loc = time.Local
cfg.Params = map[string]string{
"charset": "utf8mb4",
}
return cfg.FormatDSN()
}
type AuthConfig struct {
JWTSecret string `yaml:"jwt_secret"`
TokenExpiry time.Duration `yaml:"token_expiry"`
RefreshExpiry time.Duration `yaml:"refresh_expiry"`
}
type PricingConfig struct {
DefaultMethod string `yaml:"default_method"`
DefaultPeriodDays int `yaml:"default_period_days"`
FreshnessGreenDays int `yaml:"freshness_green_days"`
FreshnessYellowDays int `yaml:"freshness_yellow_days"`
FreshnessRedDays int `yaml:"freshness_red_days"`
MinQuotesForMedian int `yaml:"min_quotes_for_median"`
PopularityDecayDays int `yaml:"popularity_decay_days"`
}
type ExportConfig struct {
TempDir string `yaml:"temp_dir"`
MaxFileAge time.Duration `yaml:"max_file_age"`
CompanyName string `yaml:"company_name"`
}
type AlertsConfig struct {
Enabled bool `yaml:"enabled"`
CheckInterval time.Duration `yaml:"check_interval"`
HighDemandThreshold int `yaml:"high_demand_threshold"`
TrendingThresholdPercent int `yaml:"trending_threshold_percent"`
}
type NotificationsConfig struct {
EmailEnabled bool `yaml:"email_enabled"`
SMTPHost string `yaml:"smtp_host"`
SMTPPort int `yaml:"smtp_port"`
SMTPUser string `yaml:"smtp_user"`
SMTPPassword string `yaml:"smtp_password"`
FromAddress string `yaml:"from_address"`
}
type LoggingConfig struct { type LoggingConfig struct {
Level string `yaml:"level"` Level string `yaml:"level"`
Format string `yaml:"format"` Format string `yaml:"format"`
@@ -102,6 +32,10 @@ type LoggingConfig struct {
FilePath string `yaml:"file_path"` FilePath string `yaml:"file_path"`
} }
// ExportConfig is kept for constructor compatibility in export services.
// Runtime no longer persists an export section in config.yaml.
type ExportConfig struct{}
type BackupConfig struct { type BackupConfig struct {
Time string `yaml:"time"` Time string `yaml:"time"`
} }
@@ -139,45 +73,6 @@ func (c *Config) setDefaults() {
c.Server.WriteTimeout = 30 * time.Second c.Server.WriteTimeout = 30 * time.Second
} }
if c.Database.Port == 0 {
c.Database.Port = 3306
}
if c.Database.MaxOpenConns == 0 {
c.Database.MaxOpenConns = 25
}
if c.Database.MaxIdleConns == 0 {
c.Database.MaxIdleConns = 5
}
if c.Database.ConnMaxLifetime == 0 {
c.Database.ConnMaxLifetime = 5 * time.Minute
}
if c.Auth.TokenExpiry == 0 {
c.Auth.TokenExpiry = 24 * time.Hour
}
if c.Auth.RefreshExpiry == 0 {
c.Auth.RefreshExpiry = 7 * 24 * time.Hour
}
if c.Pricing.DefaultMethod == "" {
c.Pricing.DefaultMethod = "weighted_median"
}
if c.Pricing.DefaultPeriodDays == 0 {
c.Pricing.DefaultPeriodDays = 90
}
if c.Pricing.FreshnessGreenDays == 0 {
c.Pricing.FreshnessGreenDays = 30
}
if c.Pricing.FreshnessYellowDays == 0 {
c.Pricing.FreshnessYellowDays = 60
}
if c.Pricing.FreshnessRedDays == 0 {
c.Pricing.FreshnessRedDays = 90
}
if c.Pricing.MinQuotesForMedian == 0 {
c.Pricing.MinQuotesForMedian = 3
}
if c.Logging.Level == "" { if c.Logging.Level == "" {
c.Logging.Level = "info" c.Logging.Level = "info"
} }
@@ -194,5 +89,5 @@ func (c *Config) setDefaults() {
} }
func (c *Config) Address() string { func (c *Config) Address() string {
return fmt.Sprintf("%s:%d", c.Server.Host, c.Server.Port) return net.JoinHostPort(c.Server.Host, strconv.Itoa(c.Server.Port))
} }
-113
View File
@@ -1,113 +0,0 @@
package handlers
import (
"net/http"
"github.com/gin-gonic/gin"
"git.mchus.pro/mchus/quoteforge/internal/middleware"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services"
)
type AuthHandler struct {
authService *services.AuthService
userRepo *repository.UserRepository
}
func NewAuthHandler(authService *services.AuthService, userRepo *repository.UserRepository) *AuthHandler {
return &AuthHandler{
authService: authService,
userRepo: userRepo,
}
}
type LoginRequest struct {
Username string `json:"username" binding:"required"`
Password string `json:"password" binding:"required"`
}
type LoginResponse struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresAt int64 `json:"expires_at"`
User UserResponse `json:"user"`
}
type UserResponse struct {
ID uint `json:"id"`
Username string `json:"username"`
Email string `json:"email"`
Role string `json:"role"`
}
func (h *AuthHandler) Login(c *gin.Context) {
var req LoginRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tokens, user, err := h.authService.Login(req.Username, req.Password)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, LoginResponse{
AccessToken: tokens.AccessToken,
RefreshToken: tokens.RefreshToken,
ExpiresAt: tokens.ExpiresAt,
User: UserResponse{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Role: string(user.Role),
},
})
}
type RefreshRequest struct {
RefreshToken string `json:"refresh_token" binding:"required"`
}
func (h *AuthHandler) Refresh(c *gin.Context) {
var req RefreshRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
tokens, err := h.authService.RefreshTokens(req.RefreshToken)
if err != nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, tokens)
}
func (h *AuthHandler) Me(c *gin.Context) {
claims := middleware.GetClaims(c)
if claims == nil {
c.JSON(http.StatusUnauthorized, gin.H{"error": "not authenticated"})
return
}
user, err := h.userRepo.GetByID(claims.UserID)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "user not found"})
return
}
c.JSON(http.StatusOK, UserResponse{
ID: user.ID,
Username: user.Username,
Email: user.Email,
Role: string(user.Role),
})
}
func (h *AuthHandler) Logout(c *gin.Context) {
// JWT is stateless, logout is handled on client by discarding tokens
c.JSON(http.StatusOK, gin.H{"message": "logged out"})
}
+1 -1
View File
@@ -49,7 +49,7 @@ func (h *ComponentHandler) List(c *gin.Context) {
offset := (page - 1) * perPage offset := (page - 1) * perPage
localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage) localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
-239
View File
@@ -1,239 +0,0 @@
package handlers
import (
"net/http"
"strconv"
"git.mchus.pro/mchus/quoteforge/internal/middleware"
"git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin"
)
type ConfigurationHandler struct {
configService *services.ConfigurationService
exportService *services.ExportService
}
func NewConfigurationHandler(
configService *services.ConfigurationService,
exportService *services.ExportService,
) *ConfigurationHandler {
return &ConfigurationHandler{
configService: configService,
exportService: exportService,
}
}
func (h *ConfigurationHandler) List(c *gin.Context) {
username := middleware.GetUsername(c)
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
configs, total, err := h.configService.ListByUser(username, page, perPage)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{
"configurations": configs,
"total": total,
"page": page,
"per_page": perPage,
})
}
func (h *ConfigurationHandler) Create(c *gin.Context) {
username := middleware.GetUsername(c)
var req services.CreateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
config, err := h.configService.Create(username, &req)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, config)
}
func (h *ConfigurationHandler) Get(c *gin.Context) {
username := middleware.GetUsername(c)
uuid := c.Param("uuid")
config, err := h.configService.GetByUUID(uuid, username)
if err != nil {
status := http.StatusNotFound
if err == services.ErrConfigForbidden {
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, config)
}
func (h *ConfigurationHandler) Update(c *gin.Context) {
username := middleware.GetUsername(c)
uuid := c.Param("uuid")
var req services.CreateConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
config, err := h.configService.Update(uuid, username, &req)
if err != nil {
status := http.StatusInternalServerError
if err == services.ErrConfigNotFound {
status = http.StatusNotFound
} else if err == services.ErrConfigForbidden {
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, config)
}
func (h *ConfigurationHandler) Delete(c *gin.Context) {
username := middleware.GetUsername(c)
uuid := c.Param("uuid")
err := h.configService.Delete(uuid, username)
if err != nil {
status := http.StatusInternalServerError
if err == services.ErrConfigNotFound {
status = http.StatusNotFound
} else if err == services.ErrConfigForbidden {
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, gin.H{"message": "deleted"})
}
type RenameConfigRequest struct {
Name string `json:"name" binding:"required"`
}
func (h *ConfigurationHandler) Rename(c *gin.Context) {
username := middleware.GetUsername(c)
uuid := c.Param("uuid")
var req RenameConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
config, err := h.configService.Rename(uuid, username, req.Name)
if err != nil {
status := http.StatusInternalServerError
if err == services.ErrConfigNotFound {
status = http.StatusNotFound
} else if err == services.ErrConfigForbidden {
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, config)
}
type CloneConfigRequest struct {
Name string `json:"name" binding:"required"`
}
func (h *ConfigurationHandler) Clone(c *gin.Context) {
username := middleware.GetUsername(c)
uuid := c.Param("uuid")
var req CloneConfigRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
config, err := h.configService.Clone(uuid, username, req.Name)
if err != nil {
status := http.StatusInternalServerError
if err == services.ErrConfigNotFound {
status = http.StatusNotFound
} else if err == services.ErrConfigForbidden {
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, config)
}
func (h *ConfigurationHandler) RefreshPrices(c *gin.Context) {
username := middleware.GetUsername(c)
uuid := c.Param("uuid")
config, err := h.configService.RefreshPrices(uuid, username)
if err != nil {
status := http.StatusInternalServerError
if err == services.ErrConfigNotFound {
status = http.StatusNotFound
} else if err == services.ErrConfigForbidden {
status = http.StatusForbidden
}
c.JSON(status, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusOK, config)
}
// func (h *ConfigurationHandler) ExportJSON(c *gin.Context) {
// userID := middleware.GetUserID(c)
// uuid := c.Param("uuid")
//
// config, err := h.configService.GetByUUID(uuid, userID)
// if err != nil {
// c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
// return
// }
//
// data, err := h.configService.ExportJSON(uuid, userID)
// if err != nil {
// c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
// return
// }
//
// filename := fmt.Sprintf("%s %s SPEC.json", config.CreatedAt.Format("2006-01-02"), config.Name)
// c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
// c.Data(http.StatusOK, "application/json", data)
// }
// func (h *ConfigurationHandler) ImportJSON(c *gin.Context) {
// userID := middleware.GetUserID(c)
//
// data, err := io.ReadAll(c.Request.Body)
// if err != nil {
// c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read body"})
// return
// }
//
// config, err := h.configService.ImportJSON(userID, data)
// if err != nil {
// c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
// return
// }
//
// c.JSON(http.StatusCreated, config)
// }
+69 -13
View File
@@ -6,7 +6,6 @@ import (
"strings" "strings"
"time" "time"
"git.mchus.pro/mchus/quoteforge/internal/middleware"
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/services" "git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
@@ -16,17 +15,20 @@ type ExportHandler struct {
exportService *services.ExportService exportService *services.ExportService
configService services.ConfigurationGetter configService services.ConfigurationGetter
projectService *services.ProjectService projectService *services.ProjectService
dbUsername string
} }
func NewExportHandler( func NewExportHandler(
exportService *services.ExportService, exportService *services.ExportService,
configService services.ConfigurationGetter, configService services.ConfigurationGetter,
projectService *services.ProjectService, projectService *services.ProjectService,
dbUsername string,
) *ExportHandler { ) *ExportHandler {
return &ExportHandler{ return &ExportHandler{
exportService: exportService, exportService: exportService,
configService: configService, configService: configService,
projectService: projectService, projectService: projectService,
dbUsername: dbUsername,
} }
} }
@@ -45,10 +47,18 @@ type ExportRequest struct {
Notes string `json:"notes"` Notes string `json:"notes"`
} }
type ProjectExportOptionsRequest struct {
IncludeLOT bool `json:"include_lot"`
IncludeBOM bool `json:"include_bom"`
IncludeEstimate bool `json:"include_estimate"`
IncludeStock bool `json:"include_stock"`
IncludeCompetitor bool `json:"include_competitor"`
}
func (h *ExportHandler) ExportCSV(c *gin.Context) { func (h *ExportHandler) ExportCSV(c *gin.Context) {
var req ExportRequest var req ExportRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) RespondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
@@ -63,8 +73,7 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
// Get project code for filename // Get project code for filename
projectCode := req.ProjectName // legacy field: may contain code from frontend projectCode := req.ProjectName // legacy field: may contain code from frontend
if projectCode == "" && req.ProjectUUID != "" { if projectCode == "" && req.ProjectUUID != "" {
username := middleware.GetUsername(c) if project, err := h.projectService.GetByUUID(req.ProjectUUID, h.dbUsername); err == nil && project != nil {
if project, err := h.projectService.GetByUUID(req.ProjectUUID, username); err == nil && project != nil {
projectCode = project.Code projectCode = project.Code
} }
} }
@@ -136,13 +145,12 @@ func sanitizeFilenameSegment(value string) string {
} }
func (h *ExportHandler) ExportConfigCSV(c *gin.Context) { func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
username := middleware.GetUsername(c)
uuid := c.Param("uuid") uuid := c.Param("uuid")
// Get config before streaming (can return JSON error) // Get config before streaming (can return JSON error)
config, err := h.configService.GetByUUID(uuid, username) config, err := h.configService.GetByUUID(uuid, h.dbUsername)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) RespondError(c, http.StatusNotFound, "resource not found", err)
return return
} }
@@ -157,7 +165,7 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
// Get project code for filename // Get project code for filename
projectCode := config.Name // fallback: use config name if no project projectCode := config.Name // fallback: use config name if no project
if config.ProjectUUID != nil && *config.ProjectUUID != "" { if config.ProjectUUID != nil && *config.ProjectUUID != "" {
if project, err := h.projectService.GetByUUID(*config.ProjectUUID, username); err == nil && project != nil { if project, err := h.projectService.GetByUUID(*config.ProjectUUID, h.dbUsername); err == nil && project != nil {
projectCode = project.Code projectCode = project.Code
} }
} }
@@ -181,18 +189,17 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
// ExportProjectCSV exports all active configurations of a project as a single CSV file. // ExportProjectCSV exports all active configurations of a project as a single CSV file.
func (h *ExportHandler) ExportProjectCSV(c *gin.Context) { func (h *ExportHandler) ExportProjectCSV(c *gin.Context) {
username := middleware.GetUsername(c)
projectUUID := c.Param("uuid") projectUUID := c.Param("uuid")
project, err := h.projectService.GetByUUID(projectUUID, username) project, err := h.projectService.GetByUUID(projectUUID, h.dbUsername)
if err != nil { if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()}) RespondError(c, http.StatusNotFound, "resource not found", err)
return return
} }
result, err := h.projectService.ListConfigurations(projectUUID, username, "active") result, err := h.projectService.ListConfigurations(projectUUID, h.dbUsername, "active")
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
@@ -213,3 +220,52 @@ func (h *ExportHandler) ExportProjectCSV(c *gin.Context) {
return return
} }
} }
func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) {
projectUUID := c.Param("uuid")
var req ProjectExportOptionsRequest
if err := c.ShouldBindJSON(&req); err != nil {
RespondError(c, http.StatusBadRequest, "invalid request", err)
return
}
project, err := h.projectService.GetByUUID(projectUUID, h.dbUsername)
if err != nil {
RespondError(c, http.StatusNotFound, "resource not found", err)
return
}
result, err := h.projectService.ListConfigurations(projectUUID, h.dbUsername, "active")
if err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
if len(result.Configs) == 0 {
c.JSON(http.StatusBadRequest, gin.H{"error": "no configurations to export"})
return
}
opts := services.ProjectPricingExportOptions{
IncludeLOT: req.IncludeLOT,
IncludeBOM: req.IncludeBOM,
IncludeEstimate: req.IncludeEstimate,
IncludeStock: req.IncludeStock,
IncludeCompetitor: req.IncludeCompetitor,
}
data, err := h.exportService.ProjectToPricingExportData(result.Configs, opts)
if err != nil {
RespondError(c, http.StatusInternalServerError, "internal server error", err)
return
}
filename := fmt.Sprintf("%s (%s) pricing.csv", time.Now().Format("2006-01-02"), project.Code)
c.Header("Content-Type", "text/csv; charset=utf-8")
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
if err := h.exportService.ToPricingCSV(c.Writer, data, opts); err != nil {
c.Error(err)
return
}
}
+6 -8
View File
@@ -26,7 +26,6 @@ func (m *mockConfigService) GetByUUID(uuid string, ownerUsername string) (*model
return m.config, m.err return m.config, m.err
} }
func TestExportCSV_Success(t *testing.T) { func TestExportCSV_Success(t *testing.T) {
gin.SetMode(gin.TestMode) gin.SetMode(gin.TestMode)
@@ -36,6 +35,7 @@ func TestExportCSV_Success(t *testing.T) {
exportSvc, exportSvc,
&mockConfigService{}, &mockConfigService{},
nil, nil,
"testuser",
) )
// Create JSON request body // Create JSON request body
@@ -110,6 +110,7 @@ func TestExportCSV_InvalidRequest(t *testing.T) {
exportSvc, exportSvc,
&mockConfigService{}, &mockConfigService{},
nil, nil,
"testuser",
) )
// Create invalid request (missing required field) // Create invalid request (missing required field)
@@ -143,6 +144,7 @@ func TestExportCSV_EmptyItems(t *testing.T) {
exportSvc, exportSvc,
&mockConfigService{}, &mockConfigService{},
nil, nil,
"testuser",
) )
// Create request with empty items array - should fail binding validation // Create request with empty items array - should fail binding validation
@@ -184,6 +186,7 @@ func TestExportConfigCSV_Success(t *testing.T) {
exportSvc, exportSvc,
&mockConfigService{config: mockConfig}, &mockConfigService{config: mockConfig},
nil, nil,
"testuser",
) )
// Create HTTP request // Create HTTP request
@@ -196,9 +199,6 @@ func TestExportConfigCSV_Success(t *testing.T) {
{Key: "uuid", Value: "test-uuid"}, {Key: "uuid", Value: "test-uuid"},
} }
// Mock middleware.GetUsername
c.Set("username", "testuser")
handler.ExportConfigCSV(c) handler.ExportConfigCSV(c)
// Check status code // Check status code
@@ -233,6 +233,7 @@ func TestExportConfigCSV_NotFound(t *testing.T) {
exportSvc, exportSvc,
&mockConfigService{err: errors.New("config not found")}, &mockConfigService{err: errors.New("config not found")},
nil, nil,
"testuser",
) )
req, _ := http.NewRequest("GET", "/api/configs/nonexistent-uuid/export", nil) req, _ := http.NewRequest("GET", "/api/configs/nonexistent-uuid/export", nil)
@@ -243,8 +244,6 @@ func TestExportConfigCSV_NotFound(t *testing.T) {
c.Params = gin.Params{ c.Params = gin.Params{
{Key: "uuid", Value: "nonexistent-uuid"}, {Key: "uuid", Value: "nonexistent-uuid"},
} }
c.Set("username", "testuser")
handler.ExportConfigCSV(c) handler.ExportConfigCSV(c)
// Should return 404 Not Found // Should return 404 Not Found
@@ -277,6 +276,7 @@ func TestExportConfigCSV_EmptyItems(t *testing.T) {
exportSvc, exportSvc,
&mockConfigService{config: mockConfig}, &mockConfigService{config: mockConfig},
nil, nil,
"testuser",
) )
req, _ := http.NewRequest("GET", "/api/configs/test-uuid/export", nil) req, _ := http.NewRequest("GET", "/api/configs/test-uuid/export", nil)
@@ -287,8 +287,6 @@ func TestExportConfigCSV_EmptyItems(t *testing.T) {
c.Params = gin.Params{ c.Params = gin.Params{
{Key: "uuid", Value: "test-uuid"}, {Key: "uuid", Value: "test-uuid"},
} }
c.Set("username", "testuser")
handler.ExportConfigCSV(c) handler.ExportConfigCSV(c)
// Should return 400 Bad Request // Should return 400 Bad Request
+24 -8
View File
@@ -3,6 +3,7 @@ package handlers
import ( import (
"net/http" "net/http"
"strconv" "strconv"
"strings"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/repository"
@@ -24,7 +25,7 @@ func (h *PartnumberBooksHandler) List(c *gin.Context) {
bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB()) bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB())
books, err := bookRepo.ListBooks() books, err := bookRepo.ListBooks()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
@@ -66,6 +67,15 @@ func (h *PartnumberBooksHandler) GetItems(c *gin.Context) {
} }
bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB()) bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB())
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "100"))
search := strings.TrimSpace(c.Query("search"))
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 500 {
perPage = 100
}
// Find local book by server_id // Find local book by server_id
var book localdb.LocalPartnumberBook var book localdb.LocalPartnumberBook
@@ -74,17 +84,23 @@ func (h *PartnumberBooksHandler) GetItems(c *gin.Context) {
return return
} }
items, err := bookRepo.GetBookItems(book.ID) items, total, err := bookRepo.GetBookItemsPage(book.ID, search, page, perPage)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"book_id": book.ServerID, "book_id": book.ServerID,
"version": book.Version, "version": book.Version,
"is_active": book.IsActive, "is_active": book.IsActive,
"items": items, "partnumbers": book.PartnumbersJSON,
"total": len(items), "items": items,
"total": total,
"page": page,
"per_page": perPage,
"search": search,
"book_total": bookRepo.CountBookItems(book.ID),
"lot_count": bookRepo.CountDistinctLots(book.ID),
}) })
} }
+34 -10
View File
@@ -34,7 +34,7 @@ func (h *PricelistHandler) List(c *gin.Context) {
localPLs, err := h.localDB.GetLocalPricelists() localPLs, err := h.localDB.GetLocalPricelists()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
if source != "" { if source != "" {
@@ -172,24 +172,48 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
} }
var total int64 var total int64
if err := dbq.Count(&total).Error; err != nil { if err := dbq.Count(&total).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
offset := (page - 1) * perPage offset := (page - 1) * perPage
if err := dbq.Order("lot_name").Offset(offset).Limit(perPage).Find(&items).Error; err != nil { if err := dbq.Order("lot_name").Offset(offset).Limit(perPage).Find(&items).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
lotNames := make([]string, len(items))
for i, item := range items {
lotNames[i] = item.LotName
}
type compRow struct {
LotName string
LotDescription string
}
var comps []compRow
if len(lotNames) > 0 {
h.localDB.DB().Table("local_components").
Select("lot_name, lot_description").
Where("lot_name IN ?", lotNames).
Scan(&comps)
}
descMap := make(map[string]string, len(comps))
for _, c := range comps {
descMap[c.LotName] = c.LotDescription
}
resultItems := make([]gin.H, 0, len(items)) resultItems := make([]gin.H, 0, len(items))
for _, item := range items { for _, item := range items {
resultItems = append(resultItems, gin.H{ resultItems = append(resultItems, gin.H{
"id": item.ID, "id": item.ID,
"lot_name": item.LotName, "lot_name": item.LotName,
"price": item.Price, "lot_description": descMap[item.LotName],
"category": item.LotCategory, "price": item.Price,
"available_qty": item.AvailableQty, "category": item.LotCategory,
"partnumbers": []string(item.Partnumbers), "available_qty": item.AvailableQty,
"partnumbers": []string(item.Partnumbers),
"partnumber_qtys": map[string]interface{}{},
"competitor_names": []string{},
"price_spread_pct": nil,
}) })
} }
@@ -217,7 +241,7 @@ func (h *PricelistHandler) GetLotNames(c *gin.Context) {
} }
items, err := h.localDB.GetLocalPricelistItems(localPL.ID) items, err := h.localDB.GetLocalPricelistItems(localPL.ID)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
lotNames := make([]string, 0, len(items)) lotNames := make([]string, 0, len(items))
+6 -6
View File
@@ -18,13 +18,13 @@ func NewQuoteHandler(quoteService *services.QuoteService) *QuoteHandler {
func (h *QuoteHandler) Validate(c *gin.Context) { func (h *QuoteHandler) Validate(c *gin.Context) {
var req services.QuoteRequest var req services.QuoteRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) RespondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
result, err := h.quoteService.ValidateAndCalculate(&req) result, err := h.quoteService.ValidateAndCalculate(&req)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) RespondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
@@ -34,13 +34,13 @@ func (h *QuoteHandler) Validate(c *gin.Context) {
func (h *QuoteHandler) Calculate(c *gin.Context) { func (h *QuoteHandler) Calculate(c *gin.Context) {
var req services.QuoteRequest var req services.QuoteRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) RespondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
result, err := h.quoteService.ValidateAndCalculate(&req) result, err := h.quoteService.ValidateAndCalculate(&req)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) RespondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
@@ -53,13 +53,13 @@ func (h *QuoteHandler) Calculate(c *gin.Context) {
func (h *QuoteHandler) PriceLevels(c *gin.Context) { func (h *QuoteHandler) PriceLevels(c *gin.Context) {
var req services.PriceLevelsRequest var req services.PriceLevelsRequest
if err := c.ShouldBindJSON(&req); err != nil { if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) RespondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
result, err := h.quoteService.CalculatePriceLevels(&req) result, err := h.quoteService.CalculatePriceLevels(&req)
if err != nil { if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) RespondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
+73
View File
@@ -0,0 +1,73 @@
package handlers
import (
"encoding/json"
"errors"
"io"
"strings"
"github.com/gin-gonic/gin"
)
func RespondError(c *gin.Context, status int, fallback string, err error) {
if err != nil {
_ = c.Error(err)
}
c.JSON(status, gin.H{"error": clientFacingErrorMessage(status, fallback, err)})
}
func clientFacingErrorMessage(status int, fallback string, err error) string {
if err == nil {
return fallback
}
if status >= 500 {
return fallback
}
if isRequestDecodeError(err) {
return fallback
}
message := strings.TrimSpace(err.Error())
if message == "" {
return fallback
}
if looksTechnicalError(message) {
return fallback
}
return message
}
func isRequestDecodeError(err error) bool {
var syntaxErr *json.SyntaxError
if errors.As(err, &syntaxErr) {
return true
}
var unmarshalTypeErr *json.UnmarshalTypeError
if errors.As(err, &unmarshalTypeErr) {
return true
}
return errors.Is(err, io.ErrUnexpectedEOF) || errors.Is(err, io.EOF)
}
func looksTechnicalError(message string) bool {
lower := strings.ToLower(strings.TrimSpace(message))
needles := []string{
"sql",
"gorm",
"driver",
"constraint",
"syntax error",
"unexpected eof",
"record not found",
"no such table",
"stack trace",
}
for _, needle := range needles {
if strings.Contains(lower, needle) {
return true
}
}
return false
}
+41
View File
@@ -0,0 +1,41 @@
package handlers
import (
"encoding/json"
"testing"
)
func TestClientFacingErrorMessageKeepsDomain4xx(t *testing.T) {
t.Parallel()
got := clientFacingErrorMessage(400, "invalid request", &json.SyntaxError{Offset: 1})
if got != "invalid request" {
t.Fatalf("expected fallback for decode error, got %q", got)
}
}
func TestClientFacingErrorMessagePreservesBusinessMessage(t *testing.T) {
t.Parallel()
err := errString("main project variant cannot be deleted")
got := clientFacingErrorMessage(400, "invalid request", err)
if got != err.Error() {
t.Fatalf("expected business message, got %q", got)
}
}
func TestClientFacingErrorMessageHidesTechnical4xx(t *testing.T) {
t.Parallel()
err := errString("sql: no rows in result set")
got := clientFacingErrorMessage(404, "resource not found", err)
if got != "resource not found" {
t.Fatalf("expected fallback for technical error, got %q", got)
}
}
type errString string
func (e errString) Error() string {
return string(e)
}
+58 -64
View File
@@ -1,6 +1,7 @@
package handlers package handlers
import ( import (
"errors"
"fmt" "fmt"
"html/template" "html/template"
"log/slog" "log/slog"
@@ -12,8 +13,8 @@ import (
qfassets "git.mchus.pro/mchus/quoteforge" qfassets "git.mchus.pro/mchus/quoteforge"
"git.mchus.pro/mchus/quoteforge/internal/db" "git.mchus.pro/mchus/quoteforge/internal/db"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
mysqlDriver "github.com/go-sql-driver/mysql"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
mysqlDriver "github.com/go-sql-driver/mysql"
gormmysql "gorm.io/driver/mysql" gormmysql "gorm.io/driver/mysql"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/logger" "gorm.io/gorm/logger"
@@ -26,6 +27,8 @@ type SetupHandler struct {
restartSig chan struct{} restartSig chan struct{}
} }
var errPermissionProbeRollback = errors.New("permission probe rollback")
func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, _ string, restartSig chan struct{}) (*SetupHandler, error) { func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, _ string, restartSig chan struct{}) (*SetupHandler, error) {
funcMap := template.FuncMap{ funcMap := template.FuncMap{
"sub": func(a, b int) int { return a - b }, "sub": func(a, b int) int { return a - b },
@@ -64,7 +67,8 @@ func (h *SetupHandler) ShowSetup(c *gin.Context) {
tmpl := h.templates["setup.html"] tmpl := h.templates["setup.html"]
if err := tmpl.ExecuteTemplate(c.Writer, "setup.html", data); err != nil { if err := tmpl.ExecuteTemplate(c.Writer, "setup.html", data); err != nil {
c.String(http.StatusInternalServerError, "Template error: %v", err) _ = c.Error(err)
c.String(http.StatusInternalServerError, "Template error")
} }
} }
@@ -89,49 +93,16 @@ func (h *SetupHandler) TestConnection(c *gin.Context) {
} }
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second) dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
lotCount, canWrite, err := validateMariaDBConnection(dsn)
db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil { if err != nil {
_ = c.Error(err)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": false, "success": false,
"error": fmt.Sprintf("Connection failed: %v", err), "error": "Connection check failed",
}) })
return return
} }
sqlDB, err := db.DB()
if err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"error": fmt.Sprintf("Failed to get database handle: %v", err),
})
return
}
defer sqlDB.Close()
if err := sqlDB.Ping(); err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"error": fmt.Sprintf("Ping failed: %v", err),
})
return
}
// Check for required tables
var lotCount int64
if err := db.Table("lot").Count(&lotCount).Error; err != nil {
c.JSON(http.StatusOK, gin.H{
"success": false,
"error": fmt.Sprintf("Table 'lot' not found or inaccessible: %v", err),
})
return
}
// Check write permission
canWrite := testWritePermission(db)
c.JSON(http.StatusOK, gin.H{ c.JSON(http.StatusOK, gin.H{
"success": true, "success": true,
"lot_count": lotCount, "lot_count": lotCount,
@@ -164,26 +135,21 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) {
// Test connection first // Test connection first
dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second) dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second)
if _, _, err := validateMariaDBConnection(dsn); err != nil {
db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{ _ = c.Error(err)
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{ c.JSON(http.StatusBadRequest, gin.H{
"success": false, "success": false,
"error": fmt.Sprintf("Connection failed: %v", err), "error": "Connection check failed",
}) })
return return
} }
sqlDB, _ := db.DB()
sqlDB.Close()
// Save settings // Save settings
if err := h.localDB.SaveSettings(host, port, database, user, password); err != nil { if err := h.localDB.SaveSettings(host, port, database, user, password); err != nil {
_ = c.Error(err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": fmt.Sprintf("Failed to save settings: %v", err), "error": "Failed to save settings",
}) })
return return
} }
@@ -232,22 +198,6 @@ func (h *SetupHandler) GetStatus(c *gin.Context) {
}) })
} }
func testWritePermission(db *gorm.DB) bool {
// Simple check: try to create a temporary table and drop it
testTable := fmt.Sprintf("qt_write_test_%d", time.Now().UnixNano())
// Try to create a test table
err := db.Exec(fmt.Sprintf("CREATE TABLE %s (id INT)", testTable)).Error
if err != nil {
return false
}
// Drop it immediately
db.Exec(fmt.Sprintf("DROP TABLE %s", testTable))
return true
}
func buildMySQLDSN(host string, port int, database, user, password string, timeout time.Duration) string { func buildMySQLDSN(host string, port int, database, user, password string, timeout time.Duration) string {
cfg := mysqlDriver.NewConfig() cfg := mysqlDriver.NewConfig()
cfg.User = user cfg.User = user
@@ -263,3 +213,47 @@ func buildMySQLDSN(host string, port int, database, user, password string, timeo
} }
return cfg.FormatDSN() return cfg.FormatDSN()
} }
func validateMariaDBConnection(dsn string) (int64, bool, error) {
db, err := gorm.Open(gormmysql.Open(dsn), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
return 0, false, fmt.Errorf("open MariaDB connection: %w", err)
}
sqlDB, err := db.DB()
if err != nil {
return 0, false, fmt.Errorf("get database handle: %w", err)
}
defer sqlDB.Close()
if err := sqlDB.Ping(); err != nil {
return 0, false, fmt.Errorf("ping MariaDB: %w", err)
}
var lotCount int64
if err := db.Table("lot").Count(&lotCount).Error; err != nil {
return 0, false, fmt.Errorf("check required table lot: %w", err)
}
return lotCount, testSyncWritePermission(db), nil
}
func testSyncWritePermission(db *gorm.DB) bool {
sentinel := fmt.Sprintf("quoteforge-permission-check-%d", time.Now().UnixNano())
err := db.Transaction(func(tx *gorm.DB) error {
if err := tx.Exec(`
INSERT INTO qt_client_schema_state (username, hostname, last_checked_at, updated_at)
VALUES (?, ?, NOW(), NOW())
ON DUPLICATE KEY UPDATE
last_checked_at = VALUES(last_checked_at),
updated_at = VALUES(updated_at)
`, sentinel, "setup-check").Error; err != nil {
return err
}
return errPermissionProbeRollback
})
return errors.Is(err, errPermissionProbeRollback)
}
+37 -36
View File
@@ -116,9 +116,7 @@ func (h *SyncHandler) GetStatus(c *gin.Context) {
func (h *SyncHandler) GetReadiness(c *gin.Context) { func (h *SyncHandler) GetReadiness(c *gin.Context) {
readiness, err := h.syncService.GetReadiness() readiness, err := h.syncService.GetReadiness()
if err != nil && readiness == nil { if err != nil && readiness == nil {
c.JSON(http.StatusInternalServerError, gin.H{ RespondError(c, http.StatusInternalServerError, "internal server error", err)
"error": err.Error(),
})
return return
} }
if readiness == nil { if readiness == nil {
@@ -158,8 +156,9 @@ func (h *SyncHandler) ensureSyncReadiness(c *gin.Context) bool {
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": err.Error(), "error": "internal server error",
}) })
_ = c.Error(err)
_ = readiness _ = readiness
return false return false
} }
@@ -184,8 +183,9 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) {
if err != nil { if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{ c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false, "success": false,
"error": "Database connection failed: " + err.Error(), "error": "database connection failed",
}) })
_ = c.Error(err)
return return
} }
@@ -194,8 +194,9 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) {
slog.Error("component sync failed", "error", err) slog.Error("component sync failed", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": err.Error(), "error": "component sync failed",
}) })
_ = c.Error(err)
return return
} }
@@ -220,8 +221,9 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
slog.Error("pricelist sync failed", "error", err) slog.Error("pricelist sync failed", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": err.Error(), "error": "pricelist sync failed",
}) })
_ = c.Error(err)
return return
} }
@@ -247,8 +249,9 @@ func (h *SyncHandler) SyncPartnumberBooks(c *gin.Context) {
slog.Error("partnumber books pull failed", "error", err) slog.Error("partnumber books pull failed", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": err.Error(), "error": "partnumber books sync failed",
}) })
_ = c.Error(err)
return return
} }
@@ -295,8 +298,9 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
slog.Error("pending push failed during full sync", "error", err) slog.Error("pending push failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": "Pending changes push failed: " + err.Error(), "error": "pending changes push failed",
}) })
_ = c.Error(err)
return return
} }
@@ -305,8 +309,9 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
if err != nil { if err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{ c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false, "success": false,
"error": "Database connection failed: " + err.Error(), "error": "database connection failed",
}) })
_ = c.Error(err)
return return
} }
@@ -315,8 +320,9 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
slog.Error("component sync failed during full sync", "error", err) slog.Error("component sync failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": "Component sync failed: " + err.Error(), "error": "component sync failed",
}) })
_ = c.Error(err)
return return
} }
componentsSynced = compResult.TotalSynced componentsSynced = compResult.TotalSynced
@@ -327,10 +333,11 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
slog.Error("pricelist sync failed during full sync", "error", err) slog.Error("pricelist sync failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": "Pricelist sync failed: " + err.Error(), "error": "pricelist sync failed",
"pending_pushed": pendingPushed, "pending_pushed": pendingPushed,
"components_synced": componentsSynced, "components_synced": componentsSynced,
}) })
_ = c.Error(err)
return return
} }
@@ -339,11 +346,12 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
slog.Error("project import failed during full sync", "error", err) slog.Error("project import failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": "Project import failed: " + err.Error(), "error": "project import failed",
"pending_pushed": pendingPushed, "pending_pushed": pendingPushed,
"components_synced": componentsSynced, "components_synced": componentsSynced,
"pricelists_synced": pricelistsSynced, "pricelists_synced": pricelistsSynced,
}) })
_ = c.Error(err)
return return
} }
@@ -352,7 +360,7 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
slog.Error("configuration import failed during full sync", "error", err) slog.Error("configuration import failed during full sync", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": "Configuration import failed: " + err.Error(), "error": "configuration import failed",
"pending_pushed": pendingPushed, "pending_pushed": pendingPushed,
"components_synced": componentsSynced, "components_synced": componentsSynced,
"pricelists_synced": pricelistsSynced, "pricelists_synced": pricelistsSynced,
@@ -360,6 +368,7 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
"projects_updated": projectsResult.Updated, "projects_updated": projectsResult.Updated,
"projects_skipped": projectsResult.Skipped, "projects_skipped": projectsResult.Skipped,
}) })
_ = c.Error(err)
return return
} }
@@ -398,8 +407,9 @@ func (h *SyncHandler) PushPendingChanges(c *gin.Context) {
slog.Error("push pending changes failed", "error", err) slog.Error("push pending changes failed", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": err.Error(), "error": "pending changes push failed",
}) })
_ = c.Error(err)
return return
} }
@@ -426,9 +436,7 @@ func (h *SyncHandler) GetPendingCount(c *gin.Context) {
func (h *SyncHandler) GetPendingChanges(c *gin.Context) { func (h *SyncHandler) GetPendingChanges(c *gin.Context) {
changes, err := h.localDB.GetPendingChanges() changes, err := h.localDB.GetPendingChanges()
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ RespondError(c, http.StatusInternalServerError, "internal server error", err)
"error": err.Error(),
})
return return
} }
@@ -445,8 +453,9 @@ func (h *SyncHandler) RepairPendingChanges(c *gin.Context) {
slog.Error("repair pending changes failed", "error", err) slog.Error("repair pending changes failed", "error", err)
c.JSON(http.StatusInternalServerError, gin.H{ c.JSON(http.StatusInternalServerError, gin.H{
"success": false, "success": false,
"error": err.Error(), "error": "pending changes repair failed",
}) })
_ = c.Error(err)
return return
} }
@@ -516,18 +525,11 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
// Get sync times // Get sync times
lastPricelistSync := h.localDB.GetLastSyncTime() lastPricelistSync := h.localDB.GetLastSyncTime()
// Get MariaDB counts (if online)
var lotCount, lotLogCount int64
if isOnline {
if mariaDB, err := h.connMgr.GetDB(); err == nil {
mariaDB.Table("lot").Count(&lotCount)
mariaDB.Table("lot_log").Count(&lotLogCount)
}
}
// Get local counts // Get local counts
configCount := h.localDB.CountConfigurations() configCount := h.localDB.CountConfigurations()
projectCount := h.localDB.CountProjects() projectCount := h.localDB.CountProjects()
componentCount := h.localDB.CountLocalComponents()
pricelistCount := h.localDB.CountLocalPricelists()
// Get error count (only changes with LastError != "") // Get error count (only changes with LastError != "")
errorCount := int(h.localDB.CountErroredChanges()) errorCount := int(h.localDB.CountErroredChanges())
@@ -562,8 +564,8 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
DBName: dbName, DBName: dbName,
IsOnline: isOnline, IsOnline: isOnline,
LastSyncAt: lastPricelistSync, LastSyncAt: lastPricelistSync,
LotCount: lotCount, LotCount: componentCount,
LotLogCount: lotLogCount, LotLogCount: pricelistCount,
ConfigCount: configCount, ConfigCount: configCount,
ProjectCount: projectCount, ProjectCount: projectCount,
PendingChanges: changes, PendingChanges: changes,
@@ -595,9 +597,7 @@ func (h *SyncHandler) GetUsersStatus(c *gin.Context) {
users, err := h.syncService.ListUserSyncStatuses(threshold) users, err := h.syncService.ListUserSyncStatuses(threshold)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{ RespondError(c, http.StatusInternalServerError, "internal server error", err)
"error": err.Error(),
})
return return
} }
@@ -646,7 +646,8 @@ func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
c.Header("Content-Type", "text/html; charset=utf-8") c.Header("Content-Type", "text/html; charset=utf-8")
if err := h.tmpl.ExecuteTemplate(c.Writer, "sync_status", data); err != nil { if err := h.tmpl.ExecuteTemplate(c.Writer, "sync_status", data); err != nil {
slog.Error("failed to render sync_status template", "error", err) slog.Error("failed to render sync_status template", "error", err)
c.String(http.StatusInternalServerError, "Template error: "+err.Error()) _ = c.Error(err)
c.String(http.StatusInternalServerError, "Template error")
} }
} }
@@ -682,7 +683,7 @@ func (h *SyncHandler) ReportPartnumberSeen(c *gin.Context) {
} `json:"items"` } `json:"items"`
} }
if err := c.ShouldBindJSON(&body); err != nil { if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) RespondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
@@ -698,7 +699,7 @@ func (h *SyncHandler) ReportPartnumberSeen(c *gin.Context) {
} }
if err := h.syncService.PushPartnumberSeen(items); err != nil { if err := h.syncService.PushPartnumberSeen(items); err != nil {
c.JSON(http.StatusServiceUnavailable, gin.H{"error": err.Error()}) RespondError(c, http.StatusServiceUnavailable, "service unavailable", err)
return return
} }
+9 -9
View File
@@ -62,7 +62,7 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"` VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
} }
if err := c.ShouldBindJSON(&body); err != nil { if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) RespondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
@@ -82,11 +82,11 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
spec := localdb.VendorSpec(body.VendorSpec) spec := localdb.VendorSpec(body.VendorSpec)
specJSON, err := json.Marshal(spec) specJSON, err := json.Marshal(spec)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
if err := h.localDB.DB().Model(cfg).Update("vendor_spec", string(specJSON)).Error; err != nil { if err := h.localDB.DB().Model(cfg).Update("vendor_spec", string(specJSON)).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
@@ -138,7 +138,7 @@ func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"` VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"`
} }
if err := c.ShouldBindJSON(&body); err != nil { if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) RespondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
@@ -147,14 +147,14 @@ func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {
resolved, err := resolver.Resolve(body.VendorSpec) resolved, err := resolver.Resolve(body.VendorSpec)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
book, _ := bookRepo.GetActiveBook() book, _ := bookRepo.GetActiveBook()
aggregated, err := services.AggregateLOTs(resolved, book, bookRepo) aggregated, err := services.AggregateLOTs(resolved, book, bookRepo)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
@@ -181,7 +181,7 @@ func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) {
} `json:"items"` } `json:"items"`
} }
if err := c.ShouldBindJSON(&body); err != nil { if err := c.ShouldBindJSON(&body); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) RespondError(c, http.StatusBadRequest, "invalid request", err)
return return
} }
@@ -196,12 +196,12 @@ func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) {
itemsJSON, err := json.Marshal(newItems) itemsJSON, err := json.Marshal(newItems)
if err != nil { if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
if err := h.localDB.DB().Model(cfg).Update("items", string(itemsJSON)).Error; err != nil { if err := h.localDB.DB().Model(cfg).Update("items", string(itemsJSON)).Error; err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) RespondError(c, http.StatusInternalServerError, "internal server error", err)
return return
} }
+76 -32
View File
@@ -1,21 +1,23 @@
package handlers package handlers
import ( import (
"fmt"
"html/template" "html/template"
"strconv" "strconv"
"strings"
qfassets "git.mchus.pro/mchus/quoteforge" qfassets "git.mchus.pro/mchus/quoteforge"
"git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/services" "git.mchus.pro/mchus/quoteforge/internal/models"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
type WebHandler struct { type WebHandler struct {
templates map[string]*template.Template templates map[string]*template.Template
componentService *services.ComponentService localDB *localdb.LocalDB
} }
func NewWebHandler(_ string, componentService *services.ComponentService) (*WebHandler, error) { func NewWebHandler(_ string, localDB *localdb.LocalDB) (*WebHandler, error) {
funcMap := template.FuncMap{ funcMap := template.FuncMap{
"sub": func(a, b int) int { return a - b }, "sub": func(a, b int) int { return a - b },
"add": func(a, b int) int { return a + b }, "add": func(a, b int) int { return a + b },
@@ -59,7 +61,7 @@ func NewWebHandler(_ string, componentService *services.ComponentService) (*WebH
templates := make(map[string]*template.Template) templates := make(map[string]*template.Template)
// Load each page template with base // 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", "partnumber_books.html"} simplePages := []string{"configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html", "config_revisions.html", "partnumber_books.html"}
for _, page := range simplePages { for _, page := range simplePages {
var tmpl *template.Template var tmpl *template.Template
var err error var err error
@@ -104,8 +106,8 @@ func NewWebHandler(_ string, componentService *services.ComponentService) (*WebH
} }
return &WebHandler{ return &WebHandler{
templates: templates, templates: templates,
componentService: componentService, localDB: localDB,
}, nil }, nil
} }
@@ -113,12 +115,14 @@ func (h *WebHandler) render(c *gin.Context, name string, data gin.H) {
c.Header("Content-Type", "text/html; charset=utf-8") c.Header("Content-Type", "text/html; charset=utf-8")
tmpl, ok := h.templates[name] tmpl, ok := h.templates[name]
if !ok { if !ok {
c.String(500, "Template not found: %s", name) _ = c.Error(fmt.Errorf("template %q not found", name))
c.String(500, "Template error")
return return
} }
// Execute the page template which will use base // Execute the page template which will use base
if err := tmpl.ExecuteTemplate(c.Writer, name, data); err != nil { if err := tmpl.ExecuteTemplate(c.Writer, name, data); err != nil {
c.String(500, "Template error: %v", err) _ = c.Error(err)
c.String(500, "Template error")
} }
} }
@@ -128,36 +132,28 @@ func (h *WebHandler) Index(c *gin.Context) {
} }
func (h *WebHandler) Configurator(c *gin.Context) { func (h *WebHandler) Configurator(c *gin.Context) {
categories, _ := h.componentService.GetCategories()
uuid := c.Query("uuid") uuid := c.Query("uuid")
categories, _ := h.localCategories()
filter := repository.ComponentFilter{} components, total, err := h.localDB.ListComponents(localdb.ComponentFilter{}, 0, 20)
result, err := h.componentService.List(filter, 1, 20)
data := gin.H{ data := gin.H{
"ActivePage": "configurator", "ActivePage": "configurator",
"Categories": categories, "Categories": categories,
"Components": []interface{}{}, "Components": []localComponentView{},
"Total": int64(0), "Total": int64(0),
"Page": 1, "Page": 1,
"PerPage": 20, "PerPage": 20,
"ConfigUUID": uuid, "ConfigUUID": uuid,
} }
if err == nil && result != nil { if err == nil {
data["Components"] = result.Components data["Components"] = toLocalComponentViews(components)
data["Total"] = result.Total data["Total"] = total
data["Page"] = result.Page
data["PerPage"] = result.PerPage
} }
h.render(c, "index.html", data) h.render(c, "index.html", data)
} }
func (h *WebHandler) Login(c *gin.Context) {
h.render(c, "login.html", nil)
}
func (h *WebHandler) Configs(c *gin.Context) { func (h *WebHandler) Configs(c *gin.Context) {
h.render(c, "configs.html", gin.H{"ActivePage": "configs"}) h.render(c, "configs.html", gin.H{"ActivePage": "configs"})
} }
@@ -196,25 +192,30 @@ func (h *WebHandler) PartnumberBooks(c *gin.Context) {
func (h *WebHandler) ComponentsPartial(c *gin.Context) { func (h *WebHandler) ComponentsPartial(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1")) page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
if page < 1 {
page = 1
}
filter := repository.ComponentFilter{ filter := localdb.ComponentFilter{
Category: c.Query("category"), Category: c.Query("category"),
Search: c.Query("search"), Search: c.Query("search"),
} }
if c.Query("has_price") == "true" {
filter.HasPrice = true
}
offset := (page - 1) * 20
data := gin.H{ data := gin.H{
"Components": []interface{}{}, "Components": []localComponentView{},
"Total": int64(0), "Total": int64(0),
"Page": page, "Page": page,
"PerPage": 20, "PerPage": 20,
} }
result, err := h.componentService.List(filter, page, 20) components, total, err := h.localDB.ListComponents(filter, offset, 20)
if err == nil && result != nil { if err == nil {
data["Components"] = result.Components data["Components"] = toLocalComponentViews(components)
data["Total"] = result.Total data["Total"] = total
data["Page"] = result.Page
data["PerPage"] = result.PerPage
} }
c.Header("Content-Type", "text/html; charset=utf-8") c.Header("Content-Type", "text/html; charset=utf-8")
@@ -222,3 +223,46 @@ func (h *WebHandler) ComponentsPartial(c *gin.Context) {
tmpl.ExecuteTemplate(c.Writer, "components_list.html", data) tmpl.ExecuteTemplate(c.Writer, "components_list.html", data)
} }
} }
type localComponentView struct {
LotName string
Description string
Category string
CategoryName string
Model string
CurrentPrice *float64
}
func toLocalComponentViews(items []localdb.LocalComponent) []localComponentView {
result := make([]localComponentView, 0, len(items))
for _, item := range items {
result = append(result, localComponentView{
LotName: item.LotName,
Description: item.LotDescription,
Category: item.Category,
CategoryName: item.Category,
Model: item.Model,
})
}
return result
}
func (h *WebHandler) localCategories() ([]models.Category, error) {
codes, err := h.localDB.GetLocalComponentCategories()
if err != nil || len(codes) == 0 {
return []models.Category{}, err
}
categories := make([]models.Category, 0, len(codes))
for _, code := range codes {
trimmed := strings.TrimSpace(code)
if trimmed == "" {
continue
}
categories = append(categories, models.Category{
Code: trimmed,
Name: trimmed,
})
}
return categories, nil
}
+47
View File
@@ -0,0 +1,47 @@
package handlers
import (
"errors"
"html/template"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
)
func TestWebHandlerRenderHidesTemplateExecutionError(t *testing.T) {
gin.SetMode(gin.TestMode)
tmpl := template.Must(template.New("broken.html").Funcs(template.FuncMap{
"boom": func() (string, error) {
return "", errors.New("secret template failure")
},
}).Parse(`{{define "broken.html"}}{{boom}}{{end}}`))
handler := &WebHandler{
templates: map[string]*template.Template{
"broken.html": tmpl,
},
}
rec := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(rec)
ctx.Request = httptest.NewRequest(http.MethodGet, "/broken", nil)
handler.render(ctx, "broken.html", gin.H{})
if rec.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", rec.Code)
}
if body := strings.TrimSpace(rec.Body.String()); body != "Template error" {
t.Fatalf("expected generic template error, got %q", body)
}
if len(ctx.Errors) != 1 {
t.Fatalf("expected logged template error, got %d", len(ctx.Errors))
}
if !strings.Contains(ctx.Errors.String(), "secret template failure") {
t.Fatalf("expected original error in gin context, got %q", ctx.Errors.String())
}
}
@@ -0,0 +1,97 @@
package localdb
import (
"testing"
"git.mchus.pro/mchus/quoteforge/internal/models"
)
func TestConfigurationConvertersPreserveBusinessFields(t *testing.T) {
estimateID := uint(11)
warehouseID := uint(22)
competitorID := uint(33)
cfg := &models.Configuration{
UUID: "cfg-1",
OwnerUsername: "tester",
Name: "Config",
PricelistID: &estimateID,
WarehousePricelistID: &warehouseID,
CompetitorPricelistID: &competitorID,
DisablePriceRefresh: true,
OnlyInStock: true,
}
local := ConfigurationToLocal(cfg)
if local.WarehousePricelistID == nil || *local.WarehousePricelistID != warehouseID {
t.Fatalf("warehouse pricelist lost in ConfigurationToLocal: %+v", local.WarehousePricelistID)
}
if local.CompetitorPricelistID == nil || *local.CompetitorPricelistID != competitorID {
t.Fatalf("competitor pricelist lost in ConfigurationToLocal: %+v", local.CompetitorPricelistID)
}
if !local.DisablePriceRefresh {
t.Fatalf("disable_price_refresh lost in ConfigurationToLocal")
}
back := LocalToConfiguration(local)
if back.WarehousePricelistID == nil || *back.WarehousePricelistID != warehouseID {
t.Fatalf("warehouse pricelist lost in LocalToConfiguration: %+v", back.WarehousePricelistID)
}
if back.CompetitorPricelistID == nil || *back.CompetitorPricelistID != competitorID {
t.Fatalf("competitor pricelist lost in LocalToConfiguration: %+v", back.CompetitorPricelistID)
}
if !back.DisablePriceRefresh {
t.Fatalf("disable_price_refresh lost in LocalToConfiguration")
}
}
func TestConfigurationSnapshotPreservesBusinessFields(t *testing.T) {
estimateID := uint(11)
warehouseID := uint(22)
competitorID := uint(33)
cfg := &LocalConfiguration{
UUID: "cfg-1",
Name: "Config",
PricelistID: &estimateID,
WarehousePricelistID: &warehouseID,
CompetitorPricelistID: &competitorID,
DisablePriceRefresh: true,
OnlyInStock: true,
VendorSpec: VendorSpec{
{
SortOrder: 10,
VendorPartnumber: "PN-1",
Quantity: 1,
LotMappings: []VendorSpecLotMapping{
{LotName: "LOT_A", QuantityPerPN: 2},
},
},
},
}
raw, err := BuildConfigurationSnapshot(cfg)
if err != nil {
t.Fatalf("BuildConfigurationSnapshot: %v", err)
}
decoded, err := DecodeConfigurationSnapshot(raw)
if err != nil {
t.Fatalf("DecodeConfigurationSnapshot: %v", err)
}
if decoded.WarehousePricelistID == nil || *decoded.WarehousePricelistID != warehouseID {
t.Fatalf("warehouse pricelist lost in snapshot: %+v", decoded.WarehousePricelistID)
}
if decoded.CompetitorPricelistID == nil || *decoded.CompetitorPricelistID != competitorID {
t.Fatalf("competitor pricelist lost in snapshot: %+v", decoded.CompetitorPricelistID)
}
if !decoded.DisablePriceRefresh {
t.Fatalf("disable_price_refresh lost in snapshot")
}
if len(decoded.VendorSpec) != 1 || decoded.VendorSpec[0].VendorPartnumber != "PN-1" {
t.Fatalf("vendor_spec lost in snapshot: %+v", decoded.VendorSpec)
}
if len(decoded.VendorSpec[0].LotMappings) != 1 || decoded.VendorSpec[0].LotMappings[0].LotName != "LOT_A" {
t.Fatalf("lot mappings lost in snapshot: %+v", decoded.VendorSpec)
}
}
+130 -44
View File
@@ -18,32 +18,32 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
} }
local := &LocalConfiguration{ local := &LocalConfiguration{
UUID: cfg.UUID, UUID: cfg.UUID,
ProjectUUID: cfg.ProjectUUID, ProjectUUID: cfg.ProjectUUID,
IsActive: true, IsActive: true,
Name: cfg.Name, Name: cfg.Name,
Items: items, Items: items,
TotalPrice: cfg.TotalPrice, TotalPrice: cfg.TotalPrice,
CustomPrice: cfg.CustomPrice, CustomPrice: cfg.CustomPrice,
Notes: cfg.Notes, Notes: cfg.Notes,
IsTemplate: cfg.IsTemplate, IsTemplate: cfg.IsTemplate,
ServerCount: cfg.ServerCount, ServerCount: cfg.ServerCount,
ServerModel: cfg.ServerModel, ServerModel: cfg.ServerModel,
SupportCode: cfg.SupportCode, SupportCode: cfg.SupportCode,
Article: cfg.Article, Article: cfg.Article,
PricelistID: cfg.PricelistID, PricelistID: cfg.PricelistID,
OnlyInStock: cfg.OnlyInStock, WarehousePricelistID: cfg.WarehousePricelistID,
Line: cfg.Line, CompetitorPricelistID: cfg.CompetitorPricelistID,
PriceUpdatedAt: cfg.PriceUpdatedAt, VendorSpec: modelVendorSpecToLocal(cfg.VendorSpec),
CreatedAt: cfg.CreatedAt, DisablePriceRefresh: cfg.DisablePriceRefresh,
UpdatedAt: time.Now(), OnlyInStock: cfg.OnlyInStock,
SyncStatus: "pending", Line: cfg.Line,
OriginalUserID: derefUint(cfg.UserID), PriceUpdatedAt: cfg.PriceUpdatedAt,
OriginalUsername: cfg.OwnerUsername, CreatedAt: cfg.CreatedAt,
} UpdatedAt: time.Now(),
SyncStatus: "pending",
if local.OriginalUsername == "" && cfg.User != nil { OriginalUserID: derefUint(cfg.UserID),
local.OriginalUsername = cfg.User.Username OriginalUsername: cfg.OwnerUsername,
} }
if cfg.ID > 0 { if cfg.ID > 0 {
@@ -66,24 +66,28 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
} }
cfg := &models.Configuration{ cfg := &models.Configuration{
UUID: local.UUID, UUID: local.UUID,
OwnerUsername: local.OriginalUsername, OwnerUsername: local.OriginalUsername,
ProjectUUID: local.ProjectUUID, ProjectUUID: local.ProjectUUID,
Name: local.Name, Name: local.Name,
Items: items, Items: items,
TotalPrice: local.TotalPrice, TotalPrice: local.TotalPrice,
CustomPrice: local.CustomPrice, CustomPrice: local.CustomPrice,
Notes: local.Notes, Notes: local.Notes,
IsTemplate: local.IsTemplate, IsTemplate: local.IsTemplate,
ServerCount: local.ServerCount, ServerCount: local.ServerCount,
ServerModel: local.ServerModel, ServerModel: local.ServerModel,
SupportCode: local.SupportCode, SupportCode: local.SupportCode,
Article: local.Article, Article: local.Article,
PricelistID: local.PricelistID, PricelistID: local.PricelistID,
OnlyInStock: local.OnlyInStock, WarehousePricelistID: local.WarehousePricelistID,
Line: local.Line, CompetitorPricelistID: local.CompetitorPricelistID,
PriceUpdatedAt: local.PriceUpdatedAt, VendorSpec: localVendorSpecToModel(local.VendorSpec),
CreatedAt: local.CreatedAt, DisablePriceRefresh: local.DisablePriceRefresh,
OnlyInStock: local.OnlyInStock,
Line: local.Line,
PriceUpdatedAt: local.PriceUpdatedAt,
CreatedAt: local.CreatedAt,
} }
if local.ServerID != nil { if local.ServerID != nil {
@@ -107,6 +111,88 @@ func derefUint(v *uint) uint {
return *v return *v
} }
func modelVendorSpecToLocal(spec models.VendorSpec) VendorSpec {
if len(spec) == 0 {
return nil
}
out := make(VendorSpec, 0, len(spec))
for _, item := range spec {
row := VendorSpecItem{
SortOrder: item.SortOrder,
VendorPartnumber: item.VendorPartnumber,
Quantity: item.Quantity,
Description: item.Description,
UnitPrice: item.UnitPrice,
TotalPrice: item.TotalPrice,
ResolvedLotName: item.ResolvedLotName,
ResolutionSource: item.ResolutionSource,
ManualLotSuggestion: item.ManualLotSuggestion,
LotQtyPerPN: item.LotQtyPerPN,
}
if len(item.LotAllocations) > 0 {
row.LotAllocations = make([]VendorSpecLotAllocation, 0, len(item.LotAllocations))
for _, alloc := range item.LotAllocations {
row.LotAllocations = append(row.LotAllocations, VendorSpecLotAllocation{
LotName: alloc.LotName,
Quantity: alloc.Quantity,
})
}
}
if len(item.LotMappings) > 0 {
row.LotMappings = make([]VendorSpecLotMapping, 0, len(item.LotMappings))
for _, mapping := range item.LotMappings {
row.LotMappings = append(row.LotMappings, VendorSpecLotMapping{
LotName: mapping.LotName,
QuantityPerPN: mapping.QuantityPerPN,
})
}
}
out = append(out, row)
}
return out
}
func localVendorSpecToModel(spec VendorSpec) models.VendorSpec {
if len(spec) == 0 {
return nil
}
out := make(models.VendorSpec, 0, len(spec))
for _, item := range spec {
row := models.VendorSpecItem{
SortOrder: item.SortOrder,
VendorPartnumber: item.VendorPartnumber,
Quantity: item.Quantity,
Description: item.Description,
UnitPrice: item.UnitPrice,
TotalPrice: item.TotalPrice,
ResolvedLotName: item.ResolvedLotName,
ResolutionSource: item.ResolutionSource,
ManualLotSuggestion: item.ManualLotSuggestion,
LotQtyPerPN: item.LotQtyPerPN,
}
if len(item.LotAllocations) > 0 {
row.LotAllocations = make([]models.VendorSpecLotAllocation, 0, len(item.LotAllocations))
for _, alloc := range item.LotAllocations {
row.LotAllocations = append(row.LotAllocations, models.VendorSpecLotAllocation{
LotName: alloc.LotName,
Quantity: alloc.Quantity,
})
}
}
if len(item.LotMappings) > 0 {
row.LotMappings = make([]models.VendorSpecLotMapping, 0, len(item.LotMappings))
for _, mapping := range item.LotMappings {
row.LotMappings = append(row.LotMappings, models.VendorSpecLotMapping{
LotName: mapping.LotName,
QuantityPerPN: mapping.QuantityPerPN,
})
}
}
out = append(out, row)
}
return out
}
func ProjectToLocal(project *models.Project) *LocalProject { func ProjectToLocal(project *models.Project) *LocalProject {
local := &LocalProject{ local := &LocalProject{
UUID: project.UUID, UUID: project.UUID,
+136 -10
View File
@@ -7,19 +7,104 @@ import (
"crypto/sha256" "crypto/sha256"
"encoding/base64" "encoding/base64"
"errors" "errors"
"fmt"
"io" "io"
"os" "os"
"path/filepath"
"strings"
"git.mchus.pro/mchus/quoteforge/internal/appstate"
) )
// getEncryptionKey derives a 32-byte key from environment variable or machine ID const encryptionKeyFileName = "local_encryption.key"
func getEncryptionKey() []byte {
// getEncryptionKey resolves the active encryption key.
// Preference order:
// 1. QUOTEFORGE_ENCRYPTION_KEY env var
// 2. application-managed random key file in the user state directory
func getEncryptionKey() ([]byte, error) {
key := os.Getenv("QUOTEFORGE_ENCRYPTION_KEY") key := os.Getenv("QUOTEFORGE_ENCRYPTION_KEY")
if key == "" { if key != "" {
// Fallback to a machine-based key (hostname + fixed salt) hash := sha256.Sum256([]byte(key))
hostname, _ := os.Hostname() return hash[:], nil
key = hostname + "quoteforge-salt-2024"
} }
// Hash to get exactly 32 bytes for AES-256
stateDir, err := resolveEncryptionStateDir()
if err != nil {
return nil, fmt.Errorf("resolve encryption state dir: %w", err)
}
return loadOrCreateEncryptionKey(filepath.Join(stateDir, encryptionKeyFileName))
}
func resolveEncryptionStateDir() (string, error) {
configPath, err := appstate.ResolveConfigPath("")
if err != nil {
return "", err
}
return filepath.Dir(configPath), nil
}
func loadOrCreateEncryptionKey(path string) ([]byte, error) {
if data, err := os.ReadFile(path); err == nil {
return parseEncryptionKeyFile(data)
} else if !errors.Is(err, os.ErrNotExist) {
return nil, fmt.Errorf("read encryption key: %w", err)
}
if err := os.MkdirAll(filepath.Dir(path), 0700); err != nil {
return nil, fmt.Errorf("create encryption key dir: %w", err)
}
raw := make([]byte, 32)
if _, err := io.ReadFull(rand.Reader, raw); err != nil {
return nil, fmt.Errorf("generate encryption key: %w", err)
}
encoded := base64.StdEncoding.EncodeToString(raw)
if err := writeKeyFile(path, []byte(encoded+"\n")); err != nil {
if errors.Is(err, os.ErrExist) {
data, readErr := os.ReadFile(path)
if readErr != nil {
return nil, fmt.Errorf("read concurrent encryption key: %w", readErr)
}
return parseEncryptionKeyFile(data)
}
return nil, err
}
return raw, nil
}
func writeKeyFile(path string, data []byte) error {
file, err := os.OpenFile(path, os.O_WRONLY|os.O_CREATE|os.O_EXCL, 0600)
if err != nil {
return err
}
defer file.Close()
if _, err := file.Write(data); err != nil {
return err
}
return file.Sync()
}
func parseEncryptionKeyFile(data []byte) ([]byte, error) {
trimmed := strings.TrimSpace(string(data))
decoded, err := base64.StdEncoding.DecodeString(trimmed)
if err != nil {
return nil, fmt.Errorf("decode encryption key file: %w", err)
}
if len(decoded) != 32 {
return nil, fmt.Errorf("invalid encryption key length: %d", len(decoded))
}
return decoded, nil
}
func getLegacyEncryptionKey() []byte {
hostname, _ := os.Hostname()
key := hostname + "quoteforge-salt-2024"
hash := sha256.Sum256([]byte(key)) hash := sha256.Sum256([]byte(key))
return hash[:] return hash[:]
} }
@@ -30,7 +115,10 @@ func Encrypt(plaintext string) (string, error) {
return "", nil return "", nil
} }
key := getEncryptionKey() key, err := getEncryptionKey()
if err != nil {
return "", err
}
block, err := aes.NewCipher(key) block, err := aes.NewCipher(key)
if err != nil { if err != nil {
return "", err return "", err
@@ -56,12 +144,50 @@ func Decrypt(ciphertext string) (string, error) {
return "", nil return "", nil
} }
key := getEncryptionKey() key, err := getEncryptionKey()
data, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil { if err != nil {
return "", err return "", err
} }
plaintext, legacy, err := decryptWithKeys(ciphertext, key, getLegacyEncryptionKey())
if err != nil {
return "", err
}
_ = legacy
return plaintext, nil
}
func DecryptWithMetadata(ciphertext string) (string, bool, error) {
if ciphertext == "" {
return "", false, nil
}
key, err := getEncryptionKey()
if err != nil {
return "", false, err
}
return decryptWithKeys(ciphertext, key, getLegacyEncryptionKey())
}
func decryptWithKeys(ciphertext string, primaryKey, legacyKey []byte) (string, bool, error) {
data, err := base64.StdEncoding.DecodeString(ciphertext)
if err != nil {
return "", false, err
}
plaintext, err := decryptWithKey(data, primaryKey)
if err == nil {
return plaintext, false, nil
}
legacyPlaintext, legacyErr := decryptWithKey(data, legacyKey)
if legacyErr == nil {
return legacyPlaintext, true, nil
}
return "", false, err
}
func decryptWithKey(data, key []byte) (string, error) {
block, err := aes.NewCipher(key) block, err := aes.NewCipher(key)
if err != nil { if err != nil {
return "", err return "", err
+97
View File
@@ -0,0 +1,97 @@
package localdb
import (
"crypto/aes"
"crypto/cipher"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"io"
"os"
"path/filepath"
"testing"
)
func TestEncryptCreatesPersistentKeyFile(t *testing.T) {
stateDir := t.TempDir()
t.Setenv("QFS_STATE_DIR", stateDir)
t.Setenv("QUOTEFORGE_ENCRYPTION_KEY", "")
ciphertext, err := Encrypt("secret-password")
if err != nil {
t.Fatalf("encrypt: %v", err)
}
if ciphertext == "" {
t.Fatal("expected ciphertext")
}
keyPath := filepath.Join(stateDir, encryptionKeyFileName)
info, err := os.Stat(keyPath)
if err != nil {
t.Fatalf("stat key file: %v", err)
}
if info.Mode().Perm() != 0600 {
t.Fatalf("expected 0600 key file, got %v", info.Mode().Perm())
}
}
func TestDecryptMigratesLegacyCiphertext(t *testing.T) {
stateDir := t.TempDir()
t.Setenv("QFS_STATE_DIR", stateDir)
t.Setenv("QUOTEFORGE_ENCRYPTION_KEY", "")
legacyCiphertext := encryptWithKeyForTest(t, getLegacyEncryptionKey(), "legacy-password")
plaintext, migrated, err := DecryptWithMetadata(legacyCiphertext)
if err != nil {
t.Fatalf("decrypt legacy: %v", err)
}
if plaintext != "legacy-password" {
t.Fatalf("unexpected plaintext: %q", plaintext)
}
if !migrated {
t.Fatal("expected legacy ciphertext to require migration")
}
currentCiphertext, err := Encrypt("legacy-password")
if err != nil {
t.Fatalf("encrypt current: %v", err)
}
plaintext, migrated, err = DecryptWithMetadata(currentCiphertext)
if err != nil {
t.Fatalf("decrypt current: %v", err)
}
if migrated {
t.Fatal("did not expect current ciphertext to require migration")
}
}
func encryptWithKeyForTest(t *testing.T, key []byte, plaintext string) string {
t.Helper()
block, err := aes.NewCipher(key)
if err != nil {
t.Fatalf("new cipher: %v", err)
}
gcm, err := cipher.NewGCM(block)
if err != nil {
t.Fatalf("new gcm: %v", err)
}
nonce := make([]byte, gcm.NonceSize())
if _, err := io.ReadFull(rand.Reader, nonce); err != nil {
t.Fatalf("read nonce: %v", err)
}
ciphertext := gcm.Seal(nonce, nonce, []byte(plaintext), nil)
return base64.StdEncoding.EncodeToString(ciphertext)
}
func TestLegacyEncryptionKeyRemainsDeterministic(t *testing.T) {
hostname, _ := os.Hostname()
expected := sha256.Sum256([]byte(hostname + "quoteforge-salt-2024"))
actual := getLegacyEncryptionKey()
if string(actual) != string(expected[:]) {
t.Fatal("legacy key derivation changed")
}
}
+280
View File
@@ -5,7 +5,10 @@ import (
"testing" "testing"
"time" "time"
"github.com/glebarez/sqlite"
"github.com/google/uuid" "github.com/google/uuid"
"gorm.io/gorm"
"gorm.io/gorm/logger"
) )
func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) { func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) {
@@ -313,3 +316,280 @@ func TestRunLocalMigrationsBackfillsConfigurationLineNo(t *testing.T) {
t.Fatalf("expected line_no [10,20], got [%d,%d]", rows[0].Line, rows[1].Line) t.Fatalf("expected line_no [10,20], got [%d,%d]", rows[0].Line, rows[1].Line)
} }
} }
func TestRunLocalMigrationsDeduplicatesCanonicalPartnumberCatalog(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "partnumber_catalog_dedup.db")
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
firstLots := LocalPartnumberBookLots{
{LotName: "LOT-A", Qty: 1},
}
secondLots := LocalPartnumberBookLots{
{LotName: "LOT-B", Qty: 2},
}
if err := db.Exec(`
CREATE TABLE local_partnumber_book_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
partnumber TEXT NOT NULL,
lots_json TEXT NOT NULL,
description TEXT
)
`).Error; err != nil {
t.Fatalf("create dirty local_partnumber_book_items: %v", err)
}
if err := db.Create(&LocalPartnumberBookItem{
Partnumber: "PN-001",
LotsJSON: firstLots,
Description: "",
}).Error; err != nil {
t.Fatalf("insert first duplicate row: %v", err)
}
if err := db.Create(&LocalPartnumberBookItem{
Partnumber: "PN-001",
LotsJSON: secondLots,
Description: "Canonical description",
}).Error; err != nil {
t.Fatalf("insert second duplicate row: %v", err)
}
if err := migrateLocalPartnumberBookCatalog(db); err != nil {
t.Fatalf("migrate local partnumber catalog: %v", err)
}
var items []LocalPartnumberBookItem
if err := db.Order("partnumber ASC").Find(&items).Error; err != nil {
t.Fatalf("load migrated partnumber items: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 deduplicated item, got %d", len(items))
}
if items[0].Partnumber != "PN-001" {
t.Fatalf("unexpected partnumber: %s", items[0].Partnumber)
}
if items[0].Description != "Canonical description" {
t.Fatalf("expected merged description, got %q", items[0].Description)
}
if len(items[0].LotsJSON) != 2 {
t.Fatalf("expected merged lots from duplicates, got %d", len(items[0].LotsJSON))
}
var duplicateCount int64
if err := db.Model(&LocalPartnumberBookItem{}).
Where("partnumber = ?", "PN-001").
Count(&duplicateCount).Error; err != nil {
t.Fatalf("count deduplicated partnumber: %v", err)
}
if duplicateCount != 1 {
t.Fatalf("expected unique partnumber row after migration, got %d", duplicateCount)
}
}
func TestSanitizeLocalPartnumberBookCatalogRemovesRowsWithoutPartnumber(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "sanitize_partnumber_catalog.db")
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.Exec(`
CREATE TABLE local_partnumber_book_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
partnumber TEXT NULL,
lots_json TEXT NOT NULL,
description TEXT
)
`).Error; err != nil {
t.Fatalf("create local_partnumber_book_items: %v", err)
}
if err := db.Exec(`
INSERT INTO local_partnumber_book_items (partnumber, lots_json, description) VALUES
(NULL, '[]', 'null pn'),
('', '[]', 'empty pn'),
('PN-OK', '[]', 'valid pn')
`).Error; err != nil {
t.Fatalf("seed local_partnumber_book_items: %v", err)
}
if err := sanitizeLocalPartnumberBookCatalog(db); err != nil {
t.Fatalf("sanitize local partnumber catalog: %v", err)
}
var items []LocalPartnumberBookItem
if err := db.Order("id ASC").Find(&items).Error; err != nil {
t.Fatalf("load sanitized items: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 valid item after sanitize, got %d", len(items))
}
if items[0].Partnumber != "PN-OK" {
t.Fatalf("expected remaining partnumber PN-OK, got %q", items[0].Partnumber)
}
}
func TestNewMigratesLegacyPartnumberBookCatalogBeforeAutoMigrate(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "legacy_partnumber_catalog.db")
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.Exec(`
CREATE TABLE local_partnumber_book_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
partnumber TEXT NOT NULL UNIQUE,
lots_json TEXT NOT NULL,
is_primary_pn INTEGER NOT NULL DEFAULT 0,
description TEXT
)
`).Error; err != nil {
t.Fatalf("create legacy local_partnumber_book_items: %v", err)
}
if err := db.Exec(`
INSERT INTO local_partnumber_book_items (partnumber, lots_json, is_primary_pn, description)
VALUES ('PN-001', '[{"lot_name":"CPU_A","qty":1}]', 0, 'Legacy row')
`).Error; err != nil {
t.Fatalf("seed legacy local_partnumber_book_items: %v", err)
}
local, err := New(dbPath)
if err != nil {
t.Fatalf("open localdb with legacy catalog: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
var columns []struct {
Name string `gorm:"column:name"`
}
if err := local.DB().Raw(`SELECT name FROM pragma_table_info('local_partnumber_book_items')`).Scan(&columns).Error; err != nil {
t.Fatalf("load local_partnumber_book_items columns: %v", err)
}
for _, column := range columns {
if column.Name == "is_primary_pn" {
t.Fatalf("expected legacy is_primary_pn column to be removed before automigrate")
}
}
var items []LocalPartnumberBookItem
if err := local.DB().Find(&items).Error; err != nil {
t.Fatalf("load migrated local_partnumber_book_items: %v", err)
}
if len(items) != 1 || items[0].Partnumber != "PN-001" {
t.Fatalf("unexpected migrated rows: %#v", items)
}
}
func TestNewRecoversBrokenPartnumberBookCatalogCache(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "broken_partnumber_catalog.db")
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.Exec(`
CREATE TABLE local_partnumber_book_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
partnumber TEXT NOT NULL UNIQUE,
lots_json TEXT NOT NULL,
description TEXT
)
`).Error; err != nil {
t.Fatalf("create broken local_partnumber_book_items: %v", err)
}
if err := db.Exec(`
INSERT INTO local_partnumber_book_items (partnumber, lots_json, description)
VALUES ('PN-001', '{not-json}', 'Broken cache row')
`).Error; err != nil {
t.Fatalf("seed broken local_partnumber_book_items: %v", err)
}
local, err := New(dbPath)
if err != nil {
t.Fatalf("open localdb with broken catalog cache: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
var count int64
if err := local.DB().Model(&LocalPartnumberBookItem{}).Count(&count).Error; err != nil {
t.Fatalf("count recovered local_partnumber_book_items: %v", err)
}
if count != 0 {
t.Fatalf("expected empty recovered local_partnumber_book_items, got %d rows", count)
}
var quarantineTables []struct {
Name string `gorm:"column:name"`
}
if err := local.DB().Raw(`
SELECT name
FROM sqlite_master
WHERE type = 'table' AND name LIKE 'local_partnumber_book_items_broken_%'
`).Scan(&quarantineTables).Error; err != nil {
t.Fatalf("load quarantine tables: %v", err)
}
if len(quarantineTables) != 1 {
t.Fatalf("expected one quarantined broken catalog table, got %d", len(quarantineTables))
}
}
func TestCleanupStaleReadOnlyCacheTempTablesDropsShadowTempWhenBaseExists(t *testing.T) {
dbPath := filepath.Join(t.TempDir(), "stale_cache_temp.db")
db, err := gorm.Open(sqlite.Open(dbPath), &gorm.Config{
Logger: logger.Default.LogMode(logger.Silent),
})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.Exec(`
CREATE TABLE local_pricelist_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
pricelist_id INTEGER NOT NULL,
partnumber TEXT,
brand TEXT NOT NULL DEFAULT '',
lot_name TEXT NOT NULL,
description TEXT,
price REAL NOT NULL DEFAULT 0,
quantity INTEGER NOT NULL DEFAULT 0,
reserve INTEGER NOT NULL DEFAULT 0,
available_qty REAL,
partnumbers TEXT,
lot_category TEXT,
created_at DATETIME,
updated_at DATETIME
)
`).Error; err != nil {
t.Fatalf("create local_pricelist_items: %v", err)
}
if err := db.Exec(`
CREATE TABLE local_pricelist_items__temp (
id INTEGER PRIMARY KEY AUTOINCREMENT,
legacy TEXT
)
`).Error; err != nil {
t.Fatalf("create local_pricelist_items__temp: %v", err)
}
if err := cleanupStaleReadOnlyCacheTempTables(db); err != nil {
t.Fatalf("cleanup stale read-only cache temp tables: %v", err)
}
if db.Migrator().HasTable("local_pricelist_items__temp") {
t.Fatalf("expected stale temp table to be dropped")
}
if !db.Migrator().HasTable("local_pricelist_items") {
t.Fatalf("expected base local_pricelist_items table to remain")
}
}
+328 -57
View File
@@ -1,6 +1,7 @@
package localdb package localdb
import ( import (
"encoding/json"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
@@ -42,6 +43,14 @@ type LocalDB struct {
path string path string
} }
var localReadOnlyCacheTables = []string{
"local_pricelist_items",
"local_pricelists",
"local_components",
"local_partnumber_book_items",
"local_partnumber_books",
}
// ResetData clears local data tables while keeping connection settings. // ResetData clears local data tables while keeping connection settings.
// It does not drop schema or connection_settings. // It does not drop schema or connection_settings.
func ResetData(dbPath string) error { func ResetData(dbPath string) error {
@@ -70,7 +79,6 @@ func ResetData(dbPath string) error {
"local_pricelists", "local_pricelists",
"local_pricelist_items", "local_pricelist_items",
"local_components", "local_components",
"local_remote_migrations_applied",
"local_sync_guard_state", "local_sync_guard_state",
"pending_changes", "pending_changes",
"app_settings", "app_settings",
@@ -111,6 +119,12 @@ func New(dbPath string) (*LocalDB, error) {
if err := ensureLocalProjectsTable(db); err != nil { if err := ensureLocalProjectsTable(db); err != nil {
return nil, fmt.Errorf("ensure local_projects table: %w", err) return nil, fmt.Errorf("ensure local_projects table: %w", err)
} }
if err := prepareLocalPartnumberBookCatalog(db); err != nil {
return nil, fmt.Errorf("prepare local partnumber book catalog: %w", err)
}
if err := cleanupStaleReadOnlyCacheTempTables(db); err != nil {
return nil, fmt.Errorf("cleanup stale read-only cache temp tables: %w", err)
}
// Preflight: ensure local_projects has non-null UUIDs before AutoMigrate rebuilds tables. // Preflight: ensure local_projects has non-null UUIDs before AutoMigrate rebuilds tables.
if db.Migrator().HasTable(&LocalProject{}) { if db.Migrator().HasTable(&LocalProject{}) {
@@ -131,24 +145,28 @@ func New(dbPath string) (*LocalDB, error) {
} }
// Auto-migrate all local tables // Auto-migrate all local tables
if err := db.AutoMigrate( if err := autoMigrateLocalSchema(db); err != nil {
&ConnectionSettings{}, if recovered, recoveryErr := recoverFromReadOnlyCacheInitFailure(db, err); recoveryErr != nil {
&LocalConfiguration{}, return nil, fmt.Errorf("migrating sqlite database: %w (recovery failed: %v)", err, recoveryErr)
&LocalConfigurationVersion{}, } else if !recovered {
&LocalPricelist{}, return nil, fmt.Errorf("migrating sqlite database: %w", err)
&LocalPricelistItem{}, }
&LocalComponent{}, if err := autoMigrateLocalSchema(db); err != nil {
&AppSetting{}, return nil, fmt.Errorf("migrating sqlite database after cache recovery: %w", err)
&LocalRemoteMigrationApplied{}, }
&LocalSyncGuardState{}, }
&PendingChange{}, if err := ensureLocalPartnumberBookItemTable(db); err != nil {
&LocalPartnumberBook{}, return nil, fmt.Errorf("ensure local partnumber book item table: %w", err)
&LocalPartnumberBookItem{},
); err != nil {
return nil, fmt.Errorf("migrating sqlite database: %w", err)
} }
if err := runLocalMigrations(db); err != nil { if err := runLocalMigrations(db); err != nil {
return nil, fmt.Errorf("running local sqlite migrations: %w", err) if recovered, recoveryErr := recoverFromReadOnlyCacheInitFailure(db, err); recoveryErr != nil {
return nil, fmt.Errorf("running local sqlite migrations: %w (recovery failed: %v)", err, recoveryErr)
} else if !recovered {
return nil, fmt.Errorf("running local sqlite migrations: %w", err)
}
if err := runLocalMigrations(db); err != nil {
return nil, fmt.Errorf("running local sqlite migrations after cache recovery: %w", err)
}
} }
slog.Info("local SQLite database initialized", "path", dbPath) slog.Info("local SQLite database initialized", "path", dbPath)
@@ -191,6 +209,282 @@ CREATE TABLE local_projects (
return nil return nil
} }
func autoMigrateLocalSchema(db *gorm.DB) error {
return db.AutoMigrate(
&ConnectionSettings{},
&LocalConfiguration{},
&LocalConfigurationVersion{},
&LocalPricelist{},
&LocalPricelistItem{},
&LocalComponent{},
&AppSetting{},
&LocalSyncGuardState{},
&PendingChange{},
&LocalPartnumberBook{},
)
}
func sanitizeLocalPartnumberBookCatalog(db *gorm.DB) error {
if !db.Migrator().HasTable(&LocalPartnumberBookItem{}) {
return nil
}
// Old local databases may contain partially migrated catalog rows with NULL/empty
// partnumber values. SQLite table rebuild during AutoMigrate fails on such rows once
// the schema enforces NOT NULL, so remove them before AutoMigrate touches the table.
if err := db.Exec(`
DELETE FROM local_partnumber_book_items
WHERE partnumber IS NULL OR TRIM(partnumber) = ''
`).Error; err != nil {
return err
}
return nil
}
func prepareLocalPartnumberBookCatalog(db *gorm.DB) error {
if err := cleanupStaleLocalPartnumberBookCatalogTempTable(db); err != nil {
if recoveryErr := recoverLocalPartnumberBookCatalog(db, fmt.Errorf("cleanup stale temp table: %w", err)); recoveryErr != nil {
return recoveryErr
}
return nil
}
if err := sanitizeLocalPartnumberBookCatalog(db); err != nil {
if recoveryErr := recoverLocalPartnumberBookCatalog(db, fmt.Errorf("sanitize catalog: %w", err)); recoveryErr != nil {
return recoveryErr
}
return nil
}
if err := migrateLegacyPartnumberBookCatalogBeforeAutoMigrate(db); err != nil {
if recoveryErr := recoverLocalPartnumberBookCatalog(db, fmt.Errorf("migrate legacy catalog: %w", err)); recoveryErr != nil {
return recoveryErr
}
return nil
}
if err := ensureLocalPartnumberBookItemTable(db); err != nil {
if recoveryErr := recoverLocalPartnumberBookCatalog(db, fmt.Errorf("ensure canonical catalog table: %w", err)); recoveryErr != nil {
return recoveryErr
}
return nil
}
if err := validateLocalPartnumberBookCatalog(db); err != nil {
if recoveryErr := recoverLocalPartnumberBookCatalog(db, fmt.Errorf("validate canonical catalog: %w", err)); recoveryErr != nil {
return recoveryErr
}
}
return nil
}
func cleanupStaleReadOnlyCacheTempTables(db *gorm.DB) error {
for _, tableName := range localReadOnlyCacheTables {
tempName := tableName + "__temp"
if !db.Migrator().HasTable(tempName) {
continue
}
if db.Migrator().HasTable(tableName) {
if err := db.Exec(`DROP TABLE ` + tempName).Error; err != nil {
return err
}
continue
}
if err := quarantineSQLiteTable(db, tempName, localReadOnlyCacheQuarantineTableName(tableName, "temp")); err != nil {
return err
}
}
return nil
}
func cleanupStaleLocalPartnumberBookCatalogTempTable(db *gorm.DB) error {
if !db.Migrator().HasTable("local_partnumber_book_items__temp") {
return nil
}
if db.Migrator().HasTable(&LocalPartnumberBookItem{}) {
return db.Exec(`DROP TABLE local_partnumber_book_items__temp`).Error
}
return quarantineSQLiteTable(db, "local_partnumber_book_items__temp", localPartnumberBookCatalogQuarantineTableName("temp"))
}
func migrateLegacyPartnumberBookCatalogBeforeAutoMigrate(db *gorm.DB) error {
if !db.Migrator().HasTable(&LocalPartnumberBookItem{}) {
return nil
}
// Legacy databases may still have the pre-catalog shape (`book_id`/`lot_name`) or the
// intermediate canonical shape with obsolete columns like `is_primary_pn`. Let the
// explicit rebuild logic normalize this table before GORM AutoMigrate attempts a
// table-copy migration on its own.
return migrateLocalPartnumberBookCatalog(db)
}
func ensureLocalPartnumberBookItemTable(db *gorm.DB) error {
if db.Migrator().HasTable(&LocalPartnumberBookItem{}) {
return nil
}
if err := db.Exec(`
CREATE TABLE local_partnumber_book_items (
id INTEGER PRIMARY KEY AUTOINCREMENT,
partnumber TEXT NOT NULL UNIQUE,
lots_json TEXT NOT NULL,
description TEXT
)
`).Error; err != nil {
return err
}
return db.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_partnumber_book_items_partnumber ON local_partnumber_book_items(partnumber)`).Error
}
func validateLocalPartnumberBookCatalog(db *gorm.DB) error {
if !db.Migrator().HasTable(&LocalPartnumberBookItem{}) {
return nil
}
type rawCatalogRow struct {
Partnumber string `gorm:"column:partnumber"`
LotsJSON string `gorm:"column:lots_json"`
Description string `gorm:"column:description"`
}
var rows []rawCatalogRow
if err := db.Raw(`
SELECT partnumber, lots_json, COALESCE(description, '') AS description
FROM local_partnumber_book_items
ORDER BY id ASC
`).Scan(&rows).Error; err != nil {
return fmt.Errorf("load canonical catalog rows: %w", err)
}
seen := make(map[string]struct{}, len(rows))
for _, row := range rows {
partnumber := strings.TrimSpace(row.Partnumber)
if partnumber == "" {
return errors.New("catalog contains empty partnumber")
}
if _, exists := seen[partnumber]; exists {
return fmt.Errorf("catalog contains duplicate partnumber %q", partnumber)
}
seen[partnumber] = struct{}{}
if strings.TrimSpace(row.LotsJSON) == "" {
return fmt.Errorf("catalog row %q has empty lots_json", partnumber)
}
var lots LocalPartnumberBookLots
if err := json.Unmarshal([]byte(row.LotsJSON), &lots); err != nil {
return fmt.Errorf("catalog row %q has invalid lots_json: %w", partnumber, err)
}
}
return nil
}
func recoverLocalPartnumberBookCatalog(db *gorm.DB, cause error) error {
slog.Warn("recovering broken local partnumber book catalog", "error", cause.Error())
if err := ensureLocalPartnumberBooksCatalogColumn(db); err != nil {
return fmt.Errorf("ensure local_partnumber_books.partnumbers_json during recovery: %w", err)
}
if db.Migrator().HasTable("local_partnumber_book_items__temp") {
if err := quarantineSQLiteTable(db, "local_partnumber_book_items__temp", localPartnumberBookCatalogQuarantineTableName("temp")); err != nil {
return fmt.Errorf("quarantine local_partnumber_book_items__temp: %w", err)
}
}
if db.Migrator().HasTable(&LocalPartnumberBookItem{}) {
if err := quarantineSQLiteTable(db, "local_partnumber_book_items", localPartnumberBookCatalogQuarantineTableName("broken")); err != nil {
return fmt.Errorf("quarantine local_partnumber_book_items: %w", err)
}
}
if err := ensureLocalPartnumberBookItemTable(db); err != nil {
return fmt.Errorf("recreate local_partnumber_book_items after recovery: %w", err)
}
slog.Warn("local partnumber book catalog reset to empty cache; next sync will rebuild it")
return nil
}
func recoverFromReadOnlyCacheInitFailure(db *gorm.DB, cause error) (bool, error) {
lowerCause := strings.ToLower(cause.Error())
recoveredAny := false
for _, tableName := range affectedReadOnlyCacheTables(lowerCause) {
if err := resetReadOnlyCacheTable(db, tableName); err != nil {
return recoveredAny, err
}
recoveredAny = true
}
if strings.Contains(lowerCause, "local_partnumber_book_items") || strings.Contains(lowerCause, "local_partnumber_books") {
if err := recoverLocalPartnumberBookCatalog(db, cause); err != nil {
return recoveredAny, err
}
recoveredAny = true
}
if recoveredAny {
slog.Warn("recovered read-only local cache tables after startup failure", "error", cause.Error())
}
return recoveredAny, nil
}
func affectedReadOnlyCacheTables(lowerCause string) []string {
affected := make([]string, 0, len(localReadOnlyCacheTables))
for _, tableName := range localReadOnlyCacheTables {
if tableName == "local_partnumber_book_items" || tableName == "local_partnumber_books" {
continue
}
if strings.Contains(lowerCause, tableName) {
affected = append(affected, tableName)
}
}
return affected
}
func resetReadOnlyCacheTable(db *gorm.DB, tableName string) error {
slog.Warn("resetting read-only local cache table", "table", tableName)
tempName := tableName + "__temp"
if db.Migrator().HasTable(tempName) {
if err := quarantineSQLiteTable(db, tempName, localReadOnlyCacheQuarantineTableName(tableName, "temp")); err != nil {
return fmt.Errorf("quarantine temp table %s: %w", tempName, err)
}
}
if db.Migrator().HasTable(tableName) {
if err := quarantineSQLiteTable(db, tableName, localReadOnlyCacheQuarantineTableName(tableName, "broken")); err != nil {
return fmt.Errorf("quarantine table %s: %w", tableName, err)
}
}
return nil
}
func ensureLocalPartnumberBooksCatalogColumn(db *gorm.DB) error {
if !db.Migrator().HasTable(&LocalPartnumberBook{}) {
return nil
}
if db.Migrator().HasColumn(&LocalPartnumberBook{}, "partnumbers_json") {
return nil
}
return db.Exec(`ALTER TABLE local_partnumber_books ADD COLUMN partnumbers_json TEXT NOT NULL DEFAULT '[]'`).Error
}
func quarantineSQLiteTable(db *gorm.DB, tableName string, quarantineName string) error {
if !db.Migrator().HasTable(tableName) {
return nil
}
if tableName == quarantineName {
return nil
}
if db.Migrator().HasTable(quarantineName) {
if err := db.Exec(`DROP TABLE ` + quarantineName).Error; err != nil {
return err
}
}
return db.Exec(`ALTER TABLE ` + tableName + ` RENAME TO ` + quarantineName).Error
}
func localPartnumberBookCatalogQuarantineTableName(kind string) string {
return "local_partnumber_book_items_" + kind + "_" + time.Now().UTC().Format("20060102150405")
}
func localReadOnlyCacheQuarantineTableName(tableName string, kind string) string {
return tableName + "_" + kind + "_" + time.Now().UTC().Format("20060102150405")
}
// HasSettings returns true if connection settings exist // HasSettings returns true if connection settings exist
func (l *LocalDB) HasSettings() bool { func (l *LocalDB) HasSettings() bool {
var count int64 var count int64
@@ -206,10 +500,23 @@ func (l *LocalDB) GetSettings() (*ConnectionSettings, error) {
} }
// Decrypt password // Decrypt password
password, err := Decrypt(settings.PasswordEncrypted) password, migrated, err := DecryptWithMetadata(settings.PasswordEncrypted)
if err != nil { if err != nil {
return nil, fmt.Errorf("decrypting password: %w", err) return nil, fmt.Errorf("decrypting password: %w", err)
} }
if migrated {
encrypted, encryptErr := Encrypt(password)
if encryptErr != nil {
return nil, fmt.Errorf("re-encrypting legacy password: %w", encryptErr)
}
if err := l.db.Model(&ConnectionSettings{}).
Where("id = ?", settings.ID).
Update("password_encrypted", encrypted).Error; err != nil {
return nil, fmt.Errorf("upgrading legacy password encryption: %w", err)
}
}
settings.PasswordEncrypted = password // Return decrypted password in this field settings.PasswordEncrypted = password // Return decrypted password in this field
return &settings, nil return &settings, nil
@@ -840,20 +1147,20 @@ func (l *LocalDB) CountLocalPricelistItemsWithEmptyCategory(pricelistID uint) (i
return count, nil return count, nil
} }
// SaveLocalPricelistItems saves pricelist items to local SQLite // SaveLocalPricelistItems saves pricelist items to local SQLite.
// Duplicate (pricelist_id, lot_name) rows are silently ignored.
func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error { func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error {
if len(items) == 0 { if len(items) == 0 {
return nil return nil
} }
// Batch insert
batchSize := 500 batchSize := 500
for i := 0; i < len(items); i += batchSize { for i := 0; i < len(items); i += batchSize {
end := i + batchSize end := i + batchSize
if end > len(items) { if end > len(items) {
end = len(items) end = len(items)
} }
if err := l.db.CreateInBatches(items[i:end], batchSize).Error; err != nil { if err := l.db.Clauses(clause.OnConflict{DoNothing: true}).CreateInBatches(items[i:end], batchSize).Error; err != nil {
return err return err
} }
} }
@@ -1235,42 +1542,6 @@ func (l *LocalDB) repairConfigurationChange(change *PendingChange) error {
return nil return nil
} }
// GetRemoteMigrationApplied returns a locally applied remote migration by ID.
func (l *LocalDB) GetRemoteMigrationApplied(id string) (*LocalRemoteMigrationApplied, error) {
var migration LocalRemoteMigrationApplied
if err := l.db.Where("id = ?", id).First(&migration).Error; err != nil {
return nil, err
}
return &migration, nil
}
// UpsertRemoteMigrationApplied writes applied migration metadata.
func (l *LocalDB) UpsertRemoteMigrationApplied(id, checksum, appVersion string, appliedAt time.Time) error {
record := &LocalRemoteMigrationApplied{
ID: id,
Checksum: checksum,
AppVersion: appVersion,
AppliedAt: appliedAt,
}
return l.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"checksum": checksum,
"app_version": appVersion,
"applied_at": appliedAt,
}),
}).Create(record).Error
}
// GetLatestAppliedRemoteMigrationID returns last applied remote migration id.
func (l *LocalDB) GetLatestAppliedRemoteMigrationID() (string, error) {
var record LocalRemoteMigrationApplied
if err := l.db.Order("applied_at DESC").First(&record).Error; err != nil {
return "", err
}
return record.ID, nil
}
// GetSyncGuardState returns the latest readiness guard state. // GetSyncGuardState returns the latest readiness guard state.
func (l *LocalDB) GetSyncGuardState() (*LocalSyncGuardState, error) { func (l *LocalDB) GetSyncGuardState() (*LocalSyncGuardState, error) {
var state LocalSyncGuardState var state LocalSyncGuardState
+255
View File
@@ -4,6 +4,7 @@ import (
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"sort"
"strings" "strings"
"time" "time"
@@ -113,6 +114,24 @@ var localMigrations = []localMigration{
name: "Add line_no to local_configurations and backfill ordering", name: "Add line_no to local_configurations and backfill ordering",
run: addLocalConfigurationLineNo, run: addLocalConfigurationLineNo,
}, },
{
id: "2026_03_07_local_partnumber_book_catalog",
name: "Convert local partnumber book cache to book membership + deduplicated PN catalog",
run: migrateLocalPartnumberBookCatalog,
},
{
id: "2026_03_13_pricelist_items_dedup_unique",
name: "Deduplicate local_pricelist_items and add unique index on (pricelist_id, lot_name)",
run: deduplicatePricelistItemsAndAddUniqueIndex,
},
}
type localPartnumberCatalogRow struct {
Partnumber string
LotsJSON LocalPartnumberBookLots
Description string
CreatedAt time.Time
ServerID int
} }
func runLocalMigrations(db *gorm.DB) error { func runLocalMigrations(db *gorm.DB) error {
@@ -865,3 +884,239 @@ WHERE id IN (SELECT id FROM ranked)
return nil return nil
} }
func migrateLocalPartnumberBookCatalog(tx *gorm.DB) error {
type columnInfo struct {
Name string `gorm:"column:name"`
}
hasBooksTable := tx.Migrator().HasTable(&LocalPartnumberBook{})
hasItemsTable := tx.Migrator().HasTable(&LocalPartnumberBookItem{})
if !hasItemsTable {
return nil
}
if hasBooksTable {
var bookCols []columnInfo
if err := tx.Raw(`SELECT name FROM pragma_table_info('local_partnumber_books')`).Scan(&bookCols).Error; err != nil {
return fmt.Errorf("load local_partnumber_books columns: %w", err)
}
hasPartnumbersJSON := false
for _, c := range bookCols {
if c.Name == "partnumbers_json" {
hasPartnumbersJSON = true
break
}
}
if !hasPartnumbersJSON {
if err := tx.Exec(`ALTER TABLE local_partnumber_books ADD COLUMN partnumbers_json TEXT NOT NULL DEFAULT '[]'`).Error; err != nil {
return fmt.Errorf("add local_partnumber_books.partnumbers_json: %w", err)
}
}
}
var itemCols []columnInfo
if err := tx.Raw(`SELECT name FROM pragma_table_info('local_partnumber_book_items')`).Scan(&itemCols).Error; err != nil {
return fmt.Errorf("load local_partnumber_book_items columns: %w", err)
}
hasBookID := false
hasLotName := false
hasLotsJSON := false
for _, c := range itemCols {
if c.Name == "book_id" {
hasBookID = true
}
if c.Name == "lot_name" {
hasLotName = true
}
if c.Name == "lots_json" {
hasLotsJSON = true
}
}
if !hasBookID && !hasLotName && !hasLotsJSON {
return nil
}
type legacyRow struct {
BookID uint
Partnumber string
LotName string
Description string
CreatedAt time.Time
ServerID int
}
bookPNs := make(map[uint]map[string]struct{})
catalog := make(map[string]*localPartnumberCatalogRow)
if hasBookID || hasLotName {
var rows []legacyRow
if err := tx.Raw(`
SELECT
i.book_id,
i.partnumber,
i.lot_name,
COALESCE(i.description, '') AS description,
b.created_at,
b.server_id
FROM local_partnumber_book_items i
INNER JOIN local_partnumber_books b ON b.id = i.book_id
ORDER BY b.created_at DESC, b.id DESC, i.partnumber ASC, i.id ASC
`).Scan(&rows).Error; err != nil {
return fmt.Errorf("load legacy local partnumber book items: %w", err)
}
for _, row := range rows {
if _, ok := bookPNs[row.BookID]; !ok {
bookPNs[row.BookID] = make(map[string]struct{})
}
bookPNs[row.BookID][row.Partnumber] = struct{}{}
entry, ok := catalog[row.Partnumber]
if !ok {
entry = &localPartnumberCatalogRow{
Partnumber: row.Partnumber,
Description: row.Description,
CreatedAt: row.CreatedAt,
ServerID: row.ServerID,
}
catalog[row.Partnumber] = entry
}
if row.CreatedAt.After(entry.CreatedAt) || (row.CreatedAt.Equal(entry.CreatedAt) && row.ServerID >= entry.ServerID) {
entry.Description = row.Description
entry.CreatedAt = row.CreatedAt
entry.ServerID = row.ServerID
}
found := false
for i := range entry.LotsJSON {
if entry.LotsJSON[i].LotName == row.LotName {
entry.LotsJSON[i].Qty += 1
found = true
break
}
}
if !found && row.LotName != "" {
entry.LotsJSON = append(entry.LotsJSON, LocalPartnumberBookLot{LotName: row.LotName, Qty: 1})
}
}
var books []LocalPartnumberBook
if err := tx.Find(&books).Error; err != nil {
return fmt.Errorf("load local partnumber books: %w", err)
}
for _, book := range books {
pnSet := bookPNs[book.ID]
partnumbers := make([]string, 0, len(pnSet))
for pn := range pnSet {
partnumbers = append(partnumbers, pn)
}
sort.Strings(partnumbers)
if err := tx.Model(&LocalPartnumberBook{}).
Where("id = ?", book.ID).
Update("partnumbers_json", LocalStringList(partnumbers)).Error; err != nil {
return fmt.Errorf("update partnumbers_json for local book %d: %w", book.ID, err)
}
}
} else {
var items []LocalPartnumberBookItem
if err := tx.Order("id DESC").Find(&items).Error; err != nil {
return fmt.Errorf("load canonical local partnumber book items: %w", err)
}
for _, item := range items {
entry, ok := catalog[item.Partnumber]
if !ok {
copiedLots := append(LocalPartnumberBookLots(nil), item.LotsJSON...)
catalog[item.Partnumber] = &localPartnumberCatalogRow{
Partnumber: item.Partnumber,
LotsJSON: copiedLots,
Description: item.Description,
}
continue
}
if entry.Description == "" && item.Description != "" {
entry.Description = item.Description
}
for _, lot := range item.LotsJSON {
merged := false
for i := range entry.LotsJSON {
if entry.LotsJSON[i].LotName == lot.LotName {
if lot.Qty > entry.LotsJSON[i].Qty {
entry.LotsJSON[i].Qty = lot.Qty
}
merged = true
break
}
}
if !merged {
entry.LotsJSON = append(entry.LotsJSON, lot)
}
}
}
}
return rebuildLocalPartnumberBookCatalog(tx, catalog)
}
func rebuildLocalPartnumberBookCatalog(tx *gorm.DB, catalog map[string]*localPartnumberCatalogRow) error {
if err := tx.Exec(`
CREATE TABLE local_partnumber_book_items_new (
id INTEGER PRIMARY KEY AUTOINCREMENT,
partnumber TEXT NOT NULL UNIQUE,
lots_json TEXT NOT NULL,
description TEXT
)
`).Error; err != nil {
return fmt.Errorf("create new local_partnumber_book_items table: %w", err)
}
orderedPartnumbers := make([]string, 0, len(catalog))
for pn := range catalog {
orderedPartnumbers = append(orderedPartnumbers, pn)
}
sort.Strings(orderedPartnumbers)
for _, pn := range orderedPartnumbers {
row := catalog[pn]
sort.Slice(row.LotsJSON, func(i, j int) bool {
return row.LotsJSON[i].LotName < row.LotsJSON[j].LotName
})
if err := tx.Table("local_partnumber_book_items_new").Create(&LocalPartnumberBookItem{
Partnumber: row.Partnumber,
LotsJSON: row.LotsJSON,
Description: row.Description,
}).Error; err != nil {
return fmt.Errorf("insert new local_partnumber_book_items row for %s: %w", pn, err)
}
}
if err := tx.Exec(`DROP TABLE local_partnumber_book_items`).Error; err != nil {
return fmt.Errorf("drop legacy local_partnumber_book_items: %w", err)
}
if err := tx.Exec(`ALTER TABLE local_partnumber_book_items_new RENAME TO local_partnumber_book_items`).Error; err != nil {
return fmt.Errorf("rename new local_partnumber_book_items table: %w", err)
}
if err := tx.Exec(`CREATE UNIQUE INDEX IF NOT EXISTS idx_local_partnumber_book_items_partnumber ON local_partnumber_book_items(partnumber)`).Error; err != nil {
return fmt.Errorf("create local_partnumber_book_items partnumber index: %w", err)
}
return nil
}
func deduplicatePricelistItemsAndAddUniqueIndex(tx *gorm.DB) error {
// Remove duplicate (pricelist_id, lot_name) rows keeping only the row with the lowest id.
if err := tx.Exec(`
DELETE FROM local_pricelist_items
WHERE id NOT IN (
SELECT MIN(id) FROM local_pricelist_items
GROUP BY pricelist_id, lot_name
)
`).Error; err != nil {
return fmt.Errorf("deduplicate local_pricelist_items: %w", err)
}
// Add unique index to prevent future duplicates.
if err := tx.Exec(`
CREATE UNIQUE INDEX IF NOT EXISTS idx_local_pricelist_items_pricelist_lot_unique
ON local_pricelist_items(pricelist_id, lot_name)
`).Error; err != nil {
return fmt.Errorf("create unique index on local_pricelist_items: %w", err)
}
slog.Info("deduplicated local_pricelist_items and added unique index")
return nil
}
+52 -36
View File
@@ -102,8 +102,9 @@ type LocalConfiguration struct {
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"` PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"` WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"` 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"` OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
VendorSpec VendorSpec `gorm:"type:text" json:"vendor_spec,omitempty"` VendorSpec VendorSpec `gorm:"type:text" json:"vendor_spec,omitempty"`
Line int `gorm:"column:line_no;index" json:"line"` Line int `gorm:"column:line_no;index" json:"line"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"` PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
CreatedAt time.Time `json:"created_at"` CreatedAt time.Time `json:"created_at"`
@@ -202,18 +203,6 @@ func (LocalComponent) TableName() string {
return "local_components" return "local_components"
} }
// LocalRemoteMigrationApplied tracks remote SQLite migrations received from server and applied locally.
type LocalRemoteMigrationApplied struct {
ID string `gorm:"primaryKey;size:128" json:"id"`
Checksum string `gorm:"size:128;not null" json:"checksum"`
AppVersion string `gorm:"size:64" json:"app_version,omitempty"`
AppliedAt time.Time `gorm:"not null" json:"applied_at"`
}
func (LocalRemoteMigrationApplied) TableName() string {
return "local_remote_migrations_applied"
}
// LocalSyncGuardState stores latest sync readiness decision for UI and preflight checks. // LocalSyncGuardState stores latest sync readiness decision for UI and preflight checks.
type LocalSyncGuardState struct { type LocalSyncGuardState struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"` ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
@@ -247,25 +236,52 @@ func (PendingChange) TableName() string {
// LocalPartnumberBook stores a version snapshot of the PN→LOT mapping book (pull-only from PriceForge) // LocalPartnumberBook stores a version snapshot of the PN→LOT mapping book (pull-only from PriceForge)
type LocalPartnumberBook struct { type LocalPartnumberBook struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"` ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
ServerID int `gorm:"uniqueIndex;not null" json:"server_id"` ServerID int `gorm:"uniqueIndex;not null" json:"server_id"`
Version string `gorm:"not null" json:"version"` Version string `gorm:"not null" json:"version"`
CreatedAt time.Time `gorm:"not null" json:"created_at"` CreatedAt time.Time `gorm:"not null" json:"created_at"`
IsActive bool `gorm:"not null;default:true" json:"is_active"` IsActive bool `gorm:"not null;default:true" json:"is_active"`
PartnumbersJSON LocalStringList `gorm:"column:partnumbers_json;type:text" json:"partnumbers_json"`
} }
func (LocalPartnumberBook) TableName() string { func (LocalPartnumberBook) TableName() string {
return "local_partnumber_books" return "local_partnumber_books"
} }
// LocalPartnumberBookItem stores a single PN→LOT mapping within a book snapshot type LocalPartnumberBookLot struct {
LotName string `json:"lot_name"`
Qty float64 `json:"qty"`
}
type LocalPartnumberBookLots []LocalPartnumberBookLot
func (l LocalPartnumberBookLots) Value() (driver.Value, error) {
return json.Marshal(l)
}
func (l *LocalPartnumberBookLots) Scan(value interface{}) error {
if value == nil {
*l = make(LocalPartnumberBookLots, 0)
return nil
}
var bytes []byte
switch v := value.(type) {
case []byte:
bytes = v
case string:
bytes = []byte(v)
default:
return errors.New("type assertion failed for LocalPartnumberBookLots")
}
return json.Unmarshal(bytes, l)
}
// LocalPartnumberBookItem stores the canonical PN composition pulled from PriceForge.
type LocalPartnumberBookItem struct { type LocalPartnumberBookItem struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"` 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" json:"partnumber"`
Partnumber string `gorm:"not null;index:idx_local_book_pn,priority:2" json:"partnumber"` LotsJSON LocalPartnumberBookLots `gorm:"column:lots_json;type:text" json:"lots_json"`
LotName string `gorm:"not null" json:"lot_name"` Description string `json:"description,omitempty"`
IsPrimaryPN bool `gorm:"not null;default:false" json:"is_primary_pn"`
Description string `json:"description,omitempty"`
} }
func (LocalPartnumberBookItem) TableName() string { func (LocalPartnumberBookItem) TableName() string {
@@ -274,18 +290,18 @@ func (LocalPartnumberBookItem) TableName() string {
// VendorSpecItem represents a single row in a vendor BOM specification // VendorSpecItem represents a single row in a vendor BOM specification
type VendorSpecItem struct { type VendorSpecItem struct {
SortOrder int `json:"sort_order"` SortOrder int `json:"sort_order"`
VendorPartnumber string `json:"vendor_partnumber"` VendorPartnumber string `json:"vendor_partnumber"`
Quantity int `json:"quantity"` Quantity int `json:"quantity"`
Description string `json:"description,omitempty"` Description string `json:"description,omitempty"`
UnitPrice *float64 `json:"unit_price,omitempty"` UnitPrice *float64 `json:"unit_price,omitempty"`
TotalPrice *float64 `json:"total_price,omitempty"` TotalPrice *float64 `json:"total_price,omitempty"`
ResolvedLotName string `json:"resolved_lot_name,omitempty"` ResolvedLotName string `json:"resolved_lot_name,omitempty"`
ResolutionSource string `json:"resolution_source,omitempty"` // "book", "manual", "unresolved" ResolutionSource string `json:"resolution_source,omitempty"` // "book", "manual", "unresolved"
ManualLotSuggestion string `json:"manual_lot_suggestion,omitempty"` ManualLotSuggestion string `json:"manual_lot_suggestion,omitempty"`
LotQtyPerPN int `json:"lot_qty_per_pn,omitempty"` LotQtyPerPN int `json:"lot_qty_per_pn,omitempty"`
LotAllocations []VendorSpecLotAllocation `json:"lot_allocations,omitempty"` LotAllocations []VendorSpecLotAllocation `json:"lot_allocations,omitempty"`
LotMappings []VendorSpecLotMapping `json:"lot_mappings,omitempty"` LotMappings []VendorSpecLotMapping `json:"lot_mappings,omitempty"`
} }
type VendorSpecLotAllocation struct { type VendorSpecLotAllocation struct {
+74 -62
View File
@@ -10,32 +10,36 @@ import (
// BuildConfigurationSnapshot serializes the full local configuration state. // BuildConfigurationSnapshot serializes the full local configuration state.
func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) { func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
snapshot := map[string]interface{}{ snapshot := map[string]interface{}{
"id": localCfg.ID, "id": localCfg.ID,
"uuid": localCfg.UUID, "uuid": localCfg.UUID,
"server_id": localCfg.ServerID, "server_id": localCfg.ServerID,
"project_uuid": localCfg.ProjectUUID, "project_uuid": localCfg.ProjectUUID,
"current_version_id": localCfg.CurrentVersionID, "current_version_id": localCfg.CurrentVersionID,
"is_active": localCfg.IsActive, "is_active": localCfg.IsActive,
"name": localCfg.Name, "name": localCfg.Name,
"items": localCfg.Items, "items": localCfg.Items,
"total_price": localCfg.TotalPrice, "total_price": localCfg.TotalPrice,
"custom_price": localCfg.CustomPrice, "custom_price": localCfg.CustomPrice,
"notes": localCfg.Notes, "notes": localCfg.Notes,
"is_template": localCfg.IsTemplate, "is_template": localCfg.IsTemplate,
"server_count": localCfg.ServerCount, "server_count": localCfg.ServerCount,
"server_model": localCfg.ServerModel, "server_model": localCfg.ServerModel,
"support_code": localCfg.SupportCode, "support_code": localCfg.SupportCode,
"article": localCfg.Article, "article": localCfg.Article,
"pricelist_id": localCfg.PricelistID, "pricelist_id": localCfg.PricelistID,
"only_in_stock": localCfg.OnlyInStock, "warehouse_pricelist_id": localCfg.WarehousePricelistID,
"line": localCfg.Line, "competitor_pricelist_id": localCfg.CompetitorPricelistID,
"price_updated_at": localCfg.PriceUpdatedAt, "disable_price_refresh": localCfg.DisablePriceRefresh,
"created_at": localCfg.CreatedAt, "only_in_stock": localCfg.OnlyInStock,
"updated_at": localCfg.UpdatedAt, "vendor_spec": localCfg.VendorSpec,
"synced_at": localCfg.SyncedAt, "line": localCfg.Line,
"sync_status": localCfg.SyncStatus, "price_updated_at": localCfg.PriceUpdatedAt,
"original_user_id": localCfg.OriginalUserID, "created_at": localCfg.CreatedAt,
"original_username": localCfg.OriginalUsername, "updated_at": localCfg.UpdatedAt,
"synced_at": localCfg.SyncedAt,
"sync_status": localCfg.SyncStatus,
"original_user_id": localCfg.OriginalUserID,
"original_username": localCfg.OriginalUsername,
} }
data, err := json.Marshal(snapshot) data, err := json.Marshal(snapshot)
@@ -48,24 +52,28 @@ func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
// DecodeConfigurationSnapshot returns editable fields from one saved snapshot. // DecodeConfigurationSnapshot returns editable fields from one saved snapshot.
func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) { func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
var snapshot struct { var snapshot struct {
ProjectUUID *string `json:"project_uuid"` ProjectUUID *string `json:"project_uuid"`
IsActive *bool `json:"is_active"` IsActive *bool `json:"is_active"`
Name string `json:"name"` Name string `json:"name"`
Items LocalConfigItems `json:"items"` Items LocalConfigItems `json:"items"`
TotalPrice *float64 `json:"total_price"` TotalPrice *float64 `json:"total_price"`
CustomPrice *float64 `json:"custom_price"` CustomPrice *float64 `json:"custom_price"`
Notes string `json:"notes"` Notes string `json:"notes"`
IsTemplate bool `json:"is_template"` IsTemplate bool `json:"is_template"`
ServerCount int `json:"server_count"` ServerCount int `json:"server_count"`
ServerModel string `json:"server_model"` ServerModel string `json:"server_model"`
SupportCode string `json:"support_code"` SupportCode string `json:"support_code"`
Article string `json:"article"` Article string `json:"article"`
PricelistID *uint `json:"pricelist_id"` PricelistID *uint `json:"pricelist_id"`
OnlyInStock bool `json:"only_in_stock"` WarehousePricelistID *uint `json:"warehouse_pricelist_id"`
Line int `json:"line"` CompetitorPricelistID *uint `json:"competitor_pricelist_id"`
PriceUpdatedAt *time.Time `json:"price_updated_at"` DisablePriceRefresh bool `json:"disable_price_refresh"`
OriginalUserID uint `json:"original_user_id"` OnlyInStock bool `json:"only_in_stock"`
OriginalUsername string `json:"original_username"` VendorSpec VendorSpec `json:"vendor_spec"`
Line int `json:"line"`
PriceUpdatedAt *time.Time `json:"price_updated_at"`
OriginalUserID uint `json:"original_user_id"`
OriginalUsername string `json:"original_username"`
} }
if err := json.Unmarshal([]byte(data), &snapshot); err != nil { if err := json.Unmarshal([]byte(data), &snapshot); err != nil {
@@ -78,24 +86,28 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
} }
return &LocalConfiguration{ return &LocalConfiguration{
IsActive: isActive, IsActive: isActive,
ProjectUUID: snapshot.ProjectUUID, ProjectUUID: snapshot.ProjectUUID,
Name: snapshot.Name, Name: snapshot.Name,
Items: snapshot.Items, Items: snapshot.Items,
TotalPrice: snapshot.TotalPrice, TotalPrice: snapshot.TotalPrice,
CustomPrice: snapshot.CustomPrice, CustomPrice: snapshot.CustomPrice,
Notes: snapshot.Notes, Notes: snapshot.Notes,
IsTemplate: snapshot.IsTemplate, IsTemplate: snapshot.IsTemplate,
ServerCount: snapshot.ServerCount, ServerCount: snapshot.ServerCount,
ServerModel: snapshot.ServerModel, ServerModel: snapshot.ServerModel,
SupportCode: snapshot.SupportCode, SupportCode: snapshot.SupportCode,
Article: snapshot.Article, Article: snapshot.Article,
PricelistID: snapshot.PricelistID, PricelistID: snapshot.PricelistID,
OnlyInStock: snapshot.OnlyInStock, WarehousePricelistID: snapshot.WarehousePricelistID,
Line: snapshot.Line, CompetitorPricelistID: snapshot.CompetitorPricelistID,
PriceUpdatedAt: snapshot.PriceUpdatedAt, DisablePriceRefresh: snapshot.DisablePriceRefresh,
OriginalUserID: snapshot.OriginalUserID, OnlyInStock: snapshot.OnlyInStock,
OriginalUsername: snapshot.OriginalUsername, VendorSpec: snapshot.VendorSpec,
Line: snapshot.Line,
PriceUpdatedAt: snapshot.PriceUpdatedAt,
OriginalUserID: snapshot.OriginalUserID,
OriginalUsername: snapshot.OriginalUsername,
}, nil }, nil
} }
-238
View File
@@ -1,238 +0,0 @@
package lotmatch
import (
"errors"
"regexp"
"sort"
"strings"
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/gorm"
)
var (
ErrResolveConflict = errors.New("multiple lot matches")
ErrResolveNotFound = errors.New("lot not found")
)
type LotResolver struct {
partnumberToLots map[string][]string
exactLots map[string]string
allLots []string
}
type MappingMatcher struct {
exact map[string][]string
exactLot map[string]string
wildcard []wildcardMapping
}
type wildcardMapping struct {
lotName string
re *regexp.Regexp
}
func NewLotResolverFromDB(db *gorm.DB) (*LotResolver, error) {
mappings, lots, err := loadMappingsAndLots(db)
if err != nil {
return nil, err
}
return NewLotResolver(mappings, lots), nil
}
func NewMappingMatcherFromDB(db *gorm.DB) (*MappingMatcher, error) {
mappings, lots, err := loadMappingsAndLots(db)
if err != nil {
return nil, err
}
return NewMappingMatcher(mappings, lots), nil
}
func NewLotResolver(mappings []models.LotPartnumber, lots []models.Lot) *LotResolver {
partnumberToLots := make(map[string][]string, len(mappings))
for _, m := range mappings {
pn := NormalizeKey(m.Partnumber)
lot := strings.TrimSpace(m.LotName)
if pn == "" || lot == "" {
continue
}
partnumberToLots[pn] = append(partnumberToLots[pn], lot)
}
for key := range partnumberToLots {
partnumberToLots[key] = uniqueCaseInsensitive(partnumberToLots[key])
}
exactLots := make(map[string]string, len(lots))
allLots := make([]string, 0, len(lots))
for _, l := range lots {
name := strings.TrimSpace(l.LotName)
if name == "" {
continue
}
exactLots[NormalizeKey(name)] = name
allLots = append(allLots, name)
}
sort.Slice(allLots, func(i, j int) bool {
li := len([]rune(allLots[i]))
lj := len([]rune(allLots[j]))
if li == lj {
return allLots[i] < allLots[j]
}
return li > lj
})
return &LotResolver{
partnumberToLots: partnumberToLots,
exactLots: exactLots,
allLots: allLots,
}
}
func NewMappingMatcher(mappings []models.LotPartnumber, lots []models.Lot) *MappingMatcher {
exact := make(map[string][]string, len(mappings))
wildcards := make([]wildcardMapping, 0, len(mappings))
for _, m := range mappings {
pn := NormalizeKey(m.Partnumber)
lot := strings.TrimSpace(m.LotName)
if pn == "" || lot == "" {
continue
}
if strings.Contains(pn, "*") {
pattern := "^" + regexp.QuoteMeta(pn) + "$"
pattern = strings.ReplaceAll(pattern, "\\*", ".*")
re, err := regexp.Compile(pattern)
if err != nil {
continue
}
wildcards = append(wildcards, wildcardMapping{lotName: lot, re: re})
continue
}
exact[pn] = append(exact[pn], lot)
}
for key := range exact {
exact[key] = uniqueCaseInsensitive(exact[key])
}
exactLot := make(map[string]string, len(lots))
for _, l := range lots {
name := strings.TrimSpace(l.LotName)
if name == "" {
continue
}
exactLot[NormalizeKey(name)] = name
}
return &MappingMatcher{
exact: exact,
exactLot: exactLot,
wildcard: wildcards,
}
}
func (r *LotResolver) Resolve(partnumber string) (string, string, error) {
key := NormalizeKey(partnumber)
if key == "" {
return "", "", ErrResolveNotFound
}
if mapped := r.partnumberToLots[key]; len(mapped) > 0 {
if len(mapped) == 1 {
return mapped[0], "mapping_table", nil
}
return "", "", ErrResolveConflict
}
if exact, ok := r.exactLots[key]; ok {
return exact, "article_exact", nil
}
best := ""
bestLen := -1
tie := false
for _, lot := range r.allLots {
lotKey := NormalizeKey(lot)
if lotKey == "" {
continue
}
if strings.HasPrefix(key, lotKey) {
l := len([]rune(lotKey))
if l > bestLen {
best = lot
bestLen = l
tie = false
} else if l == bestLen && !strings.EqualFold(best, lot) {
tie = true
}
}
}
if best == "" {
return "", "", ErrResolveNotFound
}
if tie {
return "", "", ErrResolveConflict
}
return best, "prefix", nil
}
func (m *MappingMatcher) MatchLots(partnumber string) []string {
if m == nil {
return nil
}
key := NormalizeKey(partnumber)
if key == "" {
return nil
}
lots := make([]string, 0, 2)
if exact := m.exact[key]; len(exact) > 0 {
lots = append(lots, exact...)
}
for _, wc := range m.wildcard {
if wc.re == nil || !wc.re.MatchString(key) {
continue
}
lots = append(lots, wc.lotName)
}
if lot, ok := m.exactLot[key]; ok && strings.TrimSpace(lot) != "" {
lots = append(lots, lot)
}
return uniqueCaseInsensitive(lots)
}
func NormalizeKey(v string) string {
s := strings.ToLower(strings.TrimSpace(v))
replacer := strings.NewReplacer(" ", "", "-", "", "_", "", ".", "", "/", "", "\\", "", "\"", "", "'", "", "(", "", ")", "")
return replacer.Replace(s)
}
func loadMappingsAndLots(db *gorm.DB) ([]models.LotPartnumber, []models.Lot, error) {
var mappings []models.LotPartnumber
if err := db.Find(&mappings).Error; err != nil {
return nil, nil, err
}
var lots []models.Lot
if err := db.Select("lot_name").Find(&lots).Error; err != nil {
return nil, nil, err
}
return mappings, lots, nil
}
func uniqueCaseInsensitive(values []string) []string {
seen := make(map[string]struct{}, len(values))
out := make([]string, 0, len(values))
for _, v := range values {
trimmed := strings.TrimSpace(v)
if trimmed == "" {
continue
}
key := strings.ToLower(trimmed)
if _, ok := seen[key]; ok {
continue
}
seen[key] = struct{}{}
out = append(out, trimmed)
}
sort.Slice(out, func(i, j int) bool {
return strings.ToLower(out[i]) < strings.ToLower(out[j])
})
return out
}
-62
View File
@@ -1,62 +0,0 @@
package lotmatch
import (
"testing"
"git.mchus.pro/mchus/quoteforge/internal/models"
)
func TestLotResolverPrecedence(t *testing.T) {
resolver := NewLotResolver(
[]models.LotPartnumber{
{Partnumber: "PN-1", LotName: "LOT_A"},
},
[]models.Lot{
{LotName: "CPU_X_LONG"},
{LotName: "CPU_X"},
},
)
lot, by, err := resolver.Resolve("PN-1")
if err != nil || lot != "LOT_A" || by != "mapping_table" {
t.Fatalf("expected mapping_table LOT_A, got lot=%s by=%s err=%v", lot, by, err)
}
lot, by, err = resolver.Resolve("CPU_X")
if err != nil || lot != "CPU_X" || by != "article_exact" {
t.Fatalf("expected article_exact CPU_X, got lot=%s by=%s err=%v", lot, by, err)
}
lot, by, err = resolver.Resolve("CPU_X_LONG_001")
if err != nil || lot != "CPU_X_LONG" || by != "prefix" {
t.Fatalf("expected prefix CPU_X_LONG, got lot=%s by=%s err=%v", lot, by, err)
}
}
func TestMappingMatcherWildcardAndExactLot(t *testing.T) {
matcher := NewMappingMatcher(
[]models.LotPartnumber{
{Partnumber: "R750*", LotName: "SERVER_R750"},
{Partnumber: "HDD-01", LotName: "HDD_01"},
},
[]models.Lot{
{LotName: "MEM_DDR5_16G_4800"},
},
)
check := func(partnumber string, want string) {
t.Helper()
got := matcher.MatchLots(partnumber)
if len(got) != 1 || got[0] != want {
t.Fatalf("partnumber %s: expected [%s], got %#v", partnumber, want, got)
}
}
check("R750XD", "SERVER_R750")
check("HDD-01", "HDD_01")
check("MEM_DDR5_16G_4800", "MEM_DDR5_16G_4800")
if got := matcher.MatchLots("UNKNOWN"); len(got) != 0 {
t.Fatalf("expected no matches for UNKNOWN, got %#v", got)
}
}
-110
View File
@@ -1,110 +0,0 @@
package middleware
import (
"net/http"
"strings"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin"
)
const (
AuthUserKey = "auth_user"
AuthClaimsKey = "auth_claims"
)
func Auth(authService *services.AuthService) gin.HandlerFunc {
return func(c *gin.Context) {
authHeader := c.GetHeader("Authorization")
if authHeader == "" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "authorization header required",
})
return
}
parts := strings.SplitN(authHeader, " ", 2)
if len(parts) != 2 || parts[0] != "Bearer" {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "invalid authorization header format",
})
return
}
claims, err := authService.ValidateToken(parts[1])
if err != nil {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": err.Error(),
})
return
}
c.Set(AuthClaimsKey, claims)
c.Next()
}
}
func RequireRole(roles ...models.UserRole) gin.HandlerFunc {
return func(c *gin.Context) {
claims, exists := c.Get(AuthClaimsKey)
if !exists {
c.AbortWithStatusJSON(http.StatusUnauthorized, gin.H{
"error": "authentication required",
})
return
}
authClaims := claims.(*services.Claims)
for _, role := range roles {
if authClaims.Role == role {
c.Next()
return
}
}
c.AbortWithStatusJSON(http.StatusForbidden, gin.H{
"error": "insufficient permissions",
})
}
}
func RequireEditor() gin.HandlerFunc {
return RequireRole(models.RoleEditor, models.RolePricingAdmin, models.RoleAdmin)
}
func RequirePricingAdmin() gin.HandlerFunc {
return RequireRole(models.RolePricingAdmin, models.RoleAdmin)
}
func RequireAdmin() gin.HandlerFunc {
return RequireRole(models.RoleAdmin)
}
// GetClaims extracts auth claims from context
func GetClaims(c *gin.Context) *services.Claims {
claims, exists := c.Get(AuthClaimsKey)
if !exists {
return nil
}
return claims.(*services.Claims)
}
// GetUserID extracts user ID from context
func GetUserID(c *gin.Context) uint {
claims := GetClaims(c)
if claims == nil {
return 0
}
return claims.UserID
}
// GetUsername extracts username from context
func GetUsername(c *gin.Context) string {
claims := GetClaims(c)
if claims == nil {
return ""
}
return claims.Username
}
+52 -4
View File
@@ -39,6 +39,57 @@ func (c ConfigItems) Total() float64 {
return total return total
} }
type VendorSpecLotAllocation struct {
LotName string `json:"lot_name"`
Quantity int `json:"quantity"`
}
type VendorSpecLotMapping struct {
LotName string `json:"lot_name"`
QuantityPerPN int `json:"quantity_per_pn"`
}
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"`
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 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)
}
type Configuration struct { type Configuration struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"` ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"` UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
@@ -59,14 +110,13 @@ type Configuration struct {
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"` PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"` WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"` CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
VendorSpec VendorSpec `gorm:"type:json" json:"vendor_spec,omitempty"`
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"` DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"` OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
Line int `gorm:"column:line_no;index" json:"line"` Line int `gorm:"column:line_no;index" json:"line"`
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"` PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"` CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
CurrentVersionNo int `gorm:"-" json:"current_version_no,omitempty"` CurrentVersionNo int `gorm:"-" json:"current_version_no,omitempty"`
User *User `gorm:"foreignKey:UserID" json:"user,omitempty"`
} }
func (Configuration) TableName() string { func (Configuration) TableName() string {
@@ -81,8 +131,6 @@ type PriceOverride struct {
ValidUntil *time.Time `gorm:"type:date" json:"valid_until"` ValidUntil *time.Time `gorm:"type:date" json:"valid_until"`
Reason string `gorm:"type:text" json:"reason"` Reason string `gorm:"type:text" json:"reason"`
CreatedBy uint `gorm:"not null" json:"created_by"` CreatedBy uint `gorm:"not null" json:"created_by"`
Creator *User `gorm:"foreignKey:CreatedBy" json:"creator,omitempty"`
} }
func (PriceOverride) TableName() string { func (PriceOverride) TableName() string {
-11
View File
@@ -55,17 +55,6 @@ func (StockLog) TableName() string {
return "stock_log" return "stock_log"
} }
// LotPartnumber maps external part numbers to internal lots.
type LotPartnumber struct {
Partnumber string `gorm:"column:partnumber;size:255;primaryKey" json:"partnumber"`
LotName string `gorm:"column:lot_name;size:255;primaryKey" json:"lot_name"`
Description *string `gorm:"column:description;size:10000" json:"description,omitempty"`
}
func (LotPartnumber) TableName() string {
return "lot_partnumbers"
}
// StockIgnoreRule contains import ignore pattern rules. // StockIgnoreRule contains import ignore pattern rules.
type StockIgnoreRule struct { type StockIgnoreRule struct {
ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"` ID uint `gorm:"column:id;primaryKey;autoIncrement" json:"id"`
-52
View File
@@ -10,7 +10,6 @@ import (
// AllModels returns all models for auto-migration // AllModels returns all models for auto-migration
func AllModels() []interface{} { func AllModels() []interface{} {
return []interface{}{ return []interface{}{
&User{},
&Category{}, &Category{},
&LotMetadata{}, &LotMetadata{},
&Project{}, &Project{},
@@ -52,54 +51,3 @@ func SeedCategories(db *gorm.DB) error {
} }
return nil return nil
} }
// SeedAdminUser creates default admin user if not exists
// Default credentials: admin / admin123
func SeedAdminUser(db *gorm.DB, passwordHash string) error {
var count int64
db.Model(&User{}).Where("username = ?", "admin").Count(&count)
if count > 0 {
return nil
}
admin := &User{
Username: "admin",
Email: "admin@example.com",
PasswordHash: passwordHash,
Role: RoleAdmin,
IsActive: true,
}
return db.Create(admin).Error
}
// EnsureDBUser creates or returns the user corresponding to the database connection username.
// This is used when RBAC is disabled - configurations are owned by the DB user.
// Returns the user ID that should be used for all operations.
func EnsureDBUser(db *gorm.DB, dbUsername string) (uint, error) {
if dbUsername == "" {
return 0, nil
}
var user User
err := db.Where("username = ?", dbUsername).First(&user).Error
if err == nil {
return user.ID, nil
}
// User doesn't exist, create it
user = User{
Username: dbUsername,
Email: dbUsername + "@db.local",
PasswordHash: "-", // No password - this is a DB user, not an app user
Role: RoleAdmin,
IsActive: true,
}
if err := db.Create(&user).Error; err != nil {
slog.Error("failed to create DB user", "username", dbUsername, "error", err)
return 0, err
}
slog.Info("created DB user for configurations", "username", dbUsername, "user_id", user.ID)
return user.ID, nil
}
-39
View File
@@ -1,39 +0,0 @@
package models
import "time"
type UserRole string
const (
RoleViewer UserRole = "viewer"
RoleEditor UserRole = "editor"
RolePricingAdmin UserRole = "pricing_admin"
RoleAdmin UserRole = "admin"
)
type User struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
Username string `gorm:"size:100;uniqueIndex;not null" json:"username"`
Email string `gorm:"size:255;uniqueIndex;not null" json:"email"`
PasswordHash string `gorm:"size:255;not null" json:"-"`
Role UserRole `gorm:"type:enum('viewer','editor','pricing_admin','admin');default:'viewer'" json:"role"`
IsActive bool `gorm:"default:true" json:"is_active"`
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
UpdatedAt time.Time `gorm:"autoUpdateTime" json:"updated_at"`
}
func (User) TableName() string {
return "qt_users"
}
func (u *User) CanEdit() bool {
return u.Role == RoleEditor || u.Role == RolePricingAdmin || u.Role == RoleAdmin
}
func (u *User) CanManagePricing() bool {
return u.Role == RolePricingAdmin || u.Role == RoleAdmin
}
func (u *User) CanManageUsers() bool {
return u.Role == RoleAdmin
}
+116 -8
View File
@@ -3,6 +3,7 @@ package repository
import ( import (
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"gorm.io/gorm" "gorm.io/gorm"
"gorm.io/gorm/clause"
) )
// PartnumberBookRepository provides read-only access to local partnumber book snapshots. // PartnumberBookRepository provides read-only access to local partnumber book snapshots.
@@ -26,15 +27,48 @@ func (r *PartnumberBookRepository) GetActiveBook() (*localdb.LocalPartnumberBook
// GetBookItems returns all items for the given local book ID. // GetBookItems returns all items for the given local book ID.
func (r *PartnumberBookRepository) GetBookItems(bookID uint) ([]localdb.LocalPartnumberBookItem, error) { func (r *PartnumberBookRepository) GetBookItems(bookID uint) ([]localdb.LocalPartnumberBookItem, error) {
var items []localdb.LocalPartnumberBookItem book, err := r.getBook(bookID)
err := r.db.Where("book_id = ?", bookID).Find(&items).Error if err != nil {
return nil, err
}
items, _, err := r.listCatalogItems(book.PartnumbersJSON, "", 0, 0)
return items, err return items, err
} }
// GetBookItemsPage returns items for the given local book ID with optional search and pagination.
func (r *PartnumberBookRepository) GetBookItemsPage(bookID uint, search string, page, perPage int) ([]localdb.LocalPartnumberBookItem, int64, error) {
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 100
}
book, err := r.getBook(bookID)
if err != nil {
return nil, 0, err
}
return r.listCatalogItems(book.PartnumbersJSON, search, page, perPage)
}
// FindLotByPartnumber looks up a partnumber in the active book and returns the matching items. // 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) { func (r *PartnumberBookRepository) FindLotByPartnumber(bookID uint, partnumber string) ([]localdb.LocalPartnumberBookItem, error) {
book, err := r.getBook(bookID)
if err != nil {
return nil, err
}
found := false
for _, pn := range book.PartnumbersJSON {
if pn == partnumber {
found = true
break
}
}
if !found {
return nil, nil
}
var items []localdb.LocalPartnumberBookItem var items []localdb.LocalPartnumberBookItem
err := r.db.Where("book_id = ? AND partnumber = ?", bookID, partnumber).Find(&items).Error err = r.db.Where("partnumber = ?", partnumber).Find(&items).Error
return items, err return items, err
} }
@@ -50,17 +84,91 @@ func (r *PartnumberBookRepository) SaveBook(book *localdb.LocalPartnumberBook) e
return r.db.Save(book).Error return r.db.Save(book).Error
} }
// SaveBookItems bulk-inserts items for a book snapshot. // SaveBookItems upserts canonical PN catalog rows.
func (r *PartnumberBookRepository) SaveBookItems(items []localdb.LocalPartnumberBookItem) error { func (r *PartnumberBookRepository) SaveBookItems(items []localdb.LocalPartnumberBookItem) error {
if len(items) == 0 { if len(items) == 0 {
return nil return nil
} }
return r.db.CreateInBatches(items, 500).Error return r.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "partnumber"}},
DoUpdates: clause.AssignmentColumns([]string{
"lots_json",
"description",
}),
}).CreateInBatches(items, 500).Error
} }
// CountBookItems returns the number of items for a given local book ID. // CountBookItems returns the number of items for a given local book ID.
func (r *PartnumberBookRepository) CountBookItems(bookID uint) int64 { func (r *PartnumberBookRepository) CountBookItems(bookID uint) int64 {
var count int64 book, err := r.getBook(bookID)
r.db.Model(&localdb.LocalPartnumberBookItem{}).Where("book_id = ?", bookID).Count(&count) if err != nil {
return count return 0
}
return int64(len(book.PartnumbersJSON))
}
func (r *PartnumberBookRepository) CountDistinctLots(bookID uint) int64 {
items, err := r.GetBookItems(bookID)
if err != nil {
return 0
}
seen := make(map[string]struct{})
for _, item := range items {
for _, lot := range item.LotsJSON {
if lot.LotName == "" {
continue
}
seen[lot.LotName] = struct{}{}
}
}
return int64(len(seen))
}
func (r *PartnumberBookRepository) HasAllBookItems(bookID uint) bool {
book, err := r.getBook(bookID)
if err != nil {
return false
}
if len(book.PartnumbersJSON) == 0 {
return true
}
var count int64
if err := r.db.Model(&localdb.LocalPartnumberBookItem{}).
Where("partnumber IN ?", []string(book.PartnumbersJSON)).
Count(&count).Error; err != nil {
return false
}
return count == int64(len(book.PartnumbersJSON))
}
func (r *PartnumberBookRepository) getBook(bookID uint) (*localdb.LocalPartnumberBook, error) {
var book localdb.LocalPartnumberBook
if err := r.db.First(&book, bookID).Error; err != nil {
return nil, err
}
return &book, nil
}
func (r *PartnumberBookRepository) listCatalogItems(partnumbers localdb.LocalStringList, search string, page, perPage int) ([]localdb.LocalPartnumberBookItem, int64, error) {
if len(partnumbers) == 0 {
return []localdb.LocalPartnumberBookItem{}, 0, nil
}
query := r.db.Model(&localdb.LocalPartnumberBookItem{}).Where("partnumber IN ?", []string(partnumbers))
if search != "" {
trimmedSearch := "%" + search + "%"
query = query.Where("partnumber LIKE ? OR lots_json LIKE ? OR description LIKE ?", trimmedSearch, trimmedSearch, trimmedSearch)
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var items []localdb.LocalPartnumberBookItem
if page > 0 && perPage > 0 {
query = query.Offset((page - 1) * perPage).Limit(perPage)
}
err := query.Order("partnumber ASC, id ASC").Find(&items).Error
return items, total, err
} }
-88
View File
@@ -3,13 +3,10 @@ package repository
import ( import (
"errors" "errors"
"fmt" "fmt"
"log/slog"
"sort"
"strconv" "strconv"
"strings" "strings"
"time" "time"
"git.mchus.pro/mchus/quoteforge/internal/lotmatch"
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/gorm" "gorm.io/gorm"
) )
@@ -246,94 +243,9 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear
items[i].Category = strings.TrimSpace(items[i].LotCategory) items[i].Category = strings.TrimSpace(items[i].LotCategory)
} }
// Stock/partnumber enrichment is optional for pricelist item listing.
// Return base pricelist rows (lot_name/price/category) even when DB user lacks
// access to stock mapping tables (e.g. lot_partnumbers, stock_log).
if err := r.enrichItemsWithStock(items); err != nil {
slog.Warn("pricelist items stock enrichment skipped", "pricelist_id", pricelistID, "error", err)
}
return items, total, nil return items, total, nil
} }
func (r *PricelistRepository) enrichItemsWithStock(items []models.PricelistItem) error {
if len(items) == 0 {
return nil
}
resolver, err := lotmatch.NewLotResolverFromDB(r.db)
if err != nil {
return err
}
type stockRow struct {
Partnumber string `gorm:"column:partnumber"`
Qty *float64 `gorm:"column:qty"`
}
rows := make([]stockRow, 0)
if err := r.db.Raw(`
SELECT s.partnumber, s.qty
FROM stock_log s
INNER JOIN (
SELECT partnumber, MAX(date) AS max_date
FROM stock_log
GROUP BY partnumber
) latest ON latest.partnumber = s.partnumber AND latest.max_date = s.date
WHERE s.qty IS NOT NULL
`).Scan(&rows).Error; err != nil {
return err
}
lotTotals := make(map[string]float64, len(items))
lotPartnumbers := make(map[string][]string, len(items))
seenPartnumbers := make(map[string]map[string]struct{}, len(items))
for i := range rows {
row := rows[i]
if strings.TrimSpace(row.Partnumber) == "" {
continue
}
lotName, _, resolveErr := resolver.Resolve(row.Partnumber)
if resolveErr != nil || strings.TrimSpace(lotName) == "" {
continue
}
if row.Qty != nil {
lotTotals[lotName] += *row.Qty
}
pn := strings.TrimSpace(row.Partnumber)
if pn == "" {
continue
}
if _, ok := seenPartnumbers[lotName]; !ok {
seenPartnumbers[lotName] = make(map[string]struct{}, 4)
}
key := strings.ToLower(pn)
if _, exists := seenPartnumbers[lotName][key]; exists {
continue
}
seenPartnumbers[lotName][key] = struct{}{}
lotPartnumbers[lotName] = append(lotPartnumbers[lotName], pn)
}
for i := range items {
lotName := items[i].LotName
if qty, ok := lotTotals[lotName]; ok {
qtyCopy := qty
items[i].AvailableQty = &qtyCopy
}
if partnumbers := lotPartnumbers[lotName]; len(partnumbers) > 0 {
sort.Slice(partnumbers, func(a, b int) bool {
return strings.ToLower(partnumbers[a]) < strings.ToLower(partnumbers[b])
})
items[i].Partnumbers = partnumbers
}
}
return nil
}
// GetLotNames returns distinct lot names from pricelist items. // GetLotNames returns distinct lot names from pricelist items.
func (r *PricelistRepository) GetLotNames(pricelistID uint) ([]string, error) { func (r *PricelistRepository) GetLotNames(pricelistID uint) ([]string, error) {
var lotNames []string var lotNames []string
+1 -52
View File
@@ -75,57 +75,6 @@ func TestGenerateVersion_IsolatedBySource(t *testing.T) {
} }
} }
func TestGetItems_WarehouseAvailableQtyUsesPrefixResolver(t *testing.T) {
repo := newTestPricelistRepository(t)
db := repo.db
warehouse := models.Pricelist{
Source: string(models.PricelistSourceWarehouse),
Version: "S-2026-02-07-001",
CreatedBy: "test",
IsActive: true,
}
if err := db.Create(&warehouse).Error; err != nil {
t.Fatalf("create pricelist: %v", err)
}
if err := db.Create(&models.PricelistItem{
PricelistID: warehouse.ID,
LotName: "SSD_NVME_03.2T",
Price: 100,
}).Error; err != nil {
t.Fatalf("create pricelist item: %v", err)
}
if err := db.Create(&models.Lot{LotName: "SSD_NVME_03.2T"}).Error; err != nil {
t.Fatalf("create lot: %v", err)
}
qty := 5.0
if err := db.Create(&models.StockLog{
Partnumber: "SSD_NVME_03.2T_GEN3_P4610",
Date: time.Now(),
Price: 200,
Qty: &qty,
}).Error; err != nil {
t.Fatalf("create stock log: %v", err)
}
items, total, err := repo.GetItems(warehouse.ID, 0, 20, "")
if err != nil {
t.Fatalf("GetItems: %v", err)
}
if total != 1 {
t.Fatalf("expected total=1, got %d", total)
}
if len(items) != 1 {
t.Fatalf("expected 1 item, got %d", len(items))
}
if items[0].AvailableQty == nil {
t.Fatalf("expected available qty to be set")
}
if *items[0].AvailableQty != 5 {
t.Fatalf("expected available qty=5, got %v", *items[0].AvailableQty)
}
}
func TestGetLatestActiveBySource_SkipsPricelistsWithoutItems(t *testing.T) { func TestGetLatestActiveBySource_SkipsPricelistsWithoutItems(t *testing.T) {
repo := newTestPricelistRepository(t) repo := newTestPricelistRepository(t)
db := repo.db db := repo.db
@@ -228,7 +177,7 @@ func newTestPricelistRepository(t *testing.T) *PricelistRepository {
if err != nil { if err != nil {
t.Fatalf("open sqlite: %v", err) t.Fatalf("open sqlite: %v", err)
} }
if err := db.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}, &models.Lot{}, &models.LotPartnumber{}, &models.StockLog{}); err != nil { if err := db.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}, &models.Lot{}, &models.StockLog{}); err != nil {
t.Fatalf("migrate: %v", err) t.Fatalf("migrate: %v", err)
} }
return NewPricelistRepository(db) return NewPricelistRepository(db)
-62
View File
@@ -1,62 +0,0 @@
package repository
import (
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/gorm"
)
type UserRepository struct {
db *gorm.DB
}
func NewUserRepository(db *gorm.DB) *UserRepository {
return &UserRepository{db: db}
}
func (r *UserRepository) Create(user *models.User) error {
return r.db.Create(user).Error
}
func (r *UserRepository) GetByID(id uint) (*models.User, error) {
var user models.User
err := r.db.First(&user, id).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (r *UserRepository) GetByUsername(username string) (*models.User, error) {
var user models.User
err := r.db.Where("username = ?", username).First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (r *UserRepository) GetByEmail(email string) (*models.User, error) {
var user models.User
err := r.db.Where("email = ?", email).First(&user).Error
if err != nil {
return nil, err
}
return &user, nil
}
func (r *UserRepository) Update(user *models.User) error {
return r.db.Save(user).Error
}
func (r *UserRepository) Delete(id uint) error {
return r.db.Delete(&models.User{}, id).Error
}
func (r *UserRepository) List(offset, limit int) ([]models.User, int64, error) {
var users []models.User
var total int64
r.db.Model(&models.User{}).Count(&total)
err := r.db.Offset(offset).Limit(limit).Find(&users).Error
return users, total, err
}
-180
View File
@@ -1,180 +0,0 @@
package services
import (
"errors"
"time"
"github.com/golang-jwt/jwt/v5"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"golang.org/x/crypto/bcrypt"
)
var (
ErrInvalidCredentials = errors.New("invalid username or password")
ErrUserNotFound = errors.New("user not found")
ErrUserInactive = errors.New("user account is inactive")
ErrInvalidToken = errors.New("invalid token")
ErrTokenExpired = errors.New("token expired")
)
type AuthService struct {
userRepo *repository.UserRepository
config config.AuthConfig
}
func NewAuthService(userRepo *repository.UserRepository, cfg config.AuthConfig) *AuthService {
return &AuthService{
userRepo: userRepo,
config: cfg,
}
}
type TokenPair struct {
AccessToken string `json:"access_token"`
RefreshToken string `json:"refresh_token"`
ExpiresAt int64 `json:"expires_at"`
}
type Claims struct {
UserID uint `json:"user_id"`
Username string `json:"username"`
Role models.UserRole `json:"role"`
jwt.RegisteredClaims
}
func (s *AuthService) Login(username, password string) (*TokenPair, *models.User, error) {
user, err := s.userRepo.GetByUsername(username)
if err != nil {
return nil, nil, ErrInvalidCredentials
}
if !user.IsActive {
return nil, nil, ErrUserInactive
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return nil, nil, ErrInvalidCredentials
}
tokens, err := s.generateTokenPair(user)
if err != nil {
return nil, nil, err
}
return tokens, user, nil
}
func (s *AuthService) RefreshTokens(refreshToken string) (*TokenPair, error) {
claims, err := s.ValidateToken(refreshToken)
if err != nil {
return nil, err
}
user, err := s.userRepo.GetByID(claims.UserID)
if err != nil {
return nil, ErrUserNotFound
}
if !user.IsActive {
return nil, ErrUserInactive
}
return s.generateTokenPair(user)
}
func (s *AuthService) ValidateToken(tokenString string) (*Claims, error) {
token, err := jwt.ParseWithClaims(tokenString, &Claims{}, func(token *jwt.Token) (interface{}, error) {
return []byte(s.config.JWTSecret), nil
})
if err != nil {
if errors.Is(err, jwt.ErrTokenExpired) {
return nil, ErrTokenExpired
}
return nil, ErrInvalidToken
}
claims, ok := token.Claims.(*Claims)
if !ok || !token.Valid {
return nil, ErrInvalidToken
}
return claims, nil
}
func (s *AuthService) generateTokenPair(user *models.User) (*TokenPair, error) {
now := time.Now()
accessExpiry := now.Add(s.config.TokenExpiry)
refreshExpiry := now.Add(s.config.RefreshExpiry)
accessClaims := &Claims{
UserID: user.ID,
Username: user.Username,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(accessExpiry),
IssuedAt: jwt.NewNumericDate(now),
Subject: user.Username,
},
}
accessToken := jwt.NewWithClaims(jwt.SigningMethodHS256, accessClaims)
accessTokenString, err := accessToken.SignedString([]byte(s.config.JWTSecret))
if err != nil {
return nil, err
}
refreshClaims := &Claims{
UserID: user.ID,
Username: user.Username,
Role: user.Role,
RegisteredClaims: jwt.RegisteredClaims{
ExpiresAt: jwt.NewNumericDate(refreshExpiry),
IssuedAt: jwt.NewNumericDate(now),
Subject: user.Username,
},
}
refreshToken := jwt.NewWithClaims(jwt.SigningMethodHS256, refreshClaims)
refreshTokenString, err := refreshToken.SignedString([]byte(s.config.JWTSecret))
if err != nil {
return nil, err
}
return &TokenPair{
AccessToken: accessTokenString,
RefreshToken: refreshTokenString,
ExpiresAt: accessExpiry.Unix(),
}, nil
}
func (s *AuthService) HashPassword(password string) (string, error) {
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return "", err
}
return string(hash), nil
}
func (s *AuthService) CreateUser(username, email, password string, role models.UserRole) (*models.User, error) {
hash, err := s.HashPassword(password)
if err != nil {
return nil, err
}
user := &models.User{
Username: username,
Email: email,
PasswordHash: hash,
Role: role,
IsActive: true,
}
if err := s.userRepo.Create(user); err != nil {
return nil, err
}
return user, nil
}
+61 -46
View File
@@ -45,18 +45,21 @@ func NewConfigurationService(
} }
type CreateConfigRequest struct { type CreateConfigRequest struct {
Name string `json:"name"` Name string `json:"name"`
Items models.ConfigItems `json:"items"` Items models.ConfigItems `json:"items"`
ProjectUUID *string `json:"project_uuid,omitempty"` ProjectUUID *string `json:"project_uuid,omitempty"`
CustomPrice *float64 `json:"custom_price"` CustomPrice *float64 `json:"custom_price"`
Notes string `json:"notes"` Notes string `json:"notes"`
IsTemplate bool `json:"is_template"` IsTemplate bool `json:"is_template"`
ServerCount int `json:"server_count"` ServerCount int `json:"server_count"`
ServerModel string `json:"server_model,omitempty"` ServerModel string `json:"server_model,omitempty"`
SupportCode string `json:"support_code,omitempty"` SupportCode string `json:"support_code,omitempty"`
Article string `json:"article,omitempty"` Article string `json:"article,omitempty"`
PricelistID *uint `json:"pricelist_id,omitempty"` PricelistID *uint `json:"pricelist_id,omitempty"`
OnlyInStock bool `json:"only_in_stock"` WarehousePricelistID *uint `json:"warehouse_pricelist_id,omitempty"`
CompetitorPricelistID *uint `json:"competitor_pricelist_id,omitempty"`
DisablePriceRefresh bool `json:"disable_price_refresh"`
OnlyInStock bool `json:"only_in_stock"`
} }
type ArticlePreviewRequest struct { type ArticlePreviewRequest struct {
@@ -84,21 +87,24 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
} }
config := &models.Configuration{ config := &models.Configuration{
UUID: uuid.New().String(), UUID: uuid.New().String(),
OwnerUsername: ownerUsername, OwnerUsername: ownerUsername,
ProjectUUID: projectUUID, ProjectUUID: projectUUID,
Name: req.Name, Name: req.Name,
Items: req.Items, Items: req.Items,
TotalPrice: &total, TotalPrice: &total,
CustomPrice: req.CustomPrice, CustomPrice: req.CustomPrice,
Notes: req.Notes, Notes: req.Notes,
IsTemplate: req.IsTemplate, IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount, ServerCount: req.ServerCount,
ServerModel: req.ServerModel, ServerModel: req.ServerModel,
SupportCode: req.SupportCode, SupportCode: req.SupportCode,
Article: req.Article, Article: req.Article,
PricelistID: pricelistID, PricelistID: pricelistID,
OnlyInStock: req.OnlyInStock, WarehousePricelistID: req.WarehousePricelistID,
CompetitorPricelistID: req.CompetitorPricelistID,
DisablePriceRefresh: req.DisablePriceRefresh,
OnlyInStock: req.OnlyInStock,
} }
if err := s.configRepo.Create(config); err != nil { if err := s.configRepo.Create(config); err != nil {
@@ -163,6 +169,9 @@ func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *Cr
config.SupportCode = req.SupportCode config.SupportCode = req.SupportCode
config.Article = req.Article config.Article = req.Article
config.PricelistID = pricelistID config.PricelistID = pricelistID
config.WarehousePricelistID = req.WarehousePricelistID
config.CompetitorPricelistID = req.CompetitorPricelistID
config.DisablePriceRefresh = req.DisablePriceRefresh
config.OnlyInStock = req.OnlyInStock config.OnlyInStock = req.OnlyInStock
if err := s.configRepo.Update(config); err != nil { if err := s.configRepo.Update(config); err != nil {
@@ -230,18 +239,24 @@ func (s *ConfigurationService) CloneToProject(configUUID string, ownerUsername s
} }
clone := &models.Configuration{ clone := &models.Configuration{
UUID: uuid.New().String(), UUID: uuid.New().String(),
OwnerUsername: ownerUsername, OwnerUsername: ownerUsername,
ProjectUUID: resolvedProjectUUID, ProjectUUID: resolvedProjectUUID,
Name: newName, Name: newName,
Items: original.Items, Items: original.Items,
TotalPrice: &total, TotalPrice: &total,
CustomPrice: original.CustomPrice, CustomPrice: original.CustomPrice,
Notes: original.Notes, Notes: original.Notes,
IsTemplate: false, // Clone is never a template IsTemplate: false, // Clone is never a template
ServerCount: original.ServerCount, ServerCount: original.ServerCount,
PricelistID: original.PricelistID, ServerModel: original.ServerModel,
OnlyInStock: original.OnlyInStock, SupportCode: original.SupportCode,
Article: original.Article,
PricelistID: original.PricelistID,
WarehousePricelistID: original.WarehousePricelistID,
CompetitorPricelistID: original.CompetitorPricelistID,
DisablePriceRefresh: original.DisablePriceRefresh,
OnlyInStock: original.OnlyInStock,
} }
if err := s.configRepo.Create(clone); err != nil { if err := s.configRepo.Create(clone); err != nil {
@@ -314,7 +329,13 @@ func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigReques
config.Notes = req.Notes config.Notes = req.Notes
config.IsTemplate = req.IsTemplate config.IsTemplate = req.IsTemplate
config.ServerCount = req.ServerCount config.ServerCount = req.ServerCount
config.ServerModel = req.ServerModel
config.SupportCode = req.SupportCode
config.Article = req.Article
config.PricelistID = pricelistID config.PricelistID = pricelistID
config.WarehousePricelistID = req.WarehousePricelistID
config.CompetitorPricelistID = req.CompetitorPricelistID
config.DisablePriceRefresh = req.DisablePriceRefresh
config.OnlyInStock = req.OnlyInStock config.OnlyInStock = req.OnlyInStock
if err := s.configRepo.Update(config); err != nil { if err := s.configRepo.Update(config); err != nil {
@@ -587,13 +608,7 @@ func (s *ConfigurationService) isOwner(config *models.Configuration, ownerUserna
if config == nil || ownerUsername == "" { if config == nil || ownerUsername == "" {
return false return false
} }
if config.OwnerUsername != "" { return config.OwnerUsername == ownerUsername
return config.OwnerUsername == ownerUsername
}
if config.User != nil {
return config.User.Username == ownerUsername
}
return false
} }
// // Export configuration as JSON // // Export configuration as JSON
+517
View File
@@ -55,6 +55,38 @@ type ProjectExportData struct {
CreatedAt time.Time CreatedAt time.Time
} }
type ProjectPricingExportOptions struct {
IncludeLOT bool `json:"include_lot"`
IncludeBOM bool `json:"include_bom"`
IncludeEstimate bool `json:"include_estimate"`
IncludeStock bool `json:"include_stock"`
IncludeCompetitor bool `json:"include_competitor"`
}
type ProjectPricingExportData struct {
Configs []ProjectPricingExportConfig
CreatedAt time.Time
}
type ProjectPricingExportConfig struct {
Name string
Article string
Line int
ServerCount int
Rows []ProjectPricingExportRow
}
type ProjectPricingExportRow struct {
LotDisplay string
VendorPN string
Description string
Quantity int
BOMTotal *float64
Estimate *float64
Stock *float64
Competitor *float64
}
// ToCSV writes project export data in the new structured CSV format. // ToCSV writes project export data in the new structured CSV format.
// //
// Format: // Format:
@@ -168,6 +200,80 @@ func (s *ExportService) ToCSVBytes(data *ProjectExportData) ([]byte, error) {
return buf.Bytes(), nil return buf.Bytes(), nil
} }
func (s *ExportService) ProjectToPricingExportData(configs []models.Configuration, opts ProjectPricingExportOptions) (*ProjectPricingExportData, error) {
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([]ProjectPricingExportConfig, 0, len(sortedConfigs))
for i := range sortedConfigs {
block, err := s.buildPricingExportBlock(&sortedConfigs[i], opts)
if err != nil {
return nil, err
}
blocks = append(blocks, block)
}
return &ProjectPricingExportData{
Configs: blocks,
CreatedAt: time.Now(),
}, nil
}
func (s *ExportService) ToPricingCSV(w io.Writer, data *ProjectPricingExportData, opts ProjectPricingExportOptions) error {
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
return fmt.Errorf("failed to write BOM: %w", err)
}
csvWriter := csv.NewWriter(w)
csvWriter.Comma = ';'
defer csvWriter.Flush()
headers := pricingCSVHeaders(opts)
if err := csvWriter.Write(headers); err != nil {
return fmt.Errorf("failed to write pricing header: %w", err)
}
for idx, cfg := range data.Configs {
if err := csvWriter.Write(pricingConfigSummaryRow(cfg, opts)); err != nil {
return fmt.Errorf("failed to write config summary row: %w", err)
}
for _, row := range cfg.Rows {
if err := csvWriter.Write(pricingCSVRow(row, opts)); err != nil {
return fmt.Errorf("failed to write pricing row: %w", err)
}
}
if idx < len(data.Configs)-1 {
if err := csvWriter.Write([]string{}); err != nil {
return fmt.Errorf("failed to write separator row: %w", err)
}
}
}
csvWriter.Flush()
if err := csvWriter.Error(); err != nil {
return fmt.Errorf("csv writer error: %w", err)
}
return nil
}
// ConfigToExportData converts a single configuration into ProjectExportData. // ConfigToExportData converts a single configuration into ProjectExportData.
func (s *ExportService) ConfigToExportData(cfg *models.Configuration) *ProjectExportData { func (s *ExportService) ConfigToExportData(cfg *models.Configuration) *ProjectExportData {
block := s.buildExportBlock(cfg) block := s.buildExportBlock(cfg)
@@ -247,6 +353,99 @@ func (s *ExportService) buildExportBlock(cfg *models.Configuration) ConfigExport
} }
} }
func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts ProjectPricingExportOptions) (ProjectPricingExportConfig, error) {
block := ProjectPricingExportConfig{
Name: cfg.Name,
Article: cfg.Article,
Line: cfg.Line,
ServerCount: exportPositiveInt(cfg.ServerCount, 1),
Rows: make([]ProjectPricingExportRow, 0),
}
if s.localDB == nil {
for _, item := range cfg.Items {
block.Rows = append(block.Rows, ProjectPricingExportRow{
LotDisplay: item.LotName,
VendorPN: "—",
Quantity: item.Quantity,
Estimate: floatPtr(item.UnitPrice * float64(item.Quantity)),
})
}
return block, nil
}
localCfg, err := s.localDB.GetConfigurationByUUID(cfg.UUID)
if err != nil {
localCfg = nil
}
priceMap := s.resolvePricingTotals(cfg, localCfg, opts)
componentDescriptions := s.resolveLotDescriptions(cfg, localCfg)
if opts.IncludeBOM && localCfg != nil && len(localCfg.VendorSpec) > 0 {
coveredLots := make(map[string]struct{})
for _, row := range localCfg.VendorSpec {
rowMappings := normalizeLotMappings(row.LotMappings)
for _, mapping := range rowMappings {
coveredLots[mapping.LotName] = struct{}{}
}
description := strings.TrimSpace(row.Description)
if description == "" && len(rowMappings) > 0 {
description = componentDescriptions[rowMappings[0].LotName]
}
pricingRow := ProjectPricingExportRow{
LotDisplay: formatLotDisplay(rowMappings),
VendorPN: row.VendorPartnumber,
Description: description,
Quantity: exportPositiveInt(row.Quantity, 1),
BOMTotal: vendorRowTotal(row),
Estimate: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Estimate }),
Stock: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Stock }),
Competitor: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Competitor }),
}
block.Rows = append(block.Rows, pricingRow)
}
for _, item := range cfg.Items {
if item.LotName == "" {
continue
}
if _, ok := coveredLots[item.LotName]; ok {
continue
}
estimate := estimateOnlyTotal(priceMap[item.LotName].Estimate, item.UnitPrice, item.Quantity)
block.Rows = append(block.Rows, ProjectPricingExportRow{
LotDisplay: item.LotName,
VendorPN: "—",
Description: componentDescriptions[item.LotName],
Quantity: exportPositiveInt(item.Quantity, 1),
Estimate: estimate,
Stock: totalForUnitPrice(priceMap[item.LotName].Stock, item.Quantity),
Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity),
})
}
return block, nil
}
for _, item := range cfg.Items {
if item.LotName == "" {
continue
}
estimate := estimateOnlyTotal(priceMap[item.LotName].Estimate, item.UnitPrice, item.Quantity)
block.Rows = append(block.Rows, ProjectPricingExportRow{
LotDisplay: item.LotName,
VendorPN: "—",
Description: componentDescriptions[item.LotName],
Quantity: exportPositiveInt(item.Quantity, 1),
Estimate: estimate,
Stock: totalForUnitPrice(priceMap[item.LotName].Stock, item.Quantity),
Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity),
})
}
return block, nil
}
// resolveCategories returns lot_name → category map. // resolveCategories returns lot_name → category map.
// Primary source: pricelist items (lot_category). Fallback: local_components table. // Primary source: pricelist items (lot_category). Fallback: local_components table.
func (s *ExportService) resolveCategories(pricelistID *uint, lotNames []string) map[string]string { func (s *ExportService) resolveCategories(pricelistID *uint, lotNames []string) map[string]string {
@@ -303,6 +502,324 @@ func sortItemsByCategory(items []ExportItem, categoryOrder map[string]int) {
} }
} }
type pricingLevels struct {
Estimate *float64
Stock *float64
Competitor *float64
}
func (s *ExportService) resolvePricingTotals(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, opts ProjectPricingExportOptions) map[string]pricingLevels {
result := map[string]pricingLevels{}
lots := collectPricingLots(cfg, localCfg, opts.IncludeBOM)
if len(lots) == 0 || s.localDB == nil {
return result
}
estimateID := cfg.PricelistID
if estimateID == nil || *estimateID == 0 {
if latest, err := s.localDB.GetLatestLocalPricelistBySource("estimate"); err == nil && latest != nil {
estimateID = &latest.ServerID
}
}
var warehouseID *uint
var competitorID *uint
if localCfg != nil {
warehouseID = localCfg.WarehousePricelistID
competitorID = localCfg.CompetitorPricelistID
}
if warehouseID == nil || *warehouseID == 0 {
if latest, err := s.localDB.GetLatestLocalPricelistBySource("warehouse"); err == nil && latest != nil {
warehouseID = &latest.ServerID
}
}
if competitorID == nil || *competitorID == 0 {
if latest, err := s.localDB.GetLatestLocalPricelistBySource("competitor"); err == nil && latest != nil {
competitorID = &latest.ServerID
}
}
for _, lot := range lots {
level := pricingLevels{}
level.Estimate = s.lookupPricePointer(estimateID, lot)
level.Stock = s.lookupPricePointer(warehouseID, lot)
level.Competitor = s.lookupPricePointer(competitorID, lot)
result[lot] = level
}
return result
}
func (s *ExportService) lookupPricePointer(serverPricelistID *uint, lotName string) *float64 {
if s.localDB == nil || serverPricelistID == nil || *serverPricelistID == 0 || strings.TrimSpace(lotName) == "" {
return nil
}
localPL, err := s.localDB.GetLocalPricelistByServerID(*serverPricelistID)
if err != nil {
return nil
}
price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName)
if err != nil || price <= 0 {
return nil
}
return floatPtr(price)
}
func (s *ExportService) resolveLotDescriptions(cfg *models.Configuration, localCfg *localdb.LocalConfiguration) map[string]string {
lots := collectPricingLots(cfg, localCfg, true)
result := make(map[string]string, len(lots))
if s.localDB == nil {
return result
}
for _, lot := range lots {
component, err := s.localDB.GetLocalComponent(lot)
if err != nil {
continue
}
result[lot] = component.LotDescription
}
return result
}
func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, includeBOM bool) []string {
seen := map[string]struct{}{}
out := make([]string, 0)
if includeBOM && localCfg != nil {
for _, row := range localCfg.VendorSpec {
for _, mapping := range normalizeLotMappings(row.LotMappings) {
if _, ok := seen[mapping.LotName]; ok {
continue
}
seen[mapping.LotName] = struct{}{}
out = append(out, mapping.LotName)
}
}
}
for _, item := range cfg.Items {
lot := strings.TrimSpace(item.LotName)
if lot == "" {
continue
}
if _, ok := seen[lot]; ok {
continue
}
seen[lot] = struct{}{}
out = append(out, lot)
}
return out
}
func normalizeLotMappings(mappings []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
if len(mappings) == 0 {
return nil
}
out := make([]localdb.VendorSpecLotMapping, 0, len(mappings))
for _, mapping := range mappings {
lot := strings.TrimSpace(mapping.LotName)
if lot == "" {
continue
}
qty := mapping.QuantityPerPN
if qty < 1 {
qty = 1
}
out = append(out, localdb.VendorSpecLotMapping{
LotName: lot,
QuantityPerPN: qty,
})
}
return out
}
func vendorRowTotal(row localdb.VendorSpecItem) *float64 {
if row.TotalPrice != nil {
return floatPtr(*row.TotalPrice)
}
if row.UnitPrice == nil {
return nil
}
return floatPtr(*row.UnitPrice * float64(exportPositiveInt(row.Quantity, 1)))
}
func computeMappingTotal(priceMap map[string]pricingLevels, mappings []localdb.VendorSpecLotMapping, pnQty int, selector func(pricingLevels) *float64) *float64 {
if len(mappings) == 0 {
return nil
}
total := 0.0
hasValue := false
qty := exportPositiveInt(pnQty, 1)
for _, mapping := range mappings {
price := selector(priceMap[mapping.LotName])
if price == nil || *price <= 0 {
continue
}
total += *price * float64(qty*mapping.QuantityPerPN)
hasValue = true
}
if !hasValue {
return nil
}
return floatPtr(total)
}
func totalForUnitPrice(unitPrice *float64, quantity int) *float64 {
if unitPrice == nil || *unitPrice <= 0 {
return nil
}
total := *unitPrice * float64(exportPositiveInt(quantity, 1))
return &total
}
func estimateOnlyTotal(estimatePrice *float64, fallbackUnitPrice float64, quantity int) *float64 {
if estimatePrice != nil && *estimatePrice > 0 {
return totalForUnitPrice(estimatePrice, quantity)
}
if fallbackUnitPrice <= 0 {
return nil
}
total := fallbackUnitPrice * float64(maxInt(quantity, 1))
return &total
}
func pricingCSVHeaders(opts ProjectPricingExportOptions) []string {
headers := make([]string, 0, 8)
headers = append(headers, "Line Item")
if opts.IncludeLOT {
headers = append(headers, "LOT")
}
headers = append(headers, "PN вендора", "Описание", "Кол-во")
if opts.IncludeBOM {
headers = append(headers, "BOM")
}
if opts.IncludeEstimate {
headers = append(headers, "Estimate")
}
if opts.IncludeStock {
headers = append(headers, "Stock")
}
if opts.IncludeCompetitor {
headers = append(headers, "Конкуренты")
}
return headers
}
func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions) []string {
record := make([]string, 0, 8)
record = append(record, "")
if opts.IncludeLOT {
record = append(record, emptyDash(row.LotDisplay))
}
record = append(record,
emptyDash(row.VendorPN),
emptyDash(row.Description),
fmt.Sprintf("%d", exportPositiveInt(row.Quantity, 1)),
)
if opts.IncludeBOM {
record = append(record, formatMoneyValue(row.BOMTotal))
}
if opts.IncludeEstimate {
record = append(record, formatMoneyValue(row.Estimate))
}
if opts.IncludeStock {
record = append(record, formatMoneyValue(row.Stock))
}
if opts.IncludeCompetitor {
record = append(record, formatMoneyValue(row.Competitor))
}
return record
}
func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricingExportOptions) []string {
record := make([]string, 0, 8)
record = append(record, fmt.Sprintf("%d", cfg.Line))
if opts.IncludeLOT {
record = append(record, "")
}
record = append(record,
"",
emptyDash(cfg.Name),
fmt.Sprintf("%d", exportPositiveInt(cfg.ServerCount, 1)),
)
if opts.IncludeBOM {
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.BOMTotal })))
}
if opts.IncludeEstimate {
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Estimate })))
}
if opts.IncludeStock {
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Stock })))
}
if opts.IncludeCompetitor {
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Competitor })))
}
return record
}
func formatLotDisplay(mappings []localdb.VendorSpecLotMapping) string {
switch len(mappings) {
case 0:
return "н/д"
case 1:
return mappings[0].LotName
default:
return fmt.Sprintf("%s +%d", mappings[0].LotName, len(mappings)-1)
}
}
func formatMoneyValue(value *float64) string {
if value == nil {
return "—"
}
n := math.Round(*value*100) / 100
sign := ""
if n < 0 {
sign = "-"
n = -n
}
whole := int64(n)
fraction := int(math.Round((n - float64(whole)) * 100))
if fraction == 100 {
whole++
fraction = 0
}
return fmt.Sprintf("%s%s,%02d", sign, formatIntWithSpace(whole), fraction)
}
func emptyDash(value string) string {
if strings.TrimSpace(value) == "" {
return "—"
}
return value
}
func sumPricingColumn(rows []ProjectPricingExportRow, selector func(ProjectPricingExportRow) *float64) *float64 {
total := 0.0
hasValue := false
for _, row := range rows {
value := selector(row)
if value == nil {
continue
}
total += *value
hasValue = true
}
if !hasValue {
return nil
}
return floatPtr(total)
}
func floatPtr(value float64) *float64 {
v := value
return &v
}
func exportPositiveInt(value, fallback int) int {
if value < 1 {
return fallback
}
return value
}
// formatPriceComma formats a price with comma as decimal separator (e.g., "2074,5"). // formatPriceComma formats a price with comma as decimal separator (e.g., "2074,5").
// Trailing zeros after the comma are trimmed, and if the value is an integer, no comma is shown. // Trailing zeros after the comma are trimmed, and if the value is an integer, no comma is shown.
func formatPriceComma(value float64) string { func formatPriceComma(value float64) string {
+111
View File
@@ -444,6 +444,117 @@ func TestFormatPriceComma(t *testing.T) {
} }
} }
func TestToPricingCSV_UsesSelectedColumns(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil, nil)
data := &ProjectPricingExportData{
Configs: []ProjectPricingExportConfig{
{
Name: "Config A",
Article: "ART-1",
Line: 10,
ServerCount: 2,
Rows: []ProjectPricingExportRow{
{
LotDisplay: "LOT_A +1",
VendorPN: "PN-001",
Description: "Bundle row",
Quantity: 2,
BOMTotal: floatPtr(2400.5),
Estimate: floatPtr(2000),
Stock: floatPtr(1800.25),
},
},
},
},
CreatedAt: time.Now(),
}
opts := ProjectPricingExportOptions{
IncludeLOT: true,
IncludeBOM: true,
IncludeEstimate: true,
IncludeStock: true,
}
var buf bytes.Buffer
if err := svc.ToPricingCSV(&buf, data, opts); err != nil {
t.Fatalf("ToPricingCSV failed: %v", err)
}
reader := csv.NewReader(bytes.NewReader(buf.Bytes()[3:]))
reader.Comma = ';'
reader.FieldsPerRecord = -1
header, err := reader.Read()
if err != nil {
t.Fatalf("read header row: %v", err)
}
expectedHeader := []string{"Line Item", "LOT", "PN вендора", "Описание", "Кол-во", "BOM", "Estimate", "Stock"}
for i, want := range expectedHeader {
if header[i] != want {
t.Fatalf("header[%d]: expected %q, got %q", i, want, header[i])
}
}
summary, err := reader.Read()
if err != nil {
t.Fatalf("read summary row: %v", err)
}
expectedSummary := []string{"10", "", "", "Config A", "2", "2 400,50", "2 000,00", "1 800,25"}
for i, want := range expectedSummary {
if summary[i] != want {
t.Fatalf("summary[%d]: expected %q, got %q", i, want, summary[i])
}
}
row, err := reader.Read()
if err != nil {
t.Fatalf("read data row: %v", err)
}
expectedRow := []string{"", "LOT_A +1", "PN-001", "Bundle row", "2", "2 400,50", "2 000,00", "1 800,25"}
for i, want := range expectedRow {
if row[i] != want {
t.Fatalf("row[%d]: expected %q, got %q", i, want, row[i])
}
}
}
func TestProjectToPricingExportData_UsesCartRowsWithoutBOM(t *testing.T) {
svc := NewExportService(config.ExportConfig{}, nil, nil)
configs := []models.Configuration{
{
UUID: "cfg-1",
Name: "Config A",
Article: "ART-1",
ServerCount: 1,
Items: models.ConfigItems{
{LotName: "LOT_A", Quantity: 2, UnitPrice: 300},
},
CreatedAt: time.Now(),
},
}
data, err := svc.ProjectToPricingExportData(configs, ProjectPricingExportOptions{
IncludeLOT: true,
IncludeEstimate: true,
})
if err != nil {
t.Fatalf("ProjectToPricingExportData failed: %v", err)
}
if len(data.Configs) != 1 || len(data.Configs[0].Rows) != 1 {
t.Fatalf("unexpected rows count: %+v", data.Configs)
}
row := data.Configs[0].Rows[0]
if row.LotDisplay != "LOT_A" {
t.Fatalf("expected LOT_A, got %q", row.LotDisplay)
}
if row.VendorPN != "—" {
t.Fatalf("expected vendor dash, got %q", row.VendorPN)
}
if row.Estimate == nil || *row.Estimate != 600 {
t.Fatalf("expected estimate total 600, got %+v", row.Estimate)
}
}
// failingWriter always returns an error // failingWriter always returns an error
type failingWriter struct{} type failingWriter struct{}
+105 -75
View File
@@ -83,22 +83,25 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
} }
cfg := &models.Configuration{ cfg := &models.Configuration{
UUID: uuid.New().String(), UUID: uuid.New().String(),
OwnerUsername: ownerUsername, OwnerUsername: ownerUsername,
ProjectUUID: projectUUID, ProjectUUID: projectUUID,
Name: req.Name, Name: req.Name,
Items: req.Items, Items: req.Items,
TotalPrice: &total, TotalPrice: &total,
CustomPrice: req.CustomPrice, CustomPrice: req.CustomPrice,
Notes: req.Notes, Notes: req.Notes,
IsTemplate: req.IsTemplate, IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount, ServerCount: req.ServerCount,
ServerModel: req.ServerModel, ServerModel: req.ServerModel,
SupportCode: req.SupportCode, SupportCode: req.SupportCode,
Article: req.Article, Article: req.Article,
PricelistID: pricelistID, PricelistID: pricelistID,
OnlyInStock: req.OnlyInStock, WarehousePricelistID: req.WarehousePricelistID,
CreatedAt: time.Now(), CompetitorPricelistID: req.CompetitorPricelistID,
DisablePriceRefresh: req.DisablePriceRefresh,
OnlyInStock: req.OnlyInStock,
CreatedAt: time.Now(),
} }
// Convert to local model // Convert to local model
@@ -196,6 +199,9 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
localCfg.SupportCode = req.SupportCode localCfg.SupportCode = req.SupportCode
localCfg.Article = req.Article localCfg.Article = req.Article
localCfg.PricelistID = pricelistID localCfg.PricelistID = pricelistID
localCfg.WarehousePricelistID = req.WarehousePricelistID
localCfg.CompetitorPricelistID = req.CompetitorPricelistID
localCfg.DisablePriceRefresh = req.DisablePriceRefresh
localCfg.OnlyInStock = req.OnlyInStock localCfg.OnlyInStock = req.OnlyInStock
localCfg.UpdatedAt = time.Now() localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending" localCfg.SyncStatus = "pending"
@@ -304,22 +310,25 @@ func (s *LocalConfigurationService) CloneToProject(configUUID string, ownerUsern
} }
clone := &models.Configuration{ clone := &models.Configuration{
UUID: uuid.New().String(), UUID: uuid.New().String(),
OwnerUsername: ownerUsername, OwnerUsername: ownerUsername,
ProjectUUID: resolvedProjectUUID, ProjectUUID: resolvedProjectUUID,
Name: newName, Name: newName,
Items: original.Items, Items: original.Items,
TotalPrice: &total, TotalPrice: &total,
CustomPrice: original.CustomPrice, CustomPrice: original.CustomPrice,
Notes: original.Notes, Notes: original.Notes,
IsTemplate: false, IsTemplate: false,
ServerCount: original.ServerCount, ServerCount: original.ServerCount,
ServerModel: original.ServerModel, ServerModel: original.ServerModel,
SupportCode: original.SupportCode, SupportCode: original.SupportCode,
Article: original.Article, Article: original.Article,
PricelistID: original.PricelistID, PricelistID: original.PricelistID,
OnlyInStock: original.OnlyInStock, WarehousePricelistID: original.WarehousePricelistID,
CreatedAt: time.Now(), CompetitorPricelistID: original.CompetitorPricelistID,
DisablePriceRefresh: original.DisablePriceRefresh,
OnlyInStock: original.OnlyInStock,
CreatedAt: time.Now(),
} }
localCfg := localdb.ConfigurationToLocal(clone) localCfg := localdb.ConfigurationToLocal(clone)
@@ -521,6 +530,9 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
localCfg.SupportCode = req.SupportCode localCfg.SupportCode = req.SupportCode
localCfg.Article = req.Article localCfg.Article = req.Article
localCfg.PricelistID = pricelistID localCfg.PricelistID = pricelistID
localCfg.WarehousePricelistID = req.WarehousePricelistID
localCfg.CompetitorPricelistID = req.CompetitorPricelistID
localCfg.DisablePriceRefresh = req.DisablePriceRefresh
localCfg.OnlyInStock = req.OnlyInStock localCfg.OnlyInStock = req.OnlyInStock
localCfg.UpdatedAt = time.Now() localCfg.UpdatedAt = time.Now()
localCfg.SyncStatus = "pending" localCfg.SyncStatus = "pending"
@@ -623,19 +635,25 @@ func (s *LocalConfigurationService) CloneNoAuthToProjectFromVersion(configUUID s
} }
clone := &models.Configuration{ clone := &models.Configuration{
UUID: uuid.New().String(), UUID: uuid.New().String(),
OwnerUsername: ownerUsername, OwnerUsername: ownerUsername,
ProjectUUID: resolvedProjectUUID, ProjectUUID: resolvedProjectUUID,
Name: newName, Name: newName,
Items: original.Items, Items: original.Items,
TotalPrice: &total, TotalPrice: &total,
CustomPrice: original.CustomPrice, CustomPrice: original.CustomPrice,
Notes: original.Notes, Notes: original.Notes,
IsTemplate: false, IsTemplate: false,
ServerCount: original.ServerCount, ServerCount: original.ServerCount,
PricelistID: original.PricelistID, ServerModel: original.ServerModel,
OnlyInStock: original.OnlyInStock, SupportCode: original.SupportCode,
CreatedAt: time.Now(), Article: original.Article,
PricelistID: original.PricelistID,
WarehousePricelistID: original.WarehousePricelistID,
CompetitorPricelistID: original.CompetitorPricelistID,
DisablePriceRefresh: original.DisablePriceRefresh,
OnlyInStock: original.OnlyInStock,
CreatedAt: time.Now(),
} }
localCfg := localdb.ConfigurationToLocal(clone) localCfg := localdb.ConfigurationToLocal(clone)
@@ -1053,39 +1071,43 @@ func (s *LocalConfigurationService) isOwner(cfg *localdb.LocalConfiguration, own
func (s *LocalConfigurationService) createWithVersion(localCfg *localdb.LocalConfiguration, createdBy string) error { func (s *LocalConfigurationService) createWithVersion(localCfg *localdb.LocalConfiguration, createdBy string) error {
return s.localDB.DB().Transaction(func(tx *gorm.DB) error { return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
if localCfg.IsActive { return s.createWithVersionTx(tx, localCfg, createdBy)
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)
}
version, err := s.appendVersionTx(tx, localCfg, "create", createdBy)
if err != nil {
return fmt.Errorf("append create version: %w", err)
}
if err := tx.Model(&localdb.LocalConfiguration{}).
Where("uuid = ?", localCfg.UUID).
Update("current_version_id", version.ID).Error; err != nil {
return fmt.Errorf("set current version id: %w", err)
}
localCfg.CurrentVersionID = &version.ID
localCfg.CurrentVersion = version
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "create", version, createdBy); err != nil {
return fmt.Errorf("enqueue create pending change: %w", err)
}
if err := s.recalculateLocalPricelistUsageTx(tx); err != nil {
return fmt.Errorf("recalculate local pricelist usage: %w", err)
}
return nil
}) })
} }
func (s *LocalConfigurationService) createWithVersionTx(tx *gorm.DB, localCfg *localdb.LocalConfiguration, createdBy string) 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)
}
version, err := s.appendVersionTx(tx, localCfg, "create", createdBy)
if err != nil {
return fmt.Errorf("append create version: %w", err)
}
if err := tx.Model(&localdb.LocalConfiguration{}).
Where("uuid = ?", localCfg.UUID).
Update("current_version_id", version.ID).Error; err != nil {
return fmt.Errorf("set current version id: %w", err)
}
localCfg.CurrentVersionID = &version.ID
localCfg.CurrentVersion = version
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "create", version, createdBy); err != nil {
return fmt.Errorf("enqueue create pending change: %w", err)
}
if err := s.recalculateLocalPricelistUsageTx(tx); err != nil {
return fmt.Errorf("recalculate local pricelist usage: %w", err)
}
return nil
}
func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.LocalConfiguration, operation string, createdBy string) (*models.Configuration, error) { func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.LocalConfiguration, operation string, createdBy string) (*models.Configuration, error) {
var cfg *models.Configuration var cfg *models.Configuration
@@ -1183,6 +1205,7 @@ func hasNonRevisionConfigurationChanges(current *localdb.LocalConfiguration, nex
current.ServerModel != next.ServerModel || current.ServerModel != next.ServerModel ||
current.SupportCode != next.SupportCode || current.SupportCode != next.SupportCode ||
current.Article != next.Article || current.Article != next.Article ||
current.DisablePriceRefresh != next.DisablePriceRefresh ||
current.OnlyInStock != next.OnlyInStock || current.OnlyInStock != next.OnlyInStock ||
current.IsActive != next.IsActive || current.IsActive != next.IsActive ||
current.Line != next.Line { current.Line != next.Line {
@@ -1419,8 +1442,15 @@ func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string,
current.Notes = rollbackData.Notes current.Notes = rollbackData.Notes
current.IsTemplate = rollbackData.IsTemplate current.IsTemplate = rollbackData.IsTemplate
current.ServerCount = rollbackData.ServerCount current.ServerCount = rollbackData.ServerCount
current.ServerModel = rollbackData.ServerModel
current.SupportCode = rollbackData.SupportCode
current.Article = rollbackData.Article
current.PricelistID = rollbackData.PricelistID current.PricelistID = rollbackData.PricelistID
current.WarehousePricelistID = rollbackData.WarehousePricelistID
current.CompetitorPricelistID = rollbackData.CompetitorPricelistID
current.DisablePriceRefresh = rollbackData.DisablePriceRefresh
current.OnlyInStock = rollbackData.OnlyInStock current.OnlyInStock = rollbackData.OnlyInStock
current.VendorSpec = rollbackData.VendorSpec
if rollbackData.Line > 0 { if rollbackData.Line > 0 {
current.Line = rollbackData.Line current.Line = rollbackData.Line
} }
+57 -31
View File
@@ -1,6 +1,7 @@
package sync package sync
import ( import (
"encoding/json"
"fmt" "fmt"
"log/slog" "log/slog"
"time" "time"
@@ -23,13 +24,14 @@ func (s *Service) PullPartnumberBooks() (int, error) {
localBookRepo := repository.NewPartnumberBookRepository(s.localDB.DB()) localBookRepo := repository.NewPartnumberBookRepository(s.localDB.DB())
type serverBook struct { type serverBook struct {
ID int `gorm:"column:id"` ID int `gorm:"column:id"`
Version string `gorm:"column:version"` Version string `gorm:"column:version"`
CreatedAt time.Time `gorm:"column:created_at"` CreatedAt time.Time `gorm:"column:created_at"`
IsActive bool `gorm:"column:is_active"` IsActive bool `gorm:"column:is_active"`
PartnumbersJSON string `gorm:"column:partnumbers_json"`
} }
var serverBooks []serverBook 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 { if err := mariaDB.Raw("SELECT id, version, created_at, is_active, partnumbers_json 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) return 0, fmt.Errorf("querying server partnumber books: %w", err)
} }
slog.Info("partnumber books found on server", "count", len(serverBooks)) slog.Info("partnumber books found on server", "count", len(serverBooks))
@@ -38,16 +40,28 @@ func (s *Service) PullPartnumberBooks() (int, error) {
for _, sb := range serverBooks { for _, sb := range serverBooks {
var existing localdb.LocalPartnumberBook var existing localdb.LocalPartnumberBook
err := s.localDB.DB().Where("server_id = ?", sb.ID).First(&existing).Error err := s.localDB.DB().Where("server_id = ?", sb.ID).First(&existing).Error
partnumbers, errPartnumbers := decodeServerPartnumbers(sb.PartnumbersJSON)
if errPartnumbers != nil {
slog.Error("failed to decode server partnumbers_json", "server_id", sb.ID, "error", errPartnumbers)
continue
}
if err == nil { if err == nil {
// Header exists — check whether items were saved existing.Version = sb.Version
existing.CreatedAt = sb.CreatedAt
existing.IsActive = sb.IsActive
existing.PartnumbersJSON = partnumbers
if err := localBookRepo.SaveBook(&existing); err != nil {
slog.Error("failed to update local partnumber book header", "server_id", sb.ID, "error", err)
continue
}
localItemCount := localBookRepo.CountBookItems(existing.ID) localItemCount := localBookRepo.CountBookItems(existing.ID)
if localItemCount > 0 { if localItemCount > 0 && localBookRepo.HasAllBookItems(existing.ID) {
slog.Debug("partnumber book already synced, skipping", "server_id", sb.ID, "version", sb.Version, "items", localItemCount) slog.Debug("partnumber book already synced, skipping", "server_id", sb.ID, "version", sb.Version, "items", localItemCount)
continue continue
} }
// Items missing re-pull them slog.Info("partnumber book header exists but catalog items are missing, re-pulling items", "server_id", sb.ID, "version", sb.Version)
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, existing.PartnumbersJSON)
n, err := pullBookItems(mariaDB, localBookRepo, sb.ID, existing.ID)
if err != nil { if err != nil {
slog.Error("failed to re-pull items for existing book", "server_id", sb.ID, "error", err) slog.Error("failed to re-pull items for existing book", "server_id", sb.ID, "error", err)
} else { } else {
@@ -60,17 +74,18 @@ func (s *Service) PullPartnumberBooks() (int, error) {
slog.Info("pulling new partnumber book", "server_id", sb.ID, "version", sb.Version, "is_active", sb.IsActive) slog.Info("pulling new partnumber book", "server_id", sb.ID, "version", sb.Version, "is_active", sb.IsActive)
localBook := &localdb.LocalPartnumberBook{ localBook := &localdb.LocalPartnumberBook{
ServerID: sb.ID, ServerID: sb.ID,
Version: sb.Version, Version: sb.Version,
CreatedAt: sb.CreatedAt, CreatedAt: sb.CreatedAt,
IsActive: sb.IsActive, IsActive: sb.IsActive,
PartnumbersJSON: partnumbers,
} }
if err := localBookRepo.SaveBook(localBook); err != nil { if err := localBookRepo.SaveBook(localBook); err != nil {
slog.Error("failed to save local partnumber book", "server_id", sb.ID, "error", err) slog.Error("failed to save local partnumber book", "server_id", sb.ID, "error", err)
continue continue
} }
n, err := pullBookItems(mariaDB, localBookRepo, sb.ID, localBook.ID) n, err := pullBookItems(mariaDB, localBookRepo, localBook.PartnumbersJSON)
if err != nil { if err != nil {
slog.Error("failed to pull items for new book", "server_id", sb.ID, "error", err) slog.Error("failed to pull items for new book", "server_id", sb.ID, "error", err)
continue continue
@@ -84,39 +99,39 @@ func (s *Service) PullPartnumberBooks() (int, error) {
return pulled, nil return pulled, nil
} }
// pullBookItems fetches items for a single book from MariaDB and saves them to SQLite. // pullBookItems fetches catalog items for a partnumber list from MariaDB and saves them to SQLite.
// Returns the number of items saved. // Returns the number of items saved.
func pullBookItems(mariaDB *gorm.DB, repo *repository.PartnumberBookRepository, serverBookID int, localBookID uint) (int, error) { func pullBookItems(mariaDB *gorm.DB, repo *repository.PartnumberBookRepository, partnumbers localdb.LocalStringList) (int, error) {
if len(partnumbers) == 0 {
return 0, nil
}
type serverItem struct { type serverItem struct {
Partnumber string `gorm:"column:partnumber"` Partnumber string `gorm:"column:partnumber"`
LotName string `gorm:"column:lot_name"` LotsJSON string `gorm:"column:lots_json"`
IsPrimaryPN bool `gorm:"column:is_primary_pn"`
Description string `gorm:"column:description"` 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 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 err := mariaDB.Raw("SELECT partnumber, lots_json, description FROM qt_partnumber_book_items WHERE partnumber IN ?", []string(partnumbers)).Scan(&serverItems).Error
if err != nil { if err != nil {
slog.Warn("description column not available on server, retrying without it", "server_book_id", serverBookID, "error", err) return 0, fmt.Errorf("querying items from server: %w", 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)) slog.Info("partnumber book items fetched from server", "count", len(serverItems), "requested_partnumbers", len(partnumbers))
if len(serverItems) == 0 { if len(serverItems) == 0 {
slog.Warn("server returned 0 items for book — check qt_partnumber_book_items on server", "server_book_id", serverBookID) slog.Warn("server returned 0 partnumber book items")
return 0, nil return 0, nil
} }
localItems := make([]localdb.LocalPartnumberBookItem, 0, len(serverItems)) localItems := make([]localdb.LocalPartnumberBookItem, 0, len(serverItems))
for _, si := range serverItems { for _, si := range serverItems {
var lots localdb.LocalPartnumberBookLots
if err := json.Unmarshal([]byte(si.LotsJSON), &lots); err != nil {
return 0, fmt.Errorf("decode lots_json for %s: %w", si.Partnumber, err)
}
localItems = append(localItems, localdb.LocalPartnumberBookItem{ localItems = append(localItems, localdb.LocalPartnumberBookItem{
BookID: localBookID,
Partnumber: si.Partnumber, Partnumber: si.Partnumber,
LotName: si.LotName, LotsJSON: lots,
IsPrimaryPN: si.IsPrimaryPN,
Description: si.Description, Description: si.Description,
}) })
} }
@@ -125,3 +140,14 @@ func pullBookItems(mariaDB *gorm.DB, repo *repository.PartnumberBookRepository,
} }
return len(localItems), nil return len(localItems), nil
} }
func decodeServerPartnumbers(raw string) (localdb.LocalStringList, error) {
if raw == "" {
return localdb.LocalStringList{}, nil
}
var items []string
if err := json.Unmarshal([]byte(raw), &items); err != nil {
return nil, err
}
return localdb.LocalStringList(items), nil
}
+3 -5
View File
@@ -14,7 +14,7 @@ type SeenPartnumber struct {
} }
// PushPartnumberSeen inserts unresolved vendor partnumbers into qt_vendor_partnumber_seen on MariaDB. // 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. // Existing rows are left untouched: no updates to last_seen_at, is_ignored, or description.
func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error { func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
if len(items) == 0 { if len(items) == 0 {
return nil return nil
@@ -36,12 +36,10 @@ func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
VALUES VALUES
('manual', '', ?, ?, ?, ?) ('manual', '', ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
last_seen_at = VALUES(last_seen_at), partnumber = partnumber
is_ignored = VALUES(is_ignored),
description = COALESCE(NULLIF(VALUES(description), ''), description)
`, item.Partnumber, item.Description, item.Ignored, now).Error `, item.Partnumber, item.Description, item.Ignored, now).Error
if err != nil { if err != nil {
slog.Error("failed to upsert partnumber_seen", "partnumber", item.Partnumber, "error", err) slog.Error("failed to insert partnumber_seen", "partnumber", item.Partnumber, "error", err)
// Continue with remaining items // Continue with remaining items
} }
} }
+141 -236
View File
@@ -1,13 +1,10 @@
package sync package sync
import ( import (
"bufio"
"crypto/sha256"
"encoding/hex"
"errors" "errors"
"fmt" "fmt"
"log/slog" "log/slog"
"strconv" "os"
"strings" "strings"
"time" "time"
@@ -79,48 +76,6 @@ func (s *Service) GetReadiness() (*SyncReadiness, error) {
) )
} }
migrations, err := listActiveClientMigrations(mariaDB)
if err != nil {
return s.blockedReadiness(
now,
"REMOTE_MIGRATION_REGISTRY_UNAVAILABLE",
"Синхронизация заблокирована: не удалось проверить централизованные миграции локальной БД.",
nil,
)
}
for i := range migrations {
m := migrations[i]
if strings.TrimSpace(m.MinAppVersion) != "" {
if compareVersions(appmeta.Version(), m.MinAppVersion) < 0 {
min := m.MinAppVersion
return s.blockedReadiness(
now,
"MIN_APP_VERSION_REQUIRED",
fmt.Sprintf("Требуется обновление приложения до версии %s для безопасной синхронизации.", m.MinAppVersion),
&min,
)
}
}
}
if err := s.applyMissingRemoteMigrations(migrations); err != nil {
if strings.Contains(strings.ToLower(err.Error()), "checksum") {
return s.blockedReadiness(
now,
"REMOTE_MIGRATION_CHECKSUM_MISMATCH",
"Синхронизация заблокирована: контрольная сумма миграции не совпадает.",
nil,
)
}
return s.blockedReadiness(
now,
"LOCAL_MIGRATION_APPLY_FAILED",
"Синхронизация заблокирована: не удалось применить миграции локальной БД.",
nil,
)
}
if err := s.reportClientSchemaState(mariaDB, now); err != nil { if err := s.reportClientSchemaState(mariaDB, now); err != nil {
slog.Warn("failed to report client schema state", "error", err) slog.Warn("failed to report client schema state", "error", err)
} }
@@ -157,73 +112,68 @@ func (s *Service) isOnline() bool {
return s.connMgr.IsOnline() return s.connMgr.IsOnline()
} }
type clientLocalMigration struct { func ensureClientSchemaStateTable(db *gorm.DB) error {
ID string `gorm:"column:id"`
Name string `gorm:"column:name"`
SQLText string `gorm:"column:sql_text"`
Checksum string `gorm:"column:checksum"`
MinAppVersion string `gorm:"column:min_app_version"`
OrderNo int `gorm:"column:order_no"`
CreatedAt time.Time `gorm:"column:created_at"`
}
func listActiveClientMigrations(db *gorm.DB) ([]clientLocalMigration, error) {
if strings.EqualFold(db.Dialector.Name(), "sqlite") {
return []clientLocalMigration{}, nil
}
if err := ensureClientMigrationRegistryTable(db); err != nil {
return nil, err
}
rows := make([]clientLocalMigration, 0)
if err := db.Raw(`
SELECT id, name, sql_text, checksum, COALESCE(min_app_version, '') AS min_app_version, order_no, created_at
FROM qt_client_local_migrations
WHERE is_active = 1
ORDER BY order_no ASC, created_at ASC, id ASC
`).Scan(&rows).Error; err != nil {
return nil, fmt.Errorf("load client local migrations: %w", err)
}
return rows, nil
}
func ensureClientMigrationRegistryTable(db *gorm.DB) error {
// Check if table exists instead of trying to create (avoids permission issues)
if !tableExists(db, "qt_client_local_migrations") {
if err := db.Exec(`
CREATE TABLE IF NOT EXISTS qt_client_local_migrations (
id VARCHAR(128) NOT NULL,
name VARCHAR(255) NOT NULL,
sql_text LONGTEXT NOT NULL,
checksum VARCHAR(128) NOT NULL,
min_app_version VARCHAR(64) NULL,
order_no INT NOT NULL DEFAULT 0,
is_active TINYINT(1) NOT NULL DEFAULT 1,
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
PRIMARY KEY (id),
INDEX idx_qt_client_local_migrations_active_order (is_active, order_no, created_at)
)
`).Error; err != nil {
return fmt.Errorf("create qt_client_local_migrations table: %w", err)
}
}
if !tableExists(db, "qt_client_schema_state") { if !tableExists(db, "qt_client_schema_state") {
if err := db.Exec(` if err := db.Exec(`
CREATE TABLE IF NOT EXISTS qt_client_schema_state ( CREATE TABLE IF NOT EXISTS qt_client_schema_state (
username VARCHAR(100) NOT NULL, username VARCHAR(100) NOT NULL,
last_applied_migration_id VARCHAR(128) NULL, hostname VARCHAR(255) NOT NULL DEFAULT '',
app_version VARCHAR(64) NULL, app_version VARCHAR(64) NULL,
last_sync_at DATETIME NULL,
last_sync_status VARCHAR(32) NULL,
pending_changes_count INT NOT NULL DEFAULT 0,
pending_errors_count INT NOT NULL DEFAULT 0,
configurations_count INT NOT NULL DEFAULT 0,
projects_count INT NOT NULL DEFAULT 0,
estimate_pricelist_version VARCHAR(128) NULL,
warehouse_pricelist_version VARCHAR(128) NULL,
competitor_pricelist_version VARCHAR(128) NULL,
last_sync_error_code VARCHAR(128) NULL,
last_sync_error_text TEXT NULL,
last_checked_at DATETIME NOT NULL, last_checked_at DATETIME NOT NULL,
updated_at DATETIME NOT NULL, updated_at DATETIME NOT NULL,
PRIMARY KEY (username), PRIMARY KEY (username, hostname),
INDEX idx_qt_client_schema_state_checked (last_checked_at) INDEX idx_qt_client_schema_state_checked (last_checked_at)
) )
`).Error; err != nil { `).Error; err != nil {
return fmt.Errorf("create qt_client_schema_state table: %w", err) return fmt.Errorf("create qt_client_schema_state table: %w", err)
} }
} }
if tableExists(db, "qt_client_schema_state") {
if err := db.Exec(`
ALTER TABLE qt_client_schema_state
ADD COLUMN IF NOT EXISTS hostname VARCHAR(255) NOT NULL DEFAULT '' AFTER username
`).Error; err != nil {
return fmt.Errorf("add qt_client_schema_state.hostname: %w", err)
}
if err := db.Exec(`
ALTER TABLE qt_client_schema_state
DROP PRIMARY KEY,
ADD PRIMARY KEY (username, hostname)
`).Error; err != nil && !isDuplicatePrimaryKeyDefinition(err) {
return fmt.Errorf("set qt_client_schema_state primary key: %w", err)
}
for _, stmt := range []string{
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_at DATETIME NULL AFTER app_version",
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_status VARCHAR(32) NULL AFTER last_sync_at",
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS pending_changes_count INT NOT NULL DEFAULT 0 AFTER last_sync_status",
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS pending_errors_count INT NOT NULL DEFAULT 0 AFTER pending_changes_count",
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS configurations_count INT NOT NULL DEFAULT 0 AFTER pending_errors_count",
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS projects_count INT NOT NULL DEFAULT 0 AFTER configurations_count",
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS estimate_pricelist_version VARCHAR(128) NULL AFTER projects_count",
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS warehouse_pricelist_version VARCHAR(128) NULL AFTER estimate_pricelist_version",
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS competitor_pricelist_version VARCHAR(128) NULL AFTER warehouse_pricelist_version",
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_code VARCHAR(128) NULL AFTER competitor_pricelist_version",
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_text TEXT NULL AFTER last_sync_error_code",
} {
if err := db.Exec(stmt).Error; err != nil {
return fmt.Errorf("expand qt_client_schema_state: %w", err)
}
}
}
return nil return nil
} }
@@ -239,159 +189,114 @@ func tableExists(db *gorm.DB, tableName string) bool {
return count > 0 return count > 0
} }
func (s *Service) applyMissingRemoteMigrations(migrations []clientLocalMigration) error {
for i := range migrations {
m := migrations[i]
computedChecksum := digestSQL(m.SQLText)
checksum := strings.TrimSpace(m.Checksum)
if checksum == "" {
checksum = computedChecksum
} else if !strings.EqualFold(checksum, computedChecksum) {
return fmt.Errorf("checksum mismatch for migration %s", m.ID)
}
applied, err := s.localDB.GetRemoteMigrationApplied(m.ID)
if err == nil {
if strings.TrimSpace(applied.Checksum) != checksum {
return fmt.Errorf("checksum mismatch for migration %s", m.ID)
}
continue
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return fmt.Errorf("check local applied migration %s: %w", m.ID, err)
}
if strings.TrimSpace(m.SQLText) == "" {
if err := s.localDB.UpsertRemoteMigrationApplied(m.ID, checksum, appmeta.Version(), time.Now().UTC()); err != nil {
return fmt.Errorf("mark empty migration %s as applied: %w", m.ID, err)
}
continue
}
statements := splitSQLStatementsLite(m.SQLText)
if err := s.localDB.DB().Transaction(func(tx *gorm.DB) error {
for _, stmt := range statements {
if err := tx.Exec(stmt).Error; err != nil {
return fmt.Errorf("apply migration %s statement %q: %w", m.ID, stmt, err)
}
}
return nil
}); err != nil {
return err
}
if err := s.localDB.UpsertRemoteMigrationApplied(m.ID, checksum, appmeta.Version(), time.Now().UTC()); err != nil {
return fmt.Errorf("record applied migration %s: %w", m.ID, err)
}
}
return nil
}
func splitSQLStatementsLite(script string) []string {
scanner := bufio.NewScanner(strings.NewReader(script))
scanner.Buffer(make([]byte, 1024), 1024*1024)
lines := make([]string, 0, 64)
for scanner.Scan() {
line := strings.TrimSpace(scanner.Text())
if line == "" || strings.HasPrefix(line, "--") {
continue
}
lines = append(lines, scanner.Text())
}
combined := strings.Join(lines, "\n")
raw := strings.Split(combined, ";")
stmts := make([]string, 0, len(raw))
for _, stmt := range raw {
trimmed := strings.TrimSpace(stmt)
if trimmed == "" {
continue
}
stmts = append(stmts, trimmed)
}
return stmts
}
func digestSQL(sqlText string) string {
hash := sha256.Sum256([]byte(sqlText))
return hex.EncodeToString(hash[:])
}
func compareVersions(left, right string) int {
leftParts := normalizeVersionParts(left)
rightParts := normalizeVersionParts(right)
maxLen := len(leftParts)
if len(rightParts) > maxLen {
maxLen = len(rightParts)
}
for i := 0; i < maxLen; i++ {
lv := 0
rv := 0
if i < len(leftParts) {
lv = leftParts[i]
}
if i < len(rightParts) {
rv = rightParts[i]
}
if lv < rv {
return -1
}
if lv > rv {
return 1
}
}
return 0
}
func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time) error { func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time) error {
if strings.EqualFold(mariaDB.Dialector.Name(), "sqlite") { if strings.EqualFold(mariaDB.Dialector.Name(), "sqlite") {
return nil return nil
} }
if err := ensureClientSchemaStateTable(mariaDB); err != nil {
return err
}
username := strings.TrimSpace(s.localDB.GetDBUser()) username := strings.TrimSpace(s.localDB.GetDBUser())
if username == "" { if username == "" {
return nil return nil
} }
lastMigrationID := "" hostname, err := os.Hostname()
if id, err := s.localDB.GetLatestAppliedRemoteMigrationID(); err == nil { if err != nil {
lastMigrationID = id hostname = ""
} }
hostname = strings.TrimSpace(hostname)
lastSyncAt := s.localDB.GetLastSyncTime()
lastSyncStatus := ReadinessReady
pendingChangesCount := s.localDB.CountPendingChanges()
pendingErrorsCount := s.localDB.CountErroredChanges()
configurationsCount := s.localDB.CountConfigurations()
projectsCount := s.localDB.CountProjects()
estimateVersion := latestPricelistVersion(s.localDB, "estimate")
warehouseVersion := latestPricelistVersion(s.localDB, "warehouse")
competitorVersion := latestPricelistVersion(s.localDB, "competitor")
lastSyncErrorCode, lastSyncErrorText := latestSyncErrorState(s.localDB)
return mariaDB.Exec(` return mariaDB.Exec(`
INSERT INTO qt_client_schema_state (username, last_applied_migration_id, app_version, last_checked_at, updated_at) INSERT INTO qt_client_schema_state (
VALUES (?, ?, ?, ?, ?) username, hostname, app_version,
last_sync_at, last_sync_status, pending_changes_count, pending_errors_count,
configurations_count, projects_count,
estimate_pricelist_version, warehouse_pricelist_version, competitor_pricelist_version,
last_sync_error_code, last_sync_error_text,
last_checked_at, updated_at
)
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
ON DUPLICATE KEY UPDATE ON DUPLICATE KEY UPDATE
last_applied_migration_id = VALUES(last_applied_migration_id),
app_version = VALUES(app_version), app_version = VALUES(app_version),
last_sync_at = VALUES(last_sync_at),
last_sync_status = VALUES(last_sync_status),
pending_changes_count = VALUES(pending_changes_count),
pending_errors_count = VALUES(pending_errors_count),
configurations_count = VALUES(configurations_count),
projects_count = VALUES(projects_count),
estimate_pricelist_version = VALUES(estimate_pricelist_version),
warehouse_pricelist_version = VALUES(warehouse_pricelist_version),
competitor_pricelist_version = VALUES(competitor_pricelist_version),
last_sync_error_code = VALUES(last_sync_error_code),
last_sync_error_text = VALUES(last_sync_error_text),
last_checked_at = VALUES(last_checked_at), last_checked_at = VALUES(last_checked_at),
updated_at = VALUES(updated_at) updated_at = VALUES(updated_at)
`, username, lastMigrationID, appmeta.Version(), checkedAt, checkedAt).Error `, username, hostname, appmeta.Version(),
lastSyncAt, lastSyncStatus, pendingChangesCount, pendingErrorsCount,
configurationsCount, projectsCount,
estimateVersion, warehouseVersion, competitorVersion,
lastSyncErrorCode, lastSyncErrorText,
checkedAt, checkedAt).Error
} }
func normalizeVersionParts(v string) []int { func isDuplicatePrimaryKeyDefinition(err error) bool {
trimmed := strings.TrimSpace(v) if err == nil {
trimmed = strings.TrimPrefix(trimmed, "v") return false
chunks := strings.Split(trimmed, ".")
parts := make([]int, 0, len(chunks))
for _, chunk := range chunks {
clean := strings.TrimSpace(chunk)
if clean == "" {
parts = append(parts, 0)
continue
}
n := 0
for i := 0; i < len(clean); i++ {
if clean[i] < '0' || clean[i] > '9' {
clean = clean[:i]
break
}
}
if clean != "" {
if parsed, err := strconv.Atoi(clean); err == nil {
n = parsed
}
}
parts = append(parts, n)
} }
return parts msg := strings.ToLower(err.Error())
return strings.Contains(msg, "multiple primary key defined") ||
strings.Contains(msg, "duplicate key name 'primary'") ||
strings.Contains(msg, "duplicate entry")
}
func latestPricelistVersion(local *localdb.LocalDB, source string) *string {
if local == nil {
return nil
}
pl, err := local.GetLatestLocalPricelistBySource(source)
if err != nil || pl == nil {
return nil
}
version := strings.TrimSpace(pl.Version)
if version == "" {
return nil
}
return &version
}
func latestSyncErrorState(local *localdb.LocalDB) (*string, *string) {
if local == nil {
return nil, nil
}
if guard, err := local.GetSyncGuardState(); err == nil && guard != nil && strings.EqualFold(guard.Status, ReadinessBlocked) {
return optionalString(strings.TrimSpace(guard.ReasonCode)), optionalString(strings.TrimSpace(guard.ReasonText))
}
var pending localdb.PendingChange
if err := local.DB().
Where("TRIM(COALESCE(last_error, '')) <> ''").
Order("id DESC").
First(&pending).Error; err == nil {
return optionalString("PENDING_CHANGE_ERROR"), optionalString(strings.TrimSpace(pending.LastError))
}
return nil, nil
}
func optionalString(value string) *string {
if strings.TrimSpace(value) == "" {
return nil
}
v := strings.TrimSpace(value)
return &v
} }
func toReadinessFromState(state *localdb.LocalSyncGuardState) *SyncReadiness { func toReadinessFromState(state *localdb.LocalSyncGuardState) *SyncReadiness {
+108 -3
View File
@@ -148,9 +148,6 @@ func (s *Service) ImportConfigurationsToLocal() (*ConfigImportResult, error) {
if localCfg.Line <= 0 && existing.Line > 0 { if localCfg.Line <= 0 && existing.Line > 0 {
localCfg.Line = existing.Line 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++ result.Updated++
} else { } else {
result.Imported++ result.Imported++
@@ -693,6 +690,9 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
for i, item := range serverItems { for i, item := range serverItems {
localItems[i] = *localdb.PricelistItemToLocal(&item, localPricelistID) localItems[i] = *localdb.PricelistItemToLocal(&item, localPricelistID)
} }
if err := s.enrichLocalPricelistItemsWithStock(mariaDB, localItems); err != nil {
slog.Warn("pricelist stock enrichment skipped", "pricelist_id", localPricelistID, "error", err)
}
if err := s.localDB.SaveLocalPricelistItems(localItems); err != nil { if err := s.localDB.SaveLocalPricelistItems(localItems); err != nil {
return 0, fmt.Errorf("saving local pricelist items: %w", err) return 0, fmt.Errorf("saving local pricelist items: %w", err)
@@ -711,6 +711,111 @@ func (s *Service) SyncPricelistItemsByServerID(serverPricelistID uint) (int, err
return s.SyncPricelistItems(localPL.ID) return s.SyncPricelistItems(localPL.ID)
} }
func (s *Service) enrichLocalPricelistItemsWithStock(mariaDB *gorm.DB, items []localdb.LocalPricelistItem) error {
if len(items) == 0 {
return nil
}
bookRepo := repository.NewPartnumberBookRepository(s.localDB.DB())
book, err := bookRepo.GetActiveBook()
if err != nil || book == nil {
return nil
}
bookItems, err := bookRepo.GetBookItems(book.ID)
if err != nil {
return err
}
if len(bookItems) == 0 {
return nil
}
partnumberToLots := make(map[string][]string, len(bookItems))
for _, item := range bookItems {
pn := strings.TrimSpace(item.Partnumber)
if pn == "" {
continue
}
seenLots := make(map[string]struct{}, len(item.LotsJSON))
for _, lot := range item.LotsJSON {
lotName := strings.TrimSpace(lot.LotName)
if lotName == "" {
continue
}
key := strings.ToLower(lotName)
if _, exists := seenLots[key]; exists {
continue
}
seenLots[key] = struct{}{}
partnumberToLots[pn] = append(partnumberToLots[pn], lotName)
}
}
if len(partnumberToLots) == 0 {
return nil
}
type stockRow struct {
Partnumber string `gorm:"column:partnumber"`
Qty *float64 `gorm:"column:qty"`
}
rows := make([]stockRow, 0)
if err := mariaDB.Raw(`
SELECT s.partnumber, s.qty
FROM stock_log s
INNER JOIN (
SELECT partnumber, MAX(date) AS max_date
FROM stock_log
GROUP BY partnumber
) latest ON latest.partnumber = s.partnumber AND latest.max_date = s.date
WHERE s.qty IS NOT NULL
`).Scan(&rows).Error; err != nil {
return err
}
lotTotals := make(map[string]float64, len(items))
lotPartnumbers := make(map[string][]string, len(items))
seenPartnumbers := make(map[string]map[string]struct{}, len(items))
for _, row := range rows {
pn := strings.TrimSpace(row.Partnumber)
if pn == "" || row.Qty == nil {
continue
}
lots := partnumberToLots[pn]
if len(lots) == 0 {
continue
}
for _, lotName := range lots {
lotTotals[lotName] += *row.Qty
if _, ok := seenPartnumbers[lotName]; !ok {
seenPartnumbers[lotName] = make(map[string]struct{}, 4)
}
key := strings.ToLower(pn)
if _, exists := seenPartnumbers[lotName][key]; exists {
continue
}
seenPartnumbers[lotName][key] = struct{}{}
lotPartnumbers[lotName] = append(lotPartnumbers[lotName], pn)
}
}
for i := range items {
lotName := strings.TrimSpace(items[i].LotName)
if qty, ok := lotTotals[lotName]; ok {
qtyCopy := qty
items[i].AvailableQty = &qtyCopy
}
if partnumbers := lotPartnumbers[lotName]; len(partnumbers) > 0 {
sort.Slice(partnumbers, func(a, b int) bool {
return strings.ToLower(partnumbers[a]) < strings.ToLower(partnumbers[b])
})
items[i].Partnumbers = append(localdb.LocalStringList{}, partnumbers...)
}
}
return nil
}
// GetLocalPriceForLot returns the price for a lot from a local pricelist // GetLocalPriceForLot returns the price for a lot from a local pricelist
func (s *Service) GetLocalPriceForLot(localPricelistID uint, lotName string) (float64, error) { func (s *Service) GetLocalPriceForLot(localPricelistID uint, lotName string) (float64, error) {
return s.localDB.GetLocalPriceForLot(localPricelistID, lotName) return s.localDB.GetLocalPriceForLot(localPricelistID, lotName)
@@ -17,7 +17,6 @@ func TestSyncPricelists_BackfillsLotCategoryForUsedPricelistItems(t *testing.T)
&models.Pricelist{}, &models.Pricelist{},
&models.PricelistItem{}, &models.PricelistItem{},
&models.Lot{}, &models.Lot{},
&models.LotPartnumber{},
&models.StockLog{}, &models.StockLog{},
); err != nil { ); err != nil {
t.Fatalf("migrate server tables: %v", err) t.Fatalf("migrate server tables: %v", err)
@@ -105,3 +104,102 @@ func TestSyncPricelists_BackfillsLotCategoryForUsedPricelistItems(t *testing.T)
} }
} }
func TestSyncPricelistItems_EnrichesStockFromLocalPartnumberBook(t *testing.T) {
local := newLocalDBForSyncTest(t)
serverDB := newServerDBForSyncTest(t)
if err := serverDB.AutoMigrate(
&models.Pricelist{},
&models.PricelistItem{},
&models.Lot{},
&models.StockLog{},
); err != nil {
t.Fatalf("migrate server tables: %v", err)
}
serverPL := models.Pricelist{
Source: "warehouse",
Version: "2026-03-07-001",
Notification: "server",
CreatedBy: "tester",
IsActive: true,
CreatedAt: time.Now().Add(-1 * time.Hour),
}
if err := serverDB.Create(&serverPL).Error; err != nil {
t.Fatalf("create server pricelist: %v", err)
}
if err := serverDB.Create(&models.PricelistItem{
PricelistID: serverPL.ID,
LotName: "CPU_A",
LotCategory: "CPU",
Price: 10,
}).Error; err != nil {
t.Fatalf("create server pricelist item: %v", err)
}
qty := 7.0
if err := serverDB.Create(&models.StockLog{
Partnumber: "CPU-PN-1",
Date: time.Now(),
Price: 100,
Qty: &qty,
}).Error; err != nil {
t.Fatalf("create stock log: %v", err)
}
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
ServerID: serverPL.ID,
Source: serverPL.Source,
Version: serverPL.Version,
Name: serverPL.Notification,
CreatedAt: serverPL.CreatedAt,
SyncedAt: time.Now(),
IsUsed: false,
}); err != nil {
t.Fatalf("seed local pricelist: %v", err)
}
localPL, err := local.GetLocalPricelistByServerID(serverPL.ID)
if err != nil {
t.Fatalf("get local pricelist: %v", err)
}
if err := local.DB().Create(&localdb.LocalPartnumberBook{
ServerID: 1,
Version: "2026-03-07-001",
CreatedAt: time.Now(),
IsActive: true,
PartnumbersJSON: localdb.LocalStringList{"CPU-PN-1"},
}).Error; err != nil {
t.Fatalf("create local partnumber book: %v", err)
}
if err := local.DB().Create(&localdb.LocalPartnumberBookItem{
Partnumber: "CPU-PN-1",
LotsJSON: localdb.LocalPartnumberBookLots{
{LotName: "CPU_A", Qty: 1},
},
Description: "CPU PN",
}).Error; err != nil {
t.Fatalf("create local partnumber book item: %v", err)
}
svc := syncsvc.NewServiceWithDB(serverDB, local)
if _, err := svc.SyncPricelistItems(localPL.ID); err != nil {
t.Fatalf("sync pricelist items: %v", err)
}
items, err := local.GetLocalPricelistItems(localPL.ID)
if err != nil {
t.Fatalf("load local items: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 local item, got %d", len(items))
}
if items[0].AvailableQty == nil {
t.Fatalf("expected available_qty to be set")
}
if *items[0].AvailableQty != 7 {
t.Fatalf("expected available_qty=7, got %v", *items[0].AvailableQty)
}
if len(items[0].Partnumbers) != 1 || items[0].Partnumbers[0] != "CPU-PN-1" {
t.Fatalf("expected partnumbers [CPU-PN-1], got %v", items[0].Partnumbers)
}
}
@@ -315,10 +315,21 @@ func TestImportConfigurationsToLocalPullsLine(t *testing.T) {
} }
} }
func TestImportConfigurationsToLocalPreservesLocalVendorSpec(t *testing.T) { func TestImportConfigurationsToLocalLoadsVendorSpecFromServer(t *testing.T) {
local := newLocalDBForSyncTest(t) local := newLocalDBForSyncTest(t)
serverDB := newServerDBForSyncTest(t) serverDB := newServerDBForSyncTest(t)
serverSpec := models.VendorSpec{
{
SortOrder: 10,
VendorPartnumber: "GPU-NVHGX-H200-8141",
Quantity: 1,
Description: "NVIDIA HGX Delta-Next GPU Baseboard",
LotMappings: []models.VendorSpecLotMapping{
{LotName: "GPU_NV_H200_141GB_SXM_(HGX)", QuantityPerPN: 8},
},
},
}
cfg := models.Configuration{ cfg := models.Configuration{
UUID: "server-vendorspec-config", UUID: "server-vendorspec-config",
OwnerUsername: "tester", OwnerUsername: "tester",
@@ -326,6 +337,7 @@ func TestImportConfigurationsToLocalPreservesLocalVendorSpec(t *testing.T) {
Items: models.ConfigItems{{LotName: "CPU_PULL", Quantity: 1, UnitPrice: 900}}, Items: models.ConfigItems{{LotName: "CPU_PULL", Quantity: 1, UnitPrice: 900}},
ServerCount: 1, ServerCount: 1,
Line: 50, Line: 50,
VendorSpec: serverSpec,
} }
total := cfg.Items.Total() total := cfg.Items.Total()
cfg.TotalPrice = &total cfg.TotalPrice = &total
@@ -333,32 +345,6 @@ func TestImportConfigurationsToLocalPreservesLocalVendorSpec(t *testing.T) {
t.Fatalf("seed server config: %v", err) 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) svc := syncsvc.NewServiceWithDB(serverDB, local)
if _, err := svc.ImportConfigurationsToLocal(); err != nil { if _, err := svc.ImportConfigurationsToLocal(); err != nil {
t.Fatalf("import configurations to local: %v", err) t.Fatalf("import configurations to local: %v", err)
@@ -369,7 +355,7 @@ func TestImportConfigurationsToLocalPreservesLocalVendorSpec(t *testing.T) {
t.Fatalf("load local config: %v", err) t.Fatalf("load local config: %v", err)
} }
if len(localCfg.VendorSpec) != 1 { if len(localCfg.VendorSpec) != 1 {
t.Fatalf("expected local vendor_spec preserved, got %d rows", len(localCfg.VendorSpec)) t.Fatalf("expected server vendor_spec imported, got %d rows", len(localCfg.VendorSpec))
} }
if localCfg.VendorSpec[0].VendorPartnumber != "GPU-NVHGX-H200-8141" { if localCfg.VendorSpec[0].VendorPartnumber != "GPU-NVHGX-H200-8141" {
t.Fatalf("unexpected vendor_partnumber after import: %q", localCfg.VendorSpec[0].VendorPartnumber) t.Fatalf("unexpected vendor_partnumber after import: %q", localCfg.VendorSpec[0].VendorPartnumber)
@@ -492,6 +478,7 @@ CREATE TABLE qt_configurations (
only_in_stock INTEGER NOT NULL DEFAULT 0, only_in_stock INTEGER NOT NULL DEFAULT 0,
line_no INTEGER NULL, line_no INTEGER NULL,
price_updated_at DATETIME NULL, price_updated_at DATETIME NULL,
vendor_spec TEXT NULL,
created_at DATETIME created_at DATETIME
);`).Error; err != nil { );`).Error; err != nil {
t.Fatalf("create qt_configurations: %v", err) t.Fatalf("create qt_configurations: %v", err)
+32 -20
View File
@@ -3,6 +3,7 @@ package services
import ( import (
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/repository"
"math"
) )
// ResolvedBOMRow is the result of resolving a single vendor BOM row. // ResolvedBOMRow is the result of resolving a single vendor BOM row.
@@ -47,7 +48,19 @@ func (r *VendorSpecResolver) Resolve(items []localdb.VendorSpecItem) ([]localdb.
// Step 1: Look up in active book // Step 1: Look up in active book
matches, err := r.bookRepo.FindLotByPartnumber(book.ID, pn) matches, err := r.bookRepo.FindLotByPartnumber(book.ID, pn)
if err == nil && len(matches) > 0 { if err == nil && len(matches) > 0 {
items[i].ResolvedLotName = matches[0].LotName items[i].LotMappings = make([]localdb.VendorSpecLotMapping, 0, len(matches[0].LotsJSON))
for _, lot := range matches[0].LotsJSON {
if lot.LotName == "" {
continue
}
items[i].LotMappings = append(items[i].LotMappings, localdb.VendorSpecLotMapping{
LotName: lot.LotName,
QuantityPerPN: lotQtyToInt(lot.Qty),
})
}
if len(items[i].LotMappings) > 0 {
items[i].ResolvedLotName = items[i].LotMappings[0].LotName
}
items[i].ResolutionSource = "book" items[i].ResolutionSource = "book"
continue continue
} }
@@ -67,13 +80,9 @@ func (r *VendorSpecResolver) Resolve(items []localdb.VendorSpecItem) ([]localdb.
return items, nil return items, nil
} }
// AggregateLOTs applies the qty-logic to compute per-LOT quantities from the resolved BOM. // AggregateLOTs applies qty from the resolved PN composition stored in lots_json.
// 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) { func AggregateLOTs(items []localdb.VendorSpecItem, book *localdb.LocalPartnumberBook, bookRepo *repository.PartnumberBookRepository) ([]AggregatedLOT, error) {
// Gather all unique lot names that resolved lotTotals := make(map[string]int)
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 { if book != nil {
for _, item := range items { for _, item := range items {
@@ -83,21 +92,17 @@ func AggregateLOTs(items []localdb.VendorSpecItem, book *localdb.LocalPartnumber
lot := item.ResolvedLotName lot := item.ResolvedLotName
pn := item.VendorPartnumber pn := item.VendorPartnumber
// Find if this pn is primary for its lot
matches, err := bookRepo.FindLotByPartnumber(book.ID, pn) matches, err := bookRepo.FindLotByPartnumber(book.ID, pn)
if err != nil || len(matches) == 0 { if err != nil || len(matches) == 0 {
// manual/unresolved — treat as non-primary lotTotals[lot] += item.Quantity
lotAny[lot] = true
continue continue
} }
for _, m := range matches { for _, m := range matches {
if m.LotName == lot { for _, mappedLot := range m.LotsJSON {
if m.IsPrimaryPN { if mappedLot.LotName != lot {
lotPrimary[lot] += item.Quantity continue
lotHasPrimary[lot] = true
} else {
lotAny[lot] = true
} }
lotTotals[lot] += item.Quantity * lotQtyToInt(mappedLot.Qty)
} }
} }
} }
@@ -105,7 +110,7 @@ func AggregateLOTs(items []localdb.VendorSpecItem, book *localdb.LocalPartnumber
// No book: all resolved rows contribute qty=1 per lot // No book: all resolved rows contribute qty=1 per lot
for _, item := range items { for _, item := range items {
if item.ResolvedLotName != "" { if item.ResolvedLotName != "" {
lotAny[item.ResolvedLotName] = true lotTotals[item.ResolvedLotName] += item.Quantity
} }
} }
} }
@@ -119,11 +124,18 @@ func AggregateLOTs(items []localdb.VendorSpecItem, book *localdb.LocalPartnumber
continue continue
} }
seen[lot] = true seen[lot] = true
qty := 1 qty := lotTotals[lot]
if lotHasPrimary[lot] { if qty < 1 {
qty = lotPrimary[lot] qty = 1
} }
result = append(result, AggregatedLOT{LotName: lot, Quantity: qty}) result = append(result, AggregatedLOT{LotName: lot, Quantity: qty})
} }
return result, nil return result, nil
} }
func lotQtyToInt(qty float64) int {
if qty < 1 {
return 1
}
return int(math.Round(qty))
}
@@ -0,0 +1,560 @@
package services
import (
"bytes"
"encoding/xml"
"fmt"
"path/filepath"
"sort"
"strconv"
"strings"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"github.com/google/uuid"
"gorm.io/gorm"
)
type VendorWorkspaceImportResult struct {
Imported int `json:"imported"`
Project *models.Project `json:"project,omitempty"`
Configs []VendorWorkspaceImportedConfig `json:"configs"`
}
type VendorWorkspaceImportedConfig struct {
UUID string `json:"uuid"`
Name string `json:"name"`
ServerCount int `json:"server_count"`
ServerModel string `json:"server_model,omitempty"`
Rows int `json:"rows"`
}
type importedWorkspace struct {
SourceFormat string
SourceDocID string
SourceFileName string
CurrencyCode string
Configurations []importedConfiguration
}
type importedConfiguration struct {
GroupID string
Name string
Line int
ServerCount int
ServerModel string
Article string
CurrencyCode string
Rows []localdb.VendorSpecItem
TotalPrice *float64
}
type groupedItem struct {
order int
row cfxmlProductLineItem
}
type cfxmlDocument struct {
XMLName xml.Name `xml:"CFXML"`
ThisDocumentIdentifier cfxmlDocumentIdentifier `xml:"thisDocumentIdentifier"`
CFData cfxmlData `xml:"CFData"`
}
type cfxmlDocumentIdentifier struct {
ProprietaryDocumentIdentifier string `xml:"ProprietaryDocumentIdentifier"`
}
type cfxmlData struct {
ProprietaryInformation []cfxmlProprietaryInformation `xml:"ProprietaryInformation"`
ProductLineItems []cfxmlProductLineItem `xml:"ProductLineItem"`
}
type cfxmlProprietaryInformation struct {
Name string `xml:"Name"`
Value string `xml:"Value"`
}
type cfxmlProductLineItem struct {
ProductLineNumber string `xml:"ProductLineNumber"`
ItemNo string `xml:"ItemNo"`
TransactionType string `xml:"TransactionType"`
ProprietaryGroupIdentifier string `xml:"ProprietaryGroupIdentifier"`
ConfigurationGroupLineNumberReference string `xml:"ConfigurationGroupLineNumberReference"`
Quantity string `xml:"Quantity"`
ProductIdentification cfxmlProductIdentification `xml:"ProductIdentification"`
UnitListPrice cfxmlUnitListPrice `xml:"UnitListPrice"`
ProductSubLineItems []cfxmlProductSubLineItem `xml:"ProductSubLineItem"`
}
type cfxmlProductSubLineItem struct {
LineNumber string `xml:"LineNumber"`
TransactionType string `xml:"TransactionType"`
Quantity string `xml:"Quantity"`
ProductIdentification cfxmlProductIdentification `xml:"ProductIdentification"`
UnitListPrice cfxmlUnitListPrice `xml:"UnitListPrice"`
}
type cfxmlProductIdentification struct {
PartnerProductIdentification cfxmlPartnerProductIdentification `xml:"PartnerProductIdentification"`
}
type cfxmlPartnerProductIdentification struct {
ProprietaryProductIdentifier string `xml:"ProprietaryProductIdentifier"`
ProprietaryProductChar string `xml:"ProprietaryProductChar"`
ProductCharacter string `xml:"ProductCharacter"`
ProductDescription string `xml:"ProductDescription"`
ProductName string `xml:"ProductName"`
ProductTypeCode string `xml:"ProductTypeCode"`
}
type cfxmlUnitListPrice struct {
FinancialAmount cfxmlFinancialAmount `xml:"FinancialAmount"`
}
type cfxmlFinancialAmount struct {
GlobalCurrencyCode string `xml:"GlobalCurrencyCode"`
MonetaryAmount string `xml:"MonetaryAmount"`
}
func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID string, sourceFileName string, data []byte, ownerUsername string) (*VendorWorkspaceImportResult, error) {
project, err := s.localDB.GetProjectByUUID(projectUUID)
if err != nil {
return nil, ErrProjectNotFound
}
workspace, err := parseCFXMLWorkspace(data, filepath.Base(sourceFileName))
if err != nil {
return nil, err
}
result := &VendorWorkspaceImportResult{
Imported: 0,
Project: localdb.LocalToProject(project),
Configs: make([]VendorWorkspaceImportedConfig, 0, len(workspace.Configurations)),
}
err = s.localDB.DB().Transaction(func(tx *gorm.DB) error {
bookRepo := repository.NewPartnumberBookRepository(tx)
for _, imported := range workspace.Configurations {
now := time.Now()
cfgUUID := uuid.NewString()
groupRows, items, totalPrice, estimatePricelistID, err := s.prepareImportedConfiguration(imported.Rows, imported.ServerCount, bookRepo)
if err != nil {
return fmt.Errorf("prepare imported configuration group %s: %w", imported.GroupID, err)
}
localCfg := &localdb.LocalConfiguration{
UUID: cfgUUID,
ProjectUUID: &projectUUID,
IsActive: true,
Name: imported.Name,
Items: items,
TotalPrice: totalPrice,
ServerCount: imported.ServerCount,
ServerModel: imported.ServerModel,
Article: imported.Article,
PricelistID: estimatePricelistID,
VendorSpec: groupRows,
CreatedAt: now,
UpdatedAt: now,
SyncStatus: "pending",
OriginalUsername: ownerUsername,
}
if err := s.createWithVersionTx(tx, localCfg, ownerUsername); err != nil {
return fmt.Errorf("import configuration group %s: %w", imported.GroupID, err)
}
result.Imported++
result.Configs = append(result.Configs, VendorWorkspaceImportedConfig{
UUID: localCfg.UUID,
Name: localCfg.Name,
ServerCount: localCfg.ServerCount,
ServerModel: localCfg.ServerModel,
Rows: len(localCfg.VendorSpec),
})
}
return nil
})
if err != nil {
return nil, err
}
return result, nil
}
func (s *LocalConfigurationService) prepareImportedConfiguration(rows []localdb.VendorSpecItem, serverCount int, bookRepo *repository.PartnumberBookRepository) (localdb.VendorSpec, localdb.LocalConfigItems, *float64, *uint, error) {
resolver := NewVendorSpecResolver(bookRepo)
resolved, err := resolver.Resolve(append([]localdb.VendorSpecItem(nil), rows...))
if err != nil {
return nil, nil, nil, nil, err
}
canonical := make(localdb.VendorSpec, 0, len(resolved))
for _, row := range resolved {
if len(row.LotMappings) == 0 && strings.TrimSpace(row.ResolvedLotName) != "" {
row.LotMappings = []localdb.VendorSpecLotMapping{
{LotName: strings.TrimSpace(row.ResolvedLotName), QuantityPerPN: 1},
}
}
row.LotMappings = normalizeImportedLotMappings(row.LotMappings)
row.ResolvedLotName = ""
row.ResolutionSource = ""
row.ManualLotSuggestion = ""
row.LotQtyPerPN = 0
row.LotAllocations = nil
canonical = append(canonical, row)
}
estimatePricelist, _ := s.localDB.GetLatestLocalPricelistBySource("estimate")
var serverPricelistID *uint
if estimatePricelist != nil {
serverPricelistID = &estimatePricelist.ServerID
}
items := aggregateVendorSpecToItems(canonical, estimatePricelist, s.localDB)
totalValue := items.Total()
if serverCount > 1 {
totalValue *= float64(serverCount)
}
totalPrice := &totalValue
return canonical, items, totalPrice, serverPricelistID, nil
}
func aggregateVendorSpecToItems(spec localdb.VendorSpec, estimatePricelist *localdb.LocalPricelist, local *localdb.LocalDB) localdb.LocalConfigItems {
if len(spec) == 0 {
return localdb.LocalConfigItems{}
}
lotMap := make(map[string]int)
order := make([]string, 0)
for _, row := range spec {
for _, mapping := range normalizeImportedLotMappings(row.LotMappings) {
if _, exists := lotMap[mapping.LotName]; !exists {
order = append(order, mapping.LotName)
}
lotMap[mapping.LotName] += row.Quantity * mapping.QuantityPerPN
}
}
sort.Strings(order)
items := make(localdb.LocalConfigItems, 0, len(order))
for _, lotName := range order {
unitPrice := 0.0
if estimatePricelist != nil && local != nil {
if price, err := local.GetLocalPriceForLot(estimatePricelist.ID, lotName); err == nil && price > 0 {
unitPrice = price
}
}
items = append(items, localdb.LocalConfigItem{
LotName: lotName,
Quantity: lotMap[lotName],
UnitPrice: unitPrice,
})
}
return items
}
func normalizeImportedLotMappings(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 _, mapping := range in {
lot := strings.TrimSpace(mapping.LotName)
if lot == "" {
continue
}
qty := mapping.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
}
func parseCFXMLWorkspace(data []byte, sourceFileName string) (*importedWorkspace, error) {
var doc cfxmlDocument
if err := xml.Unmarshal(data, &doc); err != nil {
return nil, fmt.Errorf("parse CFXML workspace: %w", err)
}
if doc.XMLName.Local != "CFXML" {
return nil, fmt.Errorf("unsupported workspace root: %s", doc.XMLName.Local)
}
if len(doc.CFData.ProductLineItems) == 0 {
return nil, fmt.Errorf("CFXML workspace has no ProductLineItem rows")
}
workspace := &importedWorkspace{
SourceFormat: "CFXML",
SourceDocID: strings.TrimSpace(doc.ThisDocumentIdentifier.ProprietaryDocumentIdentifier),
SourceFileName: sourceFileName,
CurrencyCode: detectWorkspaceCurrency(doc.CFData.ProprietaryInformation, doc.CFData.ProductLineItems),
}
type groupBucket struct {
order int
items []groupedItem
}
groupOrder := make([]string, 0)
groups := make(map[string]*groupBucket)
for idx, item := range doc.CFData.ProductLineItems {
groupID := strings.TrimSpace(item.ProprietaryGroupIdentifier)
if groupID == "" {
groupID = firstNonEmpty(strings.TrimSpace(item.ProductLineNumber), strings.TrimSpace(item.ItemNo), fmt.Sprintf("group-%d", idx+1))
}
bucket := groups[groupID]
if bucket == nil {
bucket = &groupBucket{order: idx}
groups[groupID] = bucket
groupOrder = append(groupOrder, groupID)
}
bucket.items = append(bucket.items, groupedItem{order: idx, row: item})
}
for lineIdx, groupID := range groupOrder {
bucket := groups[groupID]
if bucket == nil || len(bucket.items) == 0 {
continue
}
primary := pickPrimaryTopLevelRow(bucket.items)
serverCount := maxInt(parseInt(primary.row.Quantity), 1)
rows := make([]localdb.VendorSpecItem, 0, len(bucket.items)*4)
sortOrder := 10
for _, item := range bucket.items {
topRow := vendorSpecItemFromTopLevel(item.row, serverCount, sortOrder)
if topRow != nil {
rows = append(rows, *topRow)
sortOrder += 10
}
for _, sub := range item.row.ProductSubLineItems {
subRow := vendorSpecItemFromSubLine(sub, sortOrder)
if subRow == nil {
continue
}
rows = append(rows, *subRow)
sortOrder += 10
}
}
total := sumVendorSpecRows(rows, serverCount)
name := strings.TrimSpace(primary.row.ProductIdentification.PartnerProductIdentification.ProductName)
if name == "" {
name = strings.TrimSpace(primary.row.ProductIdentification.PartnerProductIdentification.ProductDescription)
}
if name == "" {
name = fmt.Sprintf("Imported config %d", lineIdx+1)
}
workspace.Configurations = append(workspace.Configurations, importedConfiguration{
GroupID: groupID,
Name: name,
Line: (lineIdx + 1) * 10,
ServerCount: serverCount,
ServerModel: strings.TrimSpace(primary.row.ProductIdentification.PartnerProductIdentification.ProductDescription),
Article: strings.TrimSpace(primary.row.ProductIdentification.PartnerProductIdentification.ProprietaryProductIdentifier),
CurrencyCode: workspace.CurrencyCode,
Rows: rows,
TotalPrice: total,
})
}
if len(workspace.Configurations) == 0 {
return nil, fmt.Errorf("CFXML workspace has no importable configuration groups")
}
return workspace, nil
}
func detectWorkspaceCurrency(meta []cfxmlProprietaryInformation, rows []cfxmlProductLineItem) string {
for _, item := range meta {
if strings.EqualFold(strings.TrimSpace(item.Name), "Currencies") {
value := strings.TrimSpace(item.Value)
if value != "" {
return value
}
}
}
for _, row := range rows {
code := strings.TrimSpace(row.UnitListPrice.FinancialAmount.GlobalCurrencyCode)
if code != "" {
return code
}
}
return ""
}
func pickPrimaryTopLevelRow(items []groupedItem) groupedItem {
best := items[0]
bestScore := primaryScore(best.row)
for _, item := range items[1:] {
score := primaryScore(item.row)
if score > bestScore {
best = item
bestScore = score
continue
}
if score == bestScore && compareLineNumbers(item.row.ProductLineNumber, best.row.ProductLineNumber) < 0 {
best = item
}
}
return best
}
func primaryScore(row cfxmlProductLineItem) int {
score := len(row.ProductSubLineItems)
if strings.EqualFold(strings.TrimSpace(row.ProductIdentification.PartnerProductIdentification.ProductTypeCode), "Hardware") {
score += 100000
}
return score
}
func compareLineNumbers(left, right string) int {
li := parseInt(left)
ri := parseInt(right)
switch {
case li < ri:
return -1
case li > ri:
return 1
default:
return strings.Compare(left, right)
}
}
func vendorSpecItemFromTopLevel(item cfxmlProductLineItem, serverCount int, sortOrder int) *localdb.VendorSpecItem {
code := strings.TrimSpace(item.ProductIdentification.PartnerProductIdentification.ProprietaryProductIdentifier)
desc := strings.TrimSpace(item.ProductIdentification.PartnerProductIdentification.ProductDescription)
if code == "" && desc == "" {
return nil
}
qty := normalizeTopLevelQuantity(item.Quantity, serverCount)
unitPrice := parseOptionalFloat(item.UnitListPrice.FinancialAmount.MonetaryAmount)
return &localdb.VendorSpecItem{
SortOrder: sortOrder,
VendorPartnumber: code,
Quantity: qty,
Description: desc,
UnitPrice: unitPrice,
TotalPrice: totalPrice(unitPrice, qty),
}
}
func vendorSpecItemFromSubLine(item cfxmlProductSubLineItem, sortOrder int) *localdb.VendorSpecItem {
code := strings.TrimSpace(item.ProductIdentification.PartnerProductIdentification.ProprietaryProductIdentifier)
desc := strings.TrimSpace(item.ProductIdentification.PartnerProductIdentification.ProductDescription)
if code == "" && desc == "" {
return nil
}
qty := maxInt(parseInt(item.Quantity), 1)
unitPrice := parseOptionalFloat(item.UnitListPrice.FinancialAmount.MonetaryAmount)
return &localdb.VendorSpecItem{
SortOrder: sortOrder,
VendorPartnumber: code,
Quantity: qty,
Description: desc,
UnitPrice: unitPrice,
TotalPrice: totalPrice(unitPrice, qty),
}
}
func sumVendorSpecRows(rows []localdb.VendorSpecItem, serverCount int) *float64 {
total := 0.0
hasTotal := false
for _, row := range rows {
if row.TotalPrice == nil {
continue
}
total += *row.TotalPrice
hasTotal = true
}
if !hasTotal {
return nil
}
if serverCount > 1 {
total *= float64(serverCount)
}
return &total
}
func totalPrice(unitPrice *float64, qty int) *float64 {
if unitPrice == nil {
return nil
}
total := *unitPrice * float64(qty)
return &total
}
func parseOptionalFloat(raw string) *float64 {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return nil
}
value, err := strconv.ParseFloat(trimmed, 64)
if err != nil {
return nil
}
return &value
}
func parseInt(raw string) int {
trimmed := strings.TrimSpace(raw)
if trimmed == "" {
return 0
}
value, err := strconv.Atoi(trimmed)
if err != nil {
return 0
}
return value
}
func firstNonEmpty(values ...string) string {
for _, value := range values {
if strings.TrimSpace(value) != "" {
return strings.TrimSpace(value)
}
}
return ""
}
func maxInt(value, floor int) int {
if value < floor {
return floor
}
return value
}
func normalizeTopLevelQuantity(raw string, serverCount int) int {
qty := maxInt(parseInt(raw), 1)
if serverCount <= 1 {
return qty
}
if qty%serverCount == 0 {
return maxInt(qty/serverCount, 1)
}
return qty
}
func IsCFXMLWorkspace(data []byte) bool {
return bytes.Contains(data, []byte("<CFXML>")) || bytes.Contains(data, []byte("<CFXML "))
}
@@ -0,0 +1,360 @@
package services
import (
"path/filepath"
"testing"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
)
func TestParseCFXMLWorkspaceGroupsSoftwareIntoConfiguration(t *testing.T) {
const sample = `<?xml version="1.0" encoding="UTF-8"?>
<CFXML>
<thisDocumentIdentifier>
<ProprietaryDocumentIdentifier>CFXML.workspace-test</ProprietaryDocumentIdentifier>
</thisDocumentIdentifier>
<CFData>
<ProprietaryInformation>
<Name>Currencies</Name>
<Value>USD</Value>
</ProprietaryInformation>
<ProductLineItem>
<ProductLineNumber>1000</ProductLineNumber>
<ItemNo>1000</ItemNo>
<TransactionType>NEW</TransactionType>
<ProprietaryGroupIdentifier>1000</ProprietaryGroupIdentifier>
<ConfigurationGroupLineNumberReference>100</ConfigurationGroupLineNumberReference>
<Quantity>6</Quantity>
<ProductIdentification>
<PartnerProductIdentification>
<ProprietaryProductIdentifier>7DG9-CTO1WW</ProprietaryProductIdentifier>
<ProductDescription>ThinkSystem SR630 V4</ProductDescription>
<ProductName>#1</ProductName>
<ProductTypeCode>Hardware</ProductTypeCode>
</PartnerProductIdentification>
</ProductIdentification>
<UnitListPrice>
<FinancialAmount>
<GlobalCurrencyCode>USD</GlobalCurrencyCode>
<MonetaryAmount>100.00</MonetaryAmount>
</FinancialAmount>
</UnitListPrice>
<ProductSubLineItem>
<LineNumber>1001</LineNumber>
<TransactionType>ADD</TransactionType>
<Quantity>2</Quantity>
<ProductIdentification>
<PartnerProductIdentification>
<ProprietaryProductIdentifier>CPU-1</ProprietaryProductIdentifier>
<ProductDescription>CPU</ProductDescription>
<ProductCharacter>PROCESSOR</ProductCharacter>
</PartnerProductIdentification>
</ProductIdentification>
<UnitListPrice>
<FinancialAmount>
<GlobalCurrencyCode>USD</GlobalCurrencyCode>
<MonetaryAmount>0</MonetaryAmount>
</FinancialAmount>
</UnitListPrice>
</ProductSubLineItem>
</ProductLineItem>
<ProductLineItem>
<ProductLineNumber>2000</ProductLineNumber>
<ItemNo>2000</ItemNo>
<TransactionType>NEW</TransactionType>
<ProprietaryGroupIdentifier>1000</ProprietaryGroupIdentifier>
<ConfigurationGroupLineNumberReference>100</ConfigurationGroupLineNumberReference>
<Quantity>6</Quantity>
<ProductIdentification>
<PartnerProductIdentification>
<ProprietaryProductIdentifier>7S0X-CTO8WW</ProprietaryProductIdentifier>
<ProductDescription>XClarity Controller Prem-FOD</ProductDescription>
<ProductName>software1</ProductName>
<ProductTypeCode>Software</ProductTypeCode>
</PartnerProductIdentification>
</ProductIdentification>
<UnitListPrice>
<FinancialAmount>
<GlobalCurrencyCode>USD</GlobalCurrencyCode>
<MonetaryAmount>25.00</MonetaryAmount>
</FinancialAmount>
</UnitListPrice>
<ProductSubLineItem>
<LineNumber>2001</LineNumber>
<TransactionType>ADD</TransactionType>
<Quantity>1</Quantity>
<ProductIdentification>
<PartnerProductIdentification>
<ProprietaryProductIdentifier>LIC-1</ProprietaryProductIdentifier>
<ProductDescription>License</ProductDescription>
<ProductCharacter>SOFTWARE</ProductCharacter>
</PartnerProductIdentification>
</ProductIdentification>
<UnitListPrice>
<FinancialAmount>
<GlobalCurrencyCode>USD</GlobalCurrencyCode>
<MonetaryAmount>0</MonetaryAmount>
</FinancialAmount>
</UnitListPrice>
</ProductSubLineItem>
</ProductLineItem>
<ProductLineItem>
<ProductLineNumber>3000</ProductLineNumber>
<ItemNo>3000</ItemNo>
<TransactionType>NEW</TransactionType>
<ProprietaryGroupIdentifier>3000</ProprietaryGroupIdentifier>
<ConfigurationGroupLineNumberReference>100</ConfigurationGroupLineNumberReference>
<Quantity>2</Quantity>
<ProductIdentification>
<PartnerProductIdentification>
<ProprietaryProductIdentifier>7DG9-CTO1WW</ProprietaryProductIdentifier>
<ProductDescription>ThinkSystem SR630 V4</ProductDescription>
<ProductName>#2</ProductName>
<ProductTypeCode>Hardware</ProductTypeCode>
</PartnerProductIdentification>
</ProductIdentification>
<UnitListPrice>
<FinancialAmount>
<GlobalCurrencyCode>USD</GlobalCurrencyCode>
<MonetaryAmount>90.00</MonetaryAmount>
</FinancialAmount>
</UnitListPrice>
</ProductLineItem>
</CFData>
</CFXML>`
workspace, err := parseCFXMLWorkspace([]byte(sample), "sample.xml")
if err != nil {
t.Fatalf("parseCFXMLWorkspace: %v", err)
}
if workspace.SourceFormat != "CFXML" {
t.Fatalf("unexpected source format: %q", workspace.SourceFormat)
}
if len(workspace.Configurations) != 2 {
t.Fatalf("expected 2 configurations, got %d", len(workspace.Configurations))
}
first := workspace.Configurations[0]
if first.GroupID != "1000" {
t.Fatalf("expected first group 1000, got %q", first.GroupID)
}
if first.Name != "#1" {
t.Fatalf("expected first config name #1, got %q", first.Name)
}
if first.ServerCount != 6 {
t.Fatalf("expected first server count 6, got %d", first.ServerCount)
}
if len(first.Rows) != 4 {
t.Fatalf("expected 4 vendor rows in first config, got %d", len(first.Rows))
}
foundSoftwareTopLevel := false
foundSoftwareSubRow := false
foundPrimaryTopLevelQty := 0
for _, row := range first.Rows {
if row.VendorPartnumber == "7DG9-CTO1WW" {
foundPrimaryTopLevelQty = row.Quantity
}
if row.VendorPartnumber == "7S0X-CTO8WW" {
foundSoftwareTopLevel = true
}
if row.VendorPartnumber == "LIC-1" {
foundSoftwareSubRow = true
}
}
if !foundSoftwareTopLevel {
t.Fatalf("expected software top-level row to stay inside configuration")
}
if !foundSoftwareSubRow {
t.Fatalf("expected software sub-row to stay inside configuration")
}
if foundPrimaryTopLevelQty != 1 {
t.Fatalf("expected primary top-level qty normalized to 1, got %d", foundPrimaryTopLevelQty)
}
if first.TotalPrice == nil || *first.TotalPrice != 750 {
t.Fatalf("expected first total price 750, got %+v", first.TotalPrice)
}
second := workspace.Configurations[1]
if second.Name != "#2" {
t.Fatalf("expected second config name #2, got %q", second.Name)
}
if len(second.Rows) != 1 {
t.Fatalf("expected second config to contain single top-level row, got %d", len(second.Rows))
}
}
func TestImportVendorWorkspaceToProject_AutoResolvesAndAppliesEstimate(t *testing.T) {
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
if err != nil {
t.Fatalf("init local db: %v", err)
}
t.Cleanup(func() { _ = local.Close() })
projectName := "OPS-2079"
if err := local.SaveProject(&localdb.LocalProject{
UUID: "project-1",
OwnerUsername: "tester",
Code: "OPS-2079",
Variant: "",
Name: &projectName,
IsActive: true,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}); err != nil {
t.Fatalf("save project: %v", err)
}
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
ServerID: 101,
Source: "estimate",
Version: "E-1",
Name: "Estimate",
CreatedAt: time.Now(),
SyncedAt: time.Now(),
}); err != nil {
t.Fatalf("save estimate pricelist: %v", err)
}
estimatePL, err := local.GetLocalPricelistByServerID(101)
if err != nil {
t.Fatalf("get estimate pricelist: %v", err)
}
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
{PricelistID: estimatePL.ID, LotName: "CPU_INTEL_6747P", Price: 1000},
{PricelistID: estimatePL.ID, LotName: "LICENSE_XCC", Price: 50},
}); err != nil {
t.Fatalf("save estimate items: %v", err)
}
bookRepo := local.DB()
if err := bookRepo.Create(&localdb.LocalPartnumberBook{
ServerID: 501,
Version: "B-1",
CreatedAt: time.Now(),
IsActive: true,
PartnumbersJSON: localdb.LocalStringList{"CPU-1", "LIC-1"},
}).Error; err != nil {
t.Fatalf("save active book: %v", err)
}
if err := bookRepo.Create([]localdb.LocalPartnumberBookItem{
{Partnumber: "CPU-1", LotsJSON: localdb.LocalPartnumberBookLots{{LotName: "CPU_INTEL_6747P", Qty: 1}}},
{Partnumber: "LIC-1", LotsJSON: localdb.LocalPartnumberBookLots{{LotName: "LICENSE_XCC", Qty: 1}}},
}).Error; err != nil {
t.Fatalf("save book items: %v", err)
}
service := NewLocalConfigurationService(local, syncsvc.NewService(nil, local), &QuoteService{}, func() bool { return false })
const sample = `<?xml version="1.0" encoding="UTF-8"?>
<CFXML>
<thisDocumentIdentifier>
<ProprietaryDocumentIdentifier>CFXML.workspace-test</ProprietaryDocumentIdentifier>
</thisDocumentIdentifier>
<CFData>
<ProductLineItem>
<ProductLineNumber>1000</ProductLineNumber>
<ItemNo>1000</ItemNo>
<TransactionType>NEW</TransactionType>
<ProprietaryGroupIdentifier>1000</ProprietaryGroupIdentifier>
<Quantity>2</Quantity>
<ProductIdentification>
<PartnerProductIdentification>
<ProprietaryProductIdentifier>7DG9-CTO1WW</ProprietaryProductIdentifier>
<ProductDescription>ThinkSystem SR630 V4</ProductDescription>
<ProductName>#1</ProductName>
<ProductTypeCode>Hardware</ProductTypeCode>
</PartnerProductIdentification>
</ProductIdentification>
<ProductSubLineItem>
<LineNumber>1001</LineNumber>
<Quantity>2</Quantity>
<ProductIdentification>
<PartnerProductIdentification>
<ProprietaryProductIdentifier>CPU-1</ProprietaryProductIdentifier>
<ProductDescription>CPU</ProductDescription>
</PartnerProductIdentification>
</ProductIdentification>
</ProductSubLineItem>
</ProductLineItem>
<ProductLineItem>
<ProductLineNumber>2000</ProductLineNumber>
<ItemNo>2000</ItemNo>
<TransactionType>NEW</TransactionType>
<ProprietaryGroupIdentifier>1000</ProprietaryGroupIdentifier>
<Quantity>2</Quantity>
<ProductIdentification>
<PartnerProductIdentification>
<ProprietaryProductIdentifier>7S0X-CTO8WW</ProprietaryProductIdentifier>
<ProductDescription>XClarity Controller</ProductDescription>
<ProductName>software1</ProductName>
<ProductTypeCode>Software</ProductTypeCode>
</PartnerProductIdentification>
</ProductIdentification>
<ProductSubLineItem>
<LineNumber>2001</LineNumber>
<Quantity>1</Quantity>
<ProductIdentification>
<PartnerProductIdentification>
<ProprietaryProductIdentifier>LIC-1</ProprietaryProductIdentifier>
<ProductDescription>License</ProductDescription>
</PartnerProductIdentification>
</ProductIdentification>
</ProductSubLineItem>
</ProductLineItem>
</CFData>
</CFXML>`
result, err := service.ImportVendorWorkspaceToProject("project-1", "sample.xml", []byte(sample), "tester")
if err != nil {
t.Fatalf("ImportVendorWorkspaceToProject: %v", err)
}
if result.Imported != 1 || len(result.Configs) != 1 {
t.Fatalf("unexpected import result: %+v", result)
}
cfg, err := local.GetConfigurationByUUID(result.Configs[0].UUID)
if err != nil {
t.Fatalf("load imported config: %v", err)
}
if cfg.PricelistID == nil || *cfg.PricelistID != 101 {
t.Fatalf("expected estimate pricelist id 101, got %+v", cfg.PricelistID)
}
if len(cfg.VendorSpec) != 4 {
t.Fatalf("expected 4 vendor spec rows, got %d", len(cfg.VendorSpec))
}
if len(cfg.Items) != 2 {
t.Fatalf("expected 2 cart items, got %d", len(cfg.Items))
}
if cfg.Items[0].LotName != "CPU_INTEL_6747P" || cfg.Items[0].Quantity != 2 || cfg.Items[0].UnitPrice != 1000 {
t.Fatalf("unexpected first item: %+v", cfg.Items[0])
}
if cfg.Items[1].LotName != "LICENSE_XCC" || cfg.Items[1].Quantity != 1 || cfg.Items[1].UnitPrice != 50 {
t.Fatalf("unexpected second item: %+v", cfg.Items[1])
}
if cfg.TotalPrice == nil || *cfg.TotalPrice != 4100 {
t.Fatalf("expected total price 4100 for 2 servers, got %+v", cfg.TotalPrice)
}
foundCPU := false
foundLIC := false
for _, row := range cfg.VendorSpec {
if row.VendorPartnumber == "CPU-1" {
foundCPU = true
if len(row.LotMappings) != 1 || row.LotMappings[0].LotName != "CPU_INTEL_6747P" || row.LotMappings[0].QuantityPerPN != 1 {
t.Fatalf("unexpected CPU mappings: %+v", row.LotMappings)
}
}
if row.VendorPartnumber == "LIC-1" {
foundLIC = true
if len(row.LotMappings) != 1 || row.LotMappings[0].LotName != "LICENSE_XCC" || row.LotMappings[0].QuantityPerPN != 1 {
t.Fatalf("unexpected LIC mappings: %+v", row.LotMappings)
}
}
}
if !foundCPU || !foundLIC {
t.Fatalf("expected resolved rows for CPU and LIC in vendor spec")
}
}
-41
View File
@@ -1,41 +0,0 @@
# Changes summary (2026-02-11)
Implemented strict `lot_category` flow using `pricelist_items.lot_category` only (no parsing from `lot_name`), plus local caching and backfill:
1. Local DB schema + migrations
- Added `lot_category` column to `local_pricelist_items` via `LocalPricelistItem` model.
- Added local migration `2026_02_11_local_pricelist_item_category` to add the column if missing and create indexes:
- `idx_local_pricelist_items_pricelist_lot (pricelist_id, lot_name)`
- `idx_local_pricelist_items_lot_category (lot_category)`
2. Server model/repository
- Added `LotCategory` field to `models.PricelistItem`.
- `PricelistRepository.GetItems` now sets `Category` from `LotCategory` (no parsing from `lot_name`).
3. Sync + local DB helpers
- `SyncPricelistItems` now saves `lot_category` into local cache via `PricelistItemToLocal`.
- Added `LocalDB.CountLocalPricelistItemsWithEmptyCategory` and `LocalDB.ReplaceLocalPricelistItems`.
- Added `LocalDB.GetLocalLotCategoriesByServerPricelistID` for strict category lookup.
- Added `SyncPricelists` backfill step: for used active pricelists with empty categories, force refresh items from server.
4. API handler
- `GET /api/pricelists/:id/items` returns `category` from `local_pricelist_items.lot_category` (no parsing from `lot_name`).
5. Article category foundation
- New package `internal/article`:
- `ResolveLotCategoriesStrict` pulls categories from local pricelist items and errors on missing category.
- `GroupForLotCategory` maps only allowed codes (CPU/MEM/GPU/M2/SSD/HDD/EDSFF/HHHL/NIC/HCA/DPU/PSU/PS) to article groups; excludes `SFP`.
- Error type `MissingCategoryForLotError` with base `ErrMissingCategoryForLot`.
6. Tests
- Added unit tests for converters and article category resolver.
- Added handler test to ensure `/api/pricelists/:id/items` returns `lot_category`.
- Added sync test for category backfill on used pricelist items.
- `go test ./...` passed.
Additional fixes (2026-02-11):
- Fixed article parsing bug: CPU/GPU parsers were swapped in `internal/article/generator.go`. CPU now uses last token from CPU lot; GPU uses model+memory from `GPU_vendor_model_mem_iface`.
- Adjusted configurator base tab layout to align labels on the same row (separate label row + input row grid).
UI rule (2026-02-19):
- In all breadcrumbs, truncate long specification/configuration names to 16 characters using ellipsis.
-7
View File
@@ -1,7 +0,0 @@
CREATE TABLE IF NOT EXISTS lot_partnumbers (
partnumber VARCHAR(255) NOT NULL,
lot_name VARCHAR(255) NOT NULL DEFAULT '',
description VARCHAR(10000) NULL,
PRIMARY KEY (partnumber, lot_name),
INDEX idx_lot_partnumbers_lot_name (lot_name)
);
@@ -1,25 +0,0 @@
-- Allow placeholder mappings (partnumber without bound lot) and store import description.
ALTER TABLE lot_partnumbers
ADD COLUMN IF NOT EXISTS description VARCHAR(10000) NULL AFTER lot_name;
ALTER TABLE lot_partnumbers
MODIFY COLUMN lot_name VARCHAR(255) NOT NULL DEFAULT '';
-- Drop FK on lot_name if it exists to allow unresolved placeholders.
SET @lp_fk_name := (
SELECT kcu.CONSTRAINT_NAME
FROM information_schema.KEY_COLUMN_USAGE kcu
WHERE kcu.TABLE_SCHEMA = DATABASE()
AND kcu.TABLE_NAME = 'lot_partnumbers'
AND kcu.COLUMN_NAME = 'lot_name'
AND kcu.REFERENCED_TABLE_NAME IS NOT NULL
LIMIT 1
);
SET @lp_drop_fk_sql := IF(
@lp_fk_name IS NULL,
'SELECT 1',
CONCAT('ALTER TABLE lot_partnumbers DROP FOREIGN KEY `', @lp_fk_name, '`')
);
PREPARE lp_stmt FROM @lp_drop_fk_sql;
EXECUTE lp_stmt;
DEALLOCATE PREPARE lp_stmt;
+18
View File
@@ -0,0 +1,18 @@
# Releases
This directory stores packaged release artifacts and per-release notes.
Rules:
- keep release notes next to the matching release directory as `RELEASE_NOTES.md`;
- do not keep duplicate changelog memory files elsewhere in the repository;
- if a release directory has no notes yet, add them there instead of creating side documents.
Current convention:
```text
releases/
v1.5.0/
RELEASE_NOTES.md
SHA256SUMS.txt
qfs-...
```
-72
View File
@@ -1,72 +0,0 @@
# v1.2.1 Release Notes
**Date:** 2026-02-09
**Changes since v1.2.0:** 2 commits
## Summary
Fixed configurator component substitution by updating to work with new pricelist-based pricing model. Addresses regression from v1.2.0 refactor that removed `CurrentPrice` field from components.
## Commits
### 1. Refactor: Remove CurrentPrice from local_components (5984a57)
**Type:** Refactor
**Files Changed:** 11 files, +167 insertions, -194 deletions
#### Overview
Transitioned from component-based pricing to pricelist-based pricing model:
- Removed `CurrentPrice` and `SyncedAt` from LocalComponent (metadata-only now)
- Added `WarehousePricelistID` and `CompetitorPricelistID` to LocalConfiguration
- Removed 2 unused methods: UpdateComponentPricesFromPricelist, EnsureComponentPricesFromPricelists
#### Key Changes
- **Data Model:**
- LocalComponent: now stores only metadata (LotName, LotDescription, Category, Model)
- LocalConfiguration: added warehouse and competitor pricelist references
- **Migrations:**
- drop_component_unused_fields - removes CurrentPrice, SyncedAt columns
- add_warehouse_competitor_pricelists - adds new pricelist fields
- **Quote Calculation:**
- Updated to use pricelist_items instead of component.CurrentPrice
- Added PricelistID field to QuoteRequest
- Maintains offline-first behavior
- **API:**
- Removed CurrentPrice from ComponentView
- Components API no longer returns pricing
### 2. Fix: Load component prices via API (acf7c8a)
**Type:** Bug Fix
**Files Changed:** 1 file (web/templates/index.html), +66 insertions, -12 deletions
#### Problem
After v1.2.0 refactor, the configurator's autocomplete was filtering out all components because it checked for the removed `current_price` field on component objects.
#### Solution
Implemented on-demand price loading via API:
- Added `ensurePricesLoaded()` function to fetch prices from `/api/quote/price-levels`
- Added `componentPricesCache` to cache loaded prices in memory
- Updated all 3 autocomplete modes (single, multi, section) to load prices when input is focused
- Changed price validation from `c.current_price` to `hasComponentPrice(lot_name)`
- Updated cart item creation to use cached API prices
#### Impact
- Components without prices are still filtered out (as required)
- Price checks now use API data instead of removed database field
- Frontend loads prices on-demand for better performance
## Testing Notes
- ✅ Configurator component substitution now works
- ✅ Prices load correctly from pricelist
- ✅ Offline mode still supported (prices cached after initial load)
- ✅ Multi-pricelist support functional (estimate/warehouse/competitor)
## Known Issues
None
## Migration Path
No database migration needed from v1.2.0 - migrations were applied in v1.2.0 release.
## Breaking Changes
None for end users. Internal: `ComponentView` no longer includes `CurrentPrice` in API responses.
-59
View File
@@ -1,59 +0,0 @@
# Release v1.2.2 (2026-02-09)
## Summary
Fixed CSV export filename inconsistency where project names weren't being resolved correctly. Standardized export format across both manual exports and project configuration exports to use `YYYY-MM-DD (project_name) config_name BOM.csv`.
## Commits
- `8f596ce` fix: standardize CSV export filename format to use project name
## Changes
### CSV Export Filename Standardization
**Problem:**
- ExportCSV and ExportConfigCSV had inconsistent filename formats
- Project names sometimes fell back to config names when not explicitly provided
- Export timestamps didn't reflect actual price update time
**Solution:**
- Unified format: `YYYY-MM-DD (project_name) config_name BOM.csv`
- Both export paths now use PriceUpdatedAt if available, otherwise CreatedAt
- Project name resolved from ProjectUUID via ProjectService for both paths
- Frontend passes project_uuid context when exporting
**Technical Details:**
Backend:
- Added `ProjectUUID` field to `ExportRequest` struct in handlers/export.go
- Updated ExportCSV to look up project name from ProjectUUID using ProjectService
- Ensured ExportConfigCSV gets project name from config's ProjectUUID
- Both use CreatedAt (for ExportCSV) or PriceUpdatedAt/CreatedAt (for ExportConfigCSV)
Frontend:
- Added `projectUUID` and `projectName` state variables in index.html
- Load and store projectUUID when configuration is loaded
- Pass `project_uuid` in JSON body for both export requests
## Files Modified
- `internal/handlers/export.go` - Project name resolution and ExportRequest update
- `internal/handlers/export_test.go` - Updated mock initialization with projectService param
- `cmd/qfs/main.go` - Pass projectService to ExportHandler constructor
- `web/templates/index.html` - Add projectUUID tracking and export payload updates
## Testing Notes
✅ All existing tests updated and passing
✅ Code builds without errors
✅ Export filename now includes correct project name
✅ Works for both form-based and project-based exports
## Breaking Changes
None - API response format unchanged, only filename generation updated.
## Known Issues
None identified.
-95
View File
@@ -1,95 +0,0 @@
# Release v1.2.3 (2026-02-10)
## Summary
Unified synchronization functionality with event-driven UI updates. Resolved user confusion about duplicate sync buttons by implementing a single sync source with automatic page refreshes.
## Changes
### Main Feature: Sync Event System
- **Added `sync-completed` event** in base.html's `syncAction()` function
- Dispatched after successful `/api/sync/all` or `/api/sync/push`
- Includes endpoint and response data in event detail
- Enables pages to react automatically to sync completion
### Configs Page (`configs.html`)
- **Removed "Импорт с сервера" button** - duplicate functionality no longer needed
- **Updated layout** - changed from 2-column grid to single button layout
- **Removed `importConfigsFromServer()` function** - functionality now handled by navbar sync
- **Added sync-completed event listener**:
- Automatically reloads configurations list after sync
- Resets pagination to first page
- New configurations appear immediately without manual refresh
### Projects Page (`projects.html`)
- **Wrapped initialization in DOMContentLoaded**:
- Moved `loadProjects()` and all event listeners inside handler
- Ensures DOM is fully loaded before accessing elements
- **Added sync-completed event listener**:
- Automatically reloads projects list after sync
- New projects appear immediately without manual refresh
### Pricelists Page (`pricelists.html`)
- **Added sync-completed event listener** to existing DOMContentLoaded:
- Automatically reloads pricelists when sync completes
- Maintains existing permissions and modal functionality
## Benefits
### User Experience
- ✅ Single "Синхронизация" button in navbar - no confusion about sync sources
- ✅ Automatic list updates after sync - no need for manual F5 refresh
- ✅ Consistent behavior across all pages (configs, projects, pricelists)
- ✅ Better feedback: toast notification + automatic UI refresh
### Architecture
- ✅ Event-driven loose coupling between navbar and pages
- ✅ Easy to extend to other pages (just add event listener)
- ✅ No backend changes needed
- ✅ Production-ready
## Breaking Changes
- **`/api/configs/import` endpoint** still works but UI button removed
- Users should use navbar "Синхронизация" button instead
- Backend API remains unchanged for backward compatibility
## Files Modified
1. `web/templates/base.html` - Added sync-completed event dispatch
2. `web/templates/configs.html` - Event listener + removed duplicate UI
3. `web/templates/projects.html` - DOMContentLoaded wrapper + event listener
4. `web/templates/pricelists.html` - Event listener for auto-refresh
**Stats:** 4 files changed, 59 insertions(+), 65 deletions(-)
## Commits
- `99fd80b` - feat: unify sync functionality with event-driven UI updates
## Testing Checklist
- [x] Configs page: New configurations appear after navbar sync
- [x] Projects page: New projects appear after navbar sync
- [x] Pricelists page: Pricelists refresh after navbar sync
- [x] Both `/api/sync/all` and `/api/sync/push` trigger updates
- [x] Toast notifications still show correctly
- [x] Sync status indicator updates
- [x] Error handling (423, network errors) still works
- [x] Mode switching (Active/Archive) works correctly
- [x] Backward compatibility maintained
## Known Issues
None - implementation is production-ready
## Migration Notes
No migration needed. Changes are frontend-only and backward compatible:
- Old `/api/configs/import` endpoint still functional
- No database schema changes
- No configuration changes needed
-68
View File
@@ -1,68 +0,0 @@
# Release v1.3.0 (2026-02-11)
## Summary
Introduced article generation with pricelist categories, added local configuration storage, and expanded sync/export capabilities. Simplified article generator compression and loosened project update constraints.
## Changes
### Main Features: Articles + Pricelist Categories
- **Article generation pipeline**
- New generator and tests under `internal/article/`
- Category support with test coverage
- **Pricelist category integration**
- Handler and repository updates
- Sync backfill test for category propagation
### Local Configuration Storage
- **Local DB support**
- New localdb models, converters, snapshots, and migrations
- Local configuration service for cached configurations
### Export & UI
- **Export handler updates** for article data output
- **Configs and index templates** adjusted for new article-related fields
### Behavior Changes
- **Cross-user project updates allowed**
- Removed restriction in project service
- **Article compression refinement**
- Generator logic simplified to reduce complexity
## Breaking Changes
None identified. Existing APIs remain intact.
## Files Modified
1. `internal/article/*` - Article generator + categories + tests
2. `internal/localdb/*` - Local DB models, migrations, snapshots
3. `internal/handlers/export.go` - Export updates
4. `internal/handlers/pricelist.go` - Category handling
5. `internal/services/sync/service.go` - Category backfill logic
6. `web/templates/configs.html` - Article field updates
7. `web/templates/index.html` - Article field updates
**Stats:** 33 files changed, 2059 insertions(+), 329 deletions(-)
## Commits
- `5edffe8` - Add article generation and pricelist categories
- `e355903` - Allow cross-user project updates
- `e58fd35` - Refine article compression and simplify generator
## Testing Checklist
- [ ] Tests not run (not requested)
## Migration Notes
- New migrations:
- `022_add_article_to_configurations.sql`
- `023_add_server_model_to_configurations.sql`
- `024_add_support_code_to_configurations.sql`
- Ensure migrations are applied before running v1.3.0
-66
View File
@@ -1,66 +0,0 @@
# Release v1.3.2 (2026-02-19)
## Summary
Release focuses on stability and data integrity for local configurations. Added configuration revision history, stronger recovery for broken local sync/version states, improved sync self-healing, and clearer API error logging.
## Changes
### Configuration Revisions
- Added full local configuration revision flow with storage and UI support.
- Introduced revisions page/template and backend plumbing for browsing revisions.
- Prevented duplicate revisions when content did not actually change.
### Local Data Integrity and Recovery
- Added migration and snapshot support for local configuration version data.
- Hardened updates for legacy/orphaned configuration rows:
- allow update when project UUID is unchanged even if referenced project is missing locally;
- recover gracefully when `current_version_id` is stale or version rows are missing.
- Added regression tests for orphan-project and missing-current-version scenarios.
### Sync Reliability
- Added smart self-healing path for sync errors.
- Fixed duplicate-project sync edge cases.
### API and Logging
- Improved HTTP error mapping for configuration updates (`404/403` instead of generic `500` in known cases).
- Enhanced request logger to capture error responses (status, response body snippet, gin errors) for failed requests.
### UI and Export
- Updated project detail and index templates for revisions and related UX improvements.
- Updated export pipeline and tests to align with revisions/project behavior changes.
## Breaking Changes
None identified.
## Files Changed
- 24 files changed, 2394 insertions(+), 482 deletions(-)
- Main touched areas:
- `internal/services/local_configuration.go`
- `internal/services/local_configuration_versioning_test.go`
- `internal/localdb/{localdb.go,migrations.go,snapshots.go,local_migrations_test.go}`
- `internal/services/export.go`
- `cmd/qfs/main.go`
- `web/templates/{config_revisions.html,project_detail.html,index.html,base.html}`
## Commits Included (`v1.3.1..v1.3.2`)
- `b153afb` - Add smart self-healing for sync errors
- `8508ee2` - Fix sync errors for duplicate projects and add modal scrolling
- `2e973b6` - Add configuration revisions system and project variant deletion
- `71f73e2` - chore: save current changes
- `cbaeafa` - Deduplicate configuration revisions and update revisions UI
- `075fc70` - Harden local config updates and error logging
## Testing
- [x] Targeted tests for local configuration update/version recovery:
- `go test ./internal/services -run 'TestUpdateNoAuth(AllowsOrphanProjectWhenUUIDUnchanged|RecoversWhenCurrentVersionMissing|KeepsProjectWhenProjectUUIDOmitted)$'`
- [ ] Full regression suite not run in this release step.
+12 -81
View File
@@ -1,89 +1,20 @@
# QuoteForge v1.2.1 # QuoteForge v1.2.1
**Дата релиза:** 2026-02-09 Дата релиза: 2026-02-09
**Тег:** `v1.2.1` Тег: `v1.2.1`
**GitHub:** https://git.mchus.pro/mchus/QuoteForge/releases/tag/v1.2.1
## Резюме ## Ключевые изменения
Быстрый патч-релиз, исправляющий регрессию в конфигураторе после рефактора v1.2.0. После удаления поля `CurrentPrice` из компонентов, autocomplete перестал показывать компоненты. Теперь используется на-demand загрузка цен через API. - исправлена регрессия autocomplete после отказа от `CurrentPrice` в компонентах;
- цены компонентов подгружаются через `/api/quote/price-levels`;
- подготовлена сопровождающая release documentation.
## Что исправлено ## Коммиты релиза
### 🐛 Configurator Component Substitution (acf7c8a) - `acf7c8a` fix: load component prices via API instead of removed current_price field
- **Проблема:** После рефактора в v1.2.0, autocomplete фильтровал ВСЕ компоненты, потому что проверял удаленное поле `current_price` - `5984a57` refactor: remove CurrentPrice from local_components and transition to pricelist-based pricing
- **Решение:** Загрузка цен на-demand через `/api/quote/price-levels` - `8fd27d1` docs: update v1.2.1 release notes with full changelog
- Добавлен `componentPricesCache` для кэширования цен в памяти
- Функция `ensurePricesLoaded()` загружает цены при фокусе на поле поиска
- Все 3 режима autocomplete (single, multi, section) обновлены
- Компоненты без цен по-прежнему фильтруются (как требуется), но проверка использует API
- **Затронутые файлы:** `web/templates/index.html` (+66 строк, -12 строк)
## История v1.2.0 → v1.2.1 ## Совместимость
Всего коммитов: **2** - дополнительных миграций поверх `v1.2.0` не требуется.
| Хеш | Автор | Сообщение |
|-----|-------|-----------|
| `acf7c8a` | Claude | fix: load component prices via API instead of removed current_price field |
| `5984a57` | Claude | refactor: remove CurrentPrice from local_components and transition to pricelist-based pricing |
## Тестирование
✅ Configurator component substitution работает
✅ Цены загружаются корректно из pricelist
✅ Offline режим поддерживается (цены кэшируются после первой загрузки)
✅ Multi-pricelist поддержка функциональна (estimate/warehouse/competitor)
## Breaking Changes
Нет критических изменений для конечных пользователей.
⚠️ **Для разработчиков:** `ComponentView` API больше не возвращает `CurrentPrice`.
## Миграция
Не требуется миграция БД — все миграции были применены в v1.2.0.
## Установка
### macOS
```bash
# Скачать и распаковать
tar xzf qfs-v1.2.1-darwin-arm64.tar.gz # для Apple Silicon
# или
tar xzf qfs-v1.2.1-darwin-amd64.tar.gz # для Intel Mac
# Снять ограничение Gatekeeper (если требуется)
xattr -d com.apple.quarantine ./qfs
# Запустить
./qfs
```
### Linux
```bash
tar xzf qfs-v1.2.1-linux-amd64.tar.gz
./qfs
```
### Windows
```bash
# Распаковать qfs-v1.2.1-windows-amd64.zip
# Запустить qfs.exe
```
## Известные проблемы
Нет известных проблем на момент релиза.
## Поддержка
По вопросам обращайтесь: [@mchus](https://git.mchus.pro/mchus)
---
*Отправлено с ❤️ через Claude Code*
+25
View File
@@ -0,0 +1,25 @@
# QuoteForge v1.5.3
Дата релиза: 2026-03-15
Тег: `v1.5.3`
## Ключевые изменения
- документация проекта очищена и приведена к одному формату;
- `bible-local/` сокращён до актуальных архитектурных контрактов без исторического шума;
- удалены временные заметки и дублирующий changelog в `releases/memory`;
- runtime config упрощён: из активной схемы убраны мёртвые секции, оставлены только используемые части.
## Затронутые области
- корневой `README.md`;
- весь `bible-local/`;
- `config.example.yaml`;
- `internal/config/config.go`;
- release notes и правила их хранения в `releases/`.
## Совместимость
- релиз не меняет пользовательскую модель данных;
- локальные и серверные миграции не требуются;
- основное изменение касается документации и формы runtime-конфига.
-78
View File
@@ -1,78 +0,0 @@
# QuoteForge — План очистки (удаление admin pricing)
Цель: убрать всё, что связано с администрированием цен, складскими справками, алертами.
Оставить: конфигуратор, проекты, read-only просмотр прайслистов, sync, offline-first.
---
## 1. Удалить файлы
- [x] `internal/handlers/pricing.go` (40.6KB) — весь admin pricing UI
- [x] `internal/services/pricing/` — весь пакет расчёта цен
- [x] `internal/services/pricelist/` — весь пакет управления прайслистами
- [x] `internal/services/stock_import.go` — импорт складских справок
- [x] `internal/services/alerts/` — весь пакет алертов
- [x] `internal/warehouse/` — алгоритмы расчёта цен по складу
- [x] `web/templates/admin_pricing.html` (109KB) — страница admin pricing
- [x] `cmd/cron/` — cron jobs (cleanup-pricelists, update-prices, update-popularity)
- [x] `cmd/importer/` — утилита импорта данных
## 2. Упростить `internal/handlers/pricelist.go` (read-only)
Read-only методы (List, Get, GetItems, GetLotNames, GetLatest) уже работают
только через `h.localDB` (SQLite) без `pricelist.Service`.
- [x] Убрать поле `service *pricelist.Service` из структуры `PricelistHandler`
- [x] Изменить конструктор: `NewPricelistHandler(localDB *localdb.LocalDB)`
- [x] Удалить write-методы: `Create()`, `CreateWithProgress()`, `Delete()`, `SetActive()`, `CanWrite()`
- [x] Удалить метод `refreshLocalPricelistCacheFromServer()` (зависит от service)
- [x] Удалить import `pricelist` пакета
- [x] Оставить: `List()`, `Get()`, `GetItems()`, `GetLotNames()`, `GetLatest()`
## 3. Упростить `cmd/qfs/main.go`
- [x] Удалить создание сервисов: `pricingService`, `alertService`, `pricelistService`, `stockImportService`
- [x] Удалить хэндлер: `pricingHandler`
- [x] Изменить создание `pricelistHandler`: `NewPricelistHandler(local)` (без service)
- [x] Удалить repositories: `priceRepo`, `alertRepo` (statsRepo оставить — nil-safe)
- [x] Удалить все routes `/api/admin/pricing/*` (строки ~1407-1430)
- [x] Из `/api/pricelists/*` оставить только read-only:
- `GET ""` (List), `GET "/latest"`, `GET "/:id"`, `GET "/:id/items"`, `GET "/:id/lots"`
- [x] Удалить write routes: `POST ""`, `POST "/create-with-progress"`, `PATCH "/:id/active"`, `DELETE "/:id"`, `GET "/can-write"`
- [x] Удалить web page `/admin/pricing`
- [x] Исправить `/pricelists` — вместо redirect на admin/pricing сделать страницу
- [x] В `QuoteService` конструкторе: передавать `nil` для `pricingService`
- [x] Удалить imports: `pricing`, `pricelist`, `alerts` пакеты
## 4. Упростить `handlers/web.go`
- [x] Удалить из `simplePages`: `admin_pricing.html`
- [x] Удалить метод: `AdminPricing()`
- [x] Оставить все остальные методы включая `Pricelists()` и `PricelistDetail()`
## 5. Упростить `base.html` (навигация)
- [x] Убрать ссылку "Администратор цен"
- [x] Добавить ссылку "Прайслисты" (на `/pricelists`)
- [x] Оставить: "Мои проекты", "Прайслисты", sync indicator
## 6. Sync — оставить полностью
- Background worker: pull компоненты + прайслисты, push конфигурации
- Все `/api/sync/*` endpoints остаются
- Это ядро offline-first архитектуры
## 7. Верификация
- [x] `go build ./cmd/qfs` — компилируется
- [x] `go vet ./...` — без ошибок
- [ ] Запуск → `/configs` работает
- [ ] `/pricelists` — read-only список работает
- [ ] `/pricelists/:id` — детали работают
- [ ] Sync с сервером работает
- [ ] Нет ссылок на admin pricing в UI
## 8. Обновить CLAUDE.md
- [x] Убрать разделы про admin pricing, stock import, alerts, cron
- [x] Обновить API endpoints список
- [x] Обновить описание приложения
+6 -6
View File
@@ -20,7 +20,7 @@
</div> </div>
<div class="max-w-md"> <div class="max-w-md">
<input id="configs-search" type="text" placeholder="Поиск квоты по названию" <input id="configs-search" type="text" placeholder="Поиск конфигурации по названию"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500"> class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div> </div>
@@ -141,7 +141,7 @@
<div class="space-y-4"> <div class="space-y-4">
<div class="text-sm text-gray-600"> <div class="text-sm text-gray-600">
Квота: <span id="move-project-config-name" class="font-medium text-gray-900"></span> Конфигурация: <span id="move-project-config-name" class="font-medium text-gray-900"></span>
</div> </div>
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Проект</label> <label class="block text-sm font-medium text-gray-700 mb-1">Проект</label>
@@ -174,7 +174,7 @@
<div id="create-project-on-move-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50"> <div id="create-project-on-move-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6"> <div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<h2 class="text-xl font-semibold mb-3">Проект не найден</h2> <h2 class="text-xl font-semibold mb-3">Проект не найден</h2>
<p class="text-sm text-gray-600 mb-4">Проект с кодом "<span id="create-project-on-move-code" class="font-medium text-gray-900"></span>" не найден. <span id="create-project-on-move-description">Создать и привязать квоту?</span></p> <p class="text-sm text-gray-600 mb-4">Проект с кодом "<span id="create-project-on-move-code" class="font-medium text-gray-900"></span>" не найден. <span id="create-project-on-move-description">Создать и привязать конфигурацию?</span></p>
<div class="mb-4"> <div class="mb-4">
<label for="create-project-on-move-name" class="block text-sm font-medium text-gray-700 mb-1">Название проекта</label> <label for="create-project-on-move-name" class="block text-sm font-medium text-gray-700 mb-1">Название проекта</label>
<input id="create-project-on-move-name" type="text" placeholder="Например: Инфраструктура для OPS-123" <input id="create-project-on-move-name" type="text" placeholder="Например: Инфраструктура для OPS-123"
@@ -601,7 +601,7 @@ function openCreateProjectOnMoveModal(projectName) {
document.getElementById('create-project-on-move-code').textContent = projectName; document.getElementById('create-project-on-move-code').textContent = projectName;
document.getElementById('create-project-on-move-name').value = projectName; document.getElementById('create-project-on-move-name').value = projectName;
document.getElementById('create-project-on-move-variant').value = ''; document.getElementById('create-project-on-move-variant').value = '';
document.getElementById('create-project-on-move-description').textContent = 'Создать и привязать квоту?'; document.getElementById('create-project-on-move-description').textContent = 'Создать и привязать конфигурацию?';
document.getElementById('create-project-on-move-confirm-btn').textContent = 'Создать и привязать'; document.getElementById('create-project-on-move-confirm-btn').textContent = 'Создать и привязать';
document.getElementById('create-project-on-move-modal').classList.remove('hidden'); document.getElementById('create-project-on-move-modal').classList.remove('hidden');
document.getElementById('create-project-on-move-modal').classList.add('flex'); document.getElementById('create-project-on-move-modal').classList.add('flex');
@@ -719,7 +719,7 @@ async function moveConfigToProject(uuid, projectUUID) {
}); });
if (!resp.ok) { if (!resp.ok) {
const err = await resp.json(); const err = await resp.json();
alert('Не удалось перенести квоту: ' + (err.error || 'ошибка')); alert('Не удалось перенести конфигурацию: ' + (err.error || 'ошибка'));
return false; return false;
} }
closeMoveProjectModal(); closeMoveProjectModal();
@@ -727,7 +727,7 @@ async function moveConfigToProject(uuid, projectUUID) {
await loadConfigs(); await loadConfigs();
return true; return true;
} catch (e) { } catch (e) {
alert('Ошибка переноса квоты'); alert('Ошибка переноса конфигурации');
return false; return false;
} }
} }
+421 -319
View File
@@ -199,58 +199,106 @@
</div><!-- end top-section-bom --> </div><!-- end top-section-bom -->
<!-- Top-tab section: Ценообразование --> <!-- Top-tab section: Ценообразование -->
<div id="top-section-pricing" class="hidden"> <div id="top-section-pricing" class="hidden space-y-6">
<!-- === Цена покупки === -->
<div class="bg-white rounded-lg shadow p-4"> <div class="bg-white rounded-lg shadow p-4">
<div id="pricing-table-container"> <div class="flex items-baseline gap-3 mb-3">
<div class="overflow-x-auto"> <h3 class="text-base font-semibold text-gray-800">Цена покупки</h3>
<table class="w-full text-sm border-collapse"> <span class="text-xs text-gray-400">Цены указаны за 1 шт.</span>
<thead class="bg-gray-50 text-gray-700"> </div>
<tr> <div class="overflow-x-auto">
<th class="px-3 py-2 text-left border-b">LOT</th> <table class="w-full text-sm border-collapse">
<th class="px-3 py-2 text-left border-b">PN вендора</th> <thead class="bg-gray-50 text-gray-700">
<th class="px-3 py-2 text-left border-b">Описание</th> <tr>
<th class="px-3 py-2 text-right border-b">Кол-во</th> <th class="px-3 py-2 text-left border-b">LOT</th>
<th class="px-3 py-2 text-right border-b">Estimate</th> <th class="px-3 py-2 text-left border-b">PN вендора</th>
<th class="px-3 py-2 text-right border-b">Цена проектная</th> <th class="px-3 py-2 text-left border-b">Описание</th>
<th class="px-3 py-2 text-right border-b">Склад</th> <th class="px-3 py-2 text-right border-b">Кол-во</th>
<th class="px-3 py-2 text-right border-b">Конк.</th> <th class="px-3 py-2 text-right border-b">Estimate</th>
</tr> <th class="px-3 py-2 text-right border-b">Склад</th>
</thead> <th class="px-3 py-2 text-right border-b">Конкуренты</th>
<tbody id="pricing-table-body"> <th class="px-3 py-2 text-right border-b">Ручная цена</th>
<tr><td colspan="8" class="px-3 py-8 text-center text-gray-400">Загрузите BOM во вкладке «BOM»</td></tr> </tr>
</tbody> </thead>
<tfoot id="pricing-table-foot" class="hidden bg-gray-50 font-semibold"> <tbody id="pricing-body-buy">
<tr> <tr><td colspan="8" class="px-3 py-8 text-center text-gray-400">Загрузите BOM во вкладке «BOM»</td></tr>
<td colspan="4" class="px-3 py-2 text-right">Итого:</td> </tbody>
<td class="px-3 py-2 text-right" id="pricing-total-estimate"></td> <tfoot id="pricing-foot-buy" class="hidden bg-gray-50 font-semibold">
<td class="px-3 py-2 text-right font-bold" id="pricing-total-vendor"></td> <tr>
<td class="px-3 py-2 text-right" id="pricing-total-warehouse"></td> <td colspan="4" class="px-3 py-2 text-right">Итого:</td>
<td class="px-3 py-2 text-right"></td> <td class="px-3 py-2 text-right" id="pricing-total-buy-estimate"></td>
</tr> <td class="px-3 py-2 text-right" id="pricing-total-buy-warehouse"></td>
</tfoot> <td class="px-3 py-2 text-right" id="pricing-total-buy-competitor"></td>
</table> <td class="px-3 py-2 text-right font-bold" id="pricing-total-buy-vendor"></td>
</div> </tr>
<div class="mt-4 flex flex-wrap items-center gap-4"> </tfoot>
<label class="text-sm font-medium text-gray-700">Своя цена:</label> </table>
<input type="number" id="pricing-custom-price" step="0.01" min="0" placeholder="0.00" </div>
class="w-40 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500" <div class="mt-4 flex flex-wrap items-center gap-4">
oninput="onPricingCustomPriceInput()"> <label class="text-sm font-medium text-gray-700">Своя цена:</label>
<label class="text-sm font-medium text-gray-700">Uplift:</label> <input type="number" id="pricing-custom-price-buy" step="0.01" min="0" placeholder="0.00"
<input type="text" id="pricing-uplift" inputmode="decimal" placeholder="1,0000" class="w-40 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
class="w-32 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500" oninput="onBuyCustomPriceInput()">
oninput="onPricingUpliftInput()"> <button onclick="setPricingCustomPriceFromVendor()" class="px-3 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 border border-gray-300 text-sm">
<button onclick="setPricingCustomPriceFromVendor()" class="px-3 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 border border-gray-300 text-sm"> Проставить цены BOM
Проставить цены BOM </button>
</button> <button onclick="exportPricingCSV('buy')" class="px-3 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm">
<button onclick="exportPricingCSV()" class="px-3 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm"> Экспорт CSV
Экспорт CSV </button>
</button>
<span id="pricing-discount-info" class="text-sm text-gray-500 hidden">
Скидка от Estimate: <span id="pricing-discount-pct" class="font-semibold text-green-600"></span>
</span>
</div>
</div> </div>
</div> </div>
<!-- === Цена продажи === -->
<div class="bg-white rounded-lg shadow p-4">
<div class="flex items-baseline gap-3 mb-1">
<h3 class="text-base font-semibold text-gray-800">Цена продажи</h3>
<span class="text-xs text-gray-400">Цены указаны за 1 шт.</span>
</div>
<p class="text-xs text-gray-500 mb-3">Склад и Конкуренты умножаются на 1,3</p>
<div class="overflow-x-auto">
<table class="w-full text-sm border-collapse">
<thead class="bg-gray-50 text-gray-700">
<tr>
<th class="px-3 py-2 text-left border-b">LOT</th>
<th class="px-3 py-2 text-left border-b">PN вендора</th>
<th class="px-3 py-2 text-left border-b">Описание</th>
<th class="px-3 py-2 text-right border-b">Кол-во</th>
<th class="px-3 py-2 text-right border-b">Estimate</th>
<th class="px-3 py-2 text-right border-b">Склад</th>
<th class="px-3 py-2 text-right border-b">Конкуренты</th>
<th class="px-3 py-2 text-right border-b">Ручная цена</th>
</tr>
</thead>
<tbody id="pricing-body-sale">
<tr><td colspan="8" class="px-3 py-8 text-center text-gray-400">Загрузите BOM во вкладке «BOM»</td></tr>
</tbody>
<tfoot id="pricing-foot-sale" class="hidden bg-gray-50 font-semibold">
<tr>
<td colspan="4" class="px-3 py-2 text-right">Итого:</td>
<td class="px-3 py-2 text-right" id="pricing-total-sale-estimate"></td>
<td class="px-3 py-2 text-right" id="pricing-total-sale-warehouse"></td>
<td class="px-3 py-2 text-right" id="pricing-total-sale-competitor"></td>
<td class="px-3 py-2 text-right font-bold" id="pricing-total-sale-vendor"></td>
</tr>
</tfoot>
</table>
</div>
<div class="mt-4 flex flex-wrap items-center gap-4">
<label class="text-sm font-medium text-gray-700">Аплифт к estimate:</label>
<input type="text" id="pricing-uplift-sale" inputmode="decimal" placeholder="1,3000"
class="w-28 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
oninput="onSaleMarkupInput()">
<label class="text-sm font-medium text-gray-700">Своя цена:</label>
<input type="number" id="pricing-custom-price-sale" step="0.01" min="0" placeholder="0.00"
class="w-40 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
oninput="onSaleCustomPriceInput()">
<button onclick="exportPricingCSV('sale')" class="px-3 py-2 bg-green-600 text-white rounded hover:bg-green-700 text-sm">
Экспорт CSV
</button>
</div>
</div>
</div><!-- end top-section-pricing --> </div><!-- end top-section-pricing -->
</div> </div>
@@ -755,6 +803,9 @@ document.addEventListener('DOMContentLoaded', async function() {
document.getElementById('server-count').value = serverCount; document.getElementById('server-count').value = serverCount;
document.getElementById('total-server-count').textContent = serverCount; document.getElementById('total-server-count').textContent = serverCount;
selectedPricelistIds.estimate = config.pricelist_id || null; selectedPricelistIds.estimate = config.pricelist_id || null;
selectedPricelistIds.warehouse = config.warehouse_pricelist_id || null;
selectedPricelistIds.competitor = config.competitor_pricelist_id || null;
disablePriceRefresh = Boolean(config.disable_price_refresh);
onlyInStock = Boolean(config.only_in_stock); onlyInStock = Boolean(config.only_in_stock);
if (config.items && config.items.length > 0) { if (config.items && config.items.length > 0) {
@@ -1983,6 +2034,9 @@ function buildSavePayload() {
support_code: supportCode, support_code: supportCode,
article: getCurrentArticle(), article: getCurrentArticle(),
pricelist_id: selectedPricelistIds.estimate, pricelist_id: selectedPricelistIds.estimate,
warehouse_pricelist_id: selectedPricelistIds.warehouse,
competitor_pricelist_id: selectedPricelistIds.competitor,
disable_price_refresh: disablePriceRefresh,
only_in_stock: onlyInStock only_in_stock: onlyInStock
}; };
} }
@@ -3475,8 +3529,10 @@ async function loadVendorSpec(configUUID) {
// ==================== ЦЕНООБРАЗОВАНИЕ ==================== // ==================== ЦЕНООБРАЗОВАНИЕ ====================
async function renderPricingTab() { async function renderPricingTab() {
const tbody = document.getElementById('pricing-table-body'); const tbodyBuy = document.getElementById('pricing-body-buy');
const tfoot = document.getElementById('pricing-table-foot'); const tfootBuy = document.getElementById('pricing-foot-buy');
const tbodySale = document.getElementById('pricing-body-sale');
const tfootSale = document.getElementById('pricing-foot-sale');
const cart = window._currentCart || []; const cart = window._currentCart || [];
const compMap = {}; const compMap = {};
@@ -3507,7 +3563,6 @@ async function renderPricingTab() {
}); });
} }
}); });
// Also price LOTs that exist in current Estimate but are not covered by BOM mappings.
cart.forEach(item => { cart.forEach(item => {
if (!item?.lot_name || seen.has(item.lot_name)) return; if (!item?.lot_name || seen.has(item.lot_name)) return;
seen.add(item.lot_name); seen.add(item.lot_name);
@@ -3518,7 +3573,7 @@ async function renderPricingTab() {
} }
// Fetch fresh price levels for these LOTs // Fetch fresh price levels for these LOTs
const priceMap = {}; // lot_name → {estimate_price, ...} const priceMap = {};
if (itemsForPriceLevels.length) { if (itemsForPriceLevels.length) {
try { try {
const payload = { const payload = {
@@ -3537,204 +3592,207 @@ async function renderPricingTab() {
const data = await resp.json(); const data = await resp.json();
(data.items || []).forEach(i => { priceMap[i.lot_name] = i; }); (data.items || []).forEach(i => { priceMap[i.lot_name] = i; });
} }
} catch(e) { /* silent — pricing tab renders with available data */ } } catch(e) { /* silent */ }
} }
let totalVendor = 0, totalEstimate = 0, totalWarehouse = 0; // Sale uplift applied to estimate (default 1.3)
let hasVendor = false, hasEstimate = false, hasWarehouse = false; const saleUplift = (() => {
const v = parseDecimalInput(document.getElementById('pricing-uplift-sale')?.value || '');
return v > 0 ? v : 1.3;
})();
const SALE_FIXED_MULT = 1.3;
tbody.innerHTML = ''; // Helper: returns unit prices from pricelist for a single LOT
const _getUnitPrices = (pl) => ({
if (!bomRows.length) { estUnit: (pl && pl.estimate_price > 0) ? pl.estimate_price : 0,
if (!cart.length) { warehouseUnit: (pl && pl.warehouse_price > 0) ? pl.warehouse_price : null,
tbody.innerHTML = '<tr><td colspan="9" class="px-3 py-8 text-center text-gray-400">Нет данных для отображения</td></tr>'; competitorUnit: (pl && pl.competitor_price > 0) ? pl.competitor_price : null,
tfoot.classList.add('hidden');
return;
}
cart.forEach(item => {
const tr = document.createElement('tr');
tr.classList.add('pricing-row');
const pl = priceMap[item.lot_name];
const estUnit = (pl && pl.estimate_price > 0) ? pl.estimate_price : (item.unit_price || 0);
const warehouseUnit = (pl && pl.warehouse_price > 0) ? pl.warehouse_price : null;
const estimateTotal = estUnit * item.quantity;
const warehouseTotal = warehouseUnit != null ? warehouseUnit * item.quantity : null;
if (estimateTotal > 0) { totalEstimate += estimateTotal; hasEstimate = true; }
if (warehouseTotal != null && warehouseTotal > 0) { totalWarehouse += warehouseTotal; hasWarehouse = true; }
tr.dataset.est = estimateTotal;
const desc = (compMap[item.lot_name] || {}).description || '';
tr.dataset.vendorOrig = '';
tr.innerHTML = `
<td class="px-3 py-1.5 text-xs">${escapeHtml(item.lot_name)}</td>
<td class="px-3 py-1.5 text-xs text-gray-400"></td>
<td class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs">${escapeHtml(desc)}</td>
<td class="px-3 py-1.5 text-right">${item.quantity}</td>
<td class="px-3 py-1.5 text-right text-xs">${estimateTotal > 0 ? formatCurrency(estimateTotal) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs text-gray-400 pricing-vendor-price"></td>
<td class="px-3 py-1.5 text-right text-xs">${warehouseTotal != null && warehouseTotal > 0 ? formatCurrency(warehouseTotal) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs text-gray-400"></td>
`;
tbody.appendChild(tr);
});
} else {
const coveredLots = new Set();
bomRows.forEach(row => {
const tr = document.createElement('tr');
tr.classList.add('pricing-row');
const baseLot = rowBaseLot(row);
const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1);
if (baseLot) coveredLots.add(baseLot);
allocs.forEach(a => coveredLots.add(a.lot_name));
const hasMapping = !!baseLot || allocs.length > 0;
const isUnresolved = !hasMapping;
let rowEst = 0;
let hasEstimateForRow = false;
let rowWarehouse = 0;
let hasWarehouseForRow = false;
if (baseLot) {
const pl = priceMap[baseLot];
const estimateUnit = (pl && pl.estimate_price > 0) ? pl.estimate_price : null;
const warehouseUnit = (pl && pl.warehouse_price > 0) ? pl.warehouse_price : null;
if (estimateUnit != null) {
rowEst += estimateUnit * row.quantity * _getRowLotQtyPerPN(row);
hasEstimateForRow = true;
}
if (warehouseUnit != null) {
rowWarehouse += warehouseUnit * row.quantity * _getRowLotQtyPerPN(row);
hasWarehouseForRow = true;
}
}
allocs.forEach(a => {
const pl = priceMap[a.lot_name];
const estimateUnit = (pl && pl.estimate_price > 0) ? pl.estimate_price : null;
const warehouseUnit = (pl && pl.warehouse_price > 0) ? pl.warehouse_price : null;
if (estimateUnit != null) {
rowEst += estimateUnit * row.quantity * a.quantity;
hasEstimateForRow = true;
}
if (warehouseUnit != null) {
rowWarehouse += warehouseUnit * row.quantity * a.quantity;
hasWarehouseForRow = true;
}
});
const vendorTotal = row.total_price != null ? row.total_price : (row.unit_price != null ? row.unit_price * row.quantity : null);
if (vendorTotal != null) { totalVendor += vendorTotal; hasVendor = true; }
if (hasEstimateForRow) { totalEstimate += rowEst; hasEstimate = true; }
if (hasWarehouseForRow) { totalWarehouse += rowWarehouse; hasWarehouse = true; }
tr.dataset.est = rowEst;
tr.dataset.vendorOrig = vendorTotal != null ? vendorTotal : '';
const desc = row.description || (baseLot ? ((compMap[baseLot] || {}).description || '') : '');
let lotCell = '<span class="text-red-500">н/д</span>';
if (baseLot && allocs.length) {
lotCell = `${escapeHtml(baseLot)} <span class="text-gray-400">+${allocs.length}</span>`;
} else if (baseLot) {
lotCell = escapeHtml(baseLot);
} else if (allocs.length) {
lotCell = `${escapeHtml(allocs[0].lot_name)}${allocs.length > 1 ? ` <span class="text-gray-400">+${allocs.length - 1}</span>` : ''}`;
}
tr.innerHTML = `
<td class="px-3 py-1.5 text-xs">${lotCell}</td>
<td class="px-3 py-1.5 font-mono text-xs">${escapeHtml(row.vendor_pn)}</td>
<td class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs">${escapeHtml(desc)}</td>
<td class="px-3 py-1.5 text-right">${row.quantity}</td>
<td class="px-3 py-1.5 text-right text-xs">${hasEstimateForRow ? formatCurrency(rowEst) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs pricing-vendor-price">${vendorTotal != null ? formatCurrency(vendorTotal) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs">${hasWarehouseForRow ? formatCurrency(rowWarehouse) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs text-gray-400"></td>
`;
tbody.appendChild(tr);
});
// Append Estimate-only LOTs that were counted in cart but not mapped from BOM.
cart.forEach(item => {
if (!item?.lot_name || coveredLots.has(item.lot_name)) return;
const tr = document.createElement('tr');
tr.classList.add('pricing-row');
tr.classList.add('bg-blue-50');
const pl = priceMap[item.lot_name];
const estUnit = (pl && pl.estimate_price > 0) ? pl.estimate_price : (item.unit_price || 0);
const warehouseUnit = (pl && pl.warehouse_price > 0) ? pl.warehouse_price : null;
const estimateTotal = estUnit * item.quantity;
const warehouseTotal = warehouseUnit != null ? warehouseUnit * item.quantity : null;
if (estimateTotal > 0) { totalEstimate += estimateTotal; hasEstimate = true; }
if (warehouseTotal != null && warehouseTotal > 0) { totalWarehouse += warehouseTotal; hasWarehouse = true; }
tr.dataset.est = estimateTotal;
tr.dataset.vendorOrig = '';
const desc = (compMap[item.lot_name] || {}).description || '';
tr.innerHTML = `
<td class="px-3 py-1.5 text-xs">${escapeHtml(item.lot_name)}</td>
<td class="px-3 py-1.5 text-xs text-gray-400"></td>
<td class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs">${escapeHtml(desc)}</td>
<td class="px-3 py-1.5 text-right">${item.quantity}</td>
<td class="px-3 py-1.5 text-right text-xs">${estimateTotal > 0 ? formatCurrency(estimateTotal) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs text-gray-400 pricing-vendor-price"></td>
<td class="px-3 py-1.5 text-right text-xs">${warehouseTotal != null && warehouseTotal > 0 ? formatCurrency(warehouseTotal) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs text-gray-400"></td>
`;
tbody.appendChild(tr);
});
}
// Totals row
document.getElementById('pricing-total-vendor').textContent = hasVendor ? formatCurrency(totalVendor) : '—';
document.getElementById('pricing-total-estimate').textContent = hasEstimate ? formatCurrency(totalEstimate) : '—';
document.getElementById('pricing-total-warehouse').textContent = hasWarehouse ? formatCurrency(totalWarehouse) : '—';
tfoot.classList.remove('hidden');
// Update custom price proportional breakdown
onPricingCustomPriceInput();
}
function setPricingCustomPriceFromVendor() {
// Apply per-row BOM prices directly (not proportional redistribution)
const rows = document.querySelectorAll('#pricing-table-body tr.pricing-row');
const vendorCells = document.querySelectorAll('#pricing-table-body .pricing-vendor-price');
let total = 0;
let hasAny = false;
rows.forEach((tr, i) => {
const cell = vendorCells[i];
if (!cell) return;
const orig = tr.dataset.vendorOrig;
if (orig !== '') {
const v = parseFloat(orig);
cell.textContent = formatCurrency(v);
cell.classList.remove('text-blue-700', 'text-gray-400');
total += v;
hasAny = true;
} else {
cell.textContent = '—';
cell.classList.add('text-gray-400');
cell.classList.remove('text-blue-700');
}
}); });
document.getElementById('pricing-total-vendor').textContent = hasAny ? formatCurrency(total) : '—'; // ─── Build shared row data (unit prices for display, totals for math) ────
document.getElementById('pricing-custom-price').value = hasAny ? total.toFixed(2) : ''; const _buildRows = () => {
syncPricingLinkedInputs('price'); const result = [];
const coveredLots = new Set();
// Update discount info only const _pushCartRow = (item, isEstOnly) => {
const rows2 = document.querySelectorAll('#pricing-table-body tr.pricing-row'); const pl = priceMap[item.lot_name];
let estimateTotal = 0; const u = _getUnitPrices(pl);
rows2.forEach(tr => { estimateTotal += parseFloat(tr.dataset.est) || 0; }); const estUnit = u.estUnit > 0 ? u.estUnit : (item.unit_price || 0);
const discountEl = document.getElementById('pricing-discount-info'); result.push({
const pctEl = document.getElementById('pricing-discount-pct'); lotCell: escapeHtml(item.lot_name), vendorPN: null,
if (hasAny && total > 0 && estimateTotal > 0) { desc: (compMap[item.lot_name] || {}).description || '',
pctEl.textContent = ((estimateTotal - total) / estimateTotal * 100).toFixed(1) + '%'; qty: item.quantity,
discountEl.classList.remove('hidden'); estUnit, warehouseUnit: u.warehouseUnit, competitorUnit: u.competitorUnit,
est: estUnit * item.quantity,
warehouse: u.warehouseUnit != null ? u.warehouseUnit * item.quantity : null,
competitor: u.competitorUnit != null ? u.competitorUnit * item.quantity : null,
vendorOrig: null, vendorOrigUnit: null, isEstOnly,
});
};
if (!bomRows.length) {
cart.forEach(item => { _pushCartRow(item, false); coveredLots.add(item.lot_name); });
return { result, coveredLots };
}
bomRows.forEach(row => {
const baseLot = rowBaseLot(row);
const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1);
if (baseLot) coveredLots.add(baseLot);
allocs.forEach(a => coveredLots.add(a.lot_name));
// Accumulate unit prices per 1 vendor PN (base + allocs)
let rowEstUnit = 0, rowWhUnit = 0, rowCompUnit = 0;
let hasEst = false, hasWh = false, hasComp = false;
if (baseLot) {
const u = _getUnitPrices(priceMap[baseLot]);
const lotQty = _getRowLotQtyPerPN(row);
if (u.estUnit > 0) { rowEstUnit += u.estUnit * lotQty; hasEst = true; }
if (u.warehouseUnit != null) { rowWhUnit += u.warehouseUnit * lotQty; hasWh = true; }
if (u.competitorUnit != null) { rowCompUnit += u.competitorUnit * lotQty; hasComp = true; }
}
allocs.forEach(a => {
const u = _getUnitPrices(priceMap[a.lot_name]);
if (u.estUnit > 0) { rowEstUnit += u.estUnit * a.quantity; hasEst = true; }
if (u.warehouseUnit != null) { rowWhUnit += u.warehouseUnit * a.quantity; hasWh = true; }
if (u.competitorUnit != null) { rowCompUnit += u.competitorUnit * a.quantity; hasComp = true; }
});
let lotCell = '<span class="text-red-500">н/д</span>';
if (baseLot && allocs.length) lotCell = `${escapeHtml(baseLot)} <span class="text-gray-400">+${allocs.length}</span>`;
else if (baseLot) lotCell = escapeHtml(baseLot);
else if (allocs.length) lotCell = `${escapeHtml(allocs[0].lot_name)}${allocs.length > 1 ? ` <span class="text-gray-400">+${allocs.length - 1}</span>` : ''}`;
const vendorOrigUnit = row.unit_price != null ? row.unit_price
: (row.total_price != null && row.quantity > 0 ? row.total_price / row.quantity : null);
const vendorOrig = row.total_price != null ? row.total_price
: (row.unit_price != null ? row.unit_price * row.quantity : null);
const desc = row.description || (baseLot ? ((compMap[baseLot] || {}).description || '') : '');
result.push({
lotCell, vendorPN: row.vendor_pn, desc, qty: row.quantity,
estUnit: hasEst ? rowEstUnit : 0,
warehouseUnit: hasWh ? rowWhUnit : null,
competitorUnit: hasComp ? rowCompUnit : null,
est: hasEst ? rowEstUnit * row.quantity : 0,
warehouse: hasWh ? rowWhUnit * row.quantity : null,
competitor: hasComp ? rowCompUnit * row.quantity : null,
vendorOrig, vendorOrigUnit, isEstOnly: false,
});
});
// Estimate-only LOTs (cart items not covered by BOM)
cart.forEach(item => {
if (!item?.lot_name || coveredLots.has(item.lot_name)) return;
_pushCartRow(item, true);
coveredLots.add(item.lot_name);
});
return { result, coveredLots };
};
const { result: rowData } = _buildRows();
// ─── Populate Buy table ──────────────────────────────────────────────────
tbodyBuy.innerHTML = '';
if (!rowData.length) {
tbodyBuy.innerHTML = '<tr><td colspan="8" class="px-3 py-8 text-center text-gray-400">Нет данных для отображения</td></tr>';
tfootBuy.classList.add('hidden');
} else { } else {
discountEl.classList.add('hidden'); let totEst = 0, totWh = 0, totComp = 0, totVendor = 0;
let hasEst = false, hasWh = false, hasComp = false, hasVendor = false;
let cntWh = 0, cntComp = 0;
rowData.forEach(r => {
const tr = document.createElement('tr');
tr.classList.add('pricing-row-buy');
if (r.isEstOnly) tr.classList.add('bg-blue-50');
tr.dataset.est = r.est;
tr.dataset.qty = r.qty;
tr.dataset.vendorOrig = r.vendorOrig != null ? r.vendorOrig : '';
tr.dataset.vendorOrigUnit = r.vendorOrigUnit != null ? r.vendorOrigUnit : '';
if (r.est > 0) { totEst += r.est; hasEst = true; }
if (r.warehouse != null) { totWh += r.warehouse; hasWh = true; cntWh++; }
if (r.competitor != null) { totComp += r.competitor; hasComp = true; cntComp++; }
if (r.vendorOrig != null) { totVendor += r.vendorOrig; hasVendor = true; }
tr.innerHTML = `
<td class="px-3 py-1.5 text-xs">${r.lotCell}</td>
<td class="px-3 py-1.5 font-mono text-xs ${r.vendorPN == null ? 'text-gray-400' : ''}">${r.vendorPN != null ? escapeHtml(r.vendorPN) : '—'}</td>
<td class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs">${escapeHtml(r.desc)}</td>
<td class="px-3 py-1.5 text-right text-xs">${r.qty}</td>
<td class="px-3 py-1.5 text-right text-xs">${r.estUnit > 0 ? formatCurrency(r.estUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs">${r.warehouseUnit != null ? formatCurrency(r.warehouseUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs">${r.competitorUnit != null ? formatCurrency(r.competitorUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs pricing-vendor-price-buy ${r.vendorOrigUnit == null ? 'text-gray-400' : ''}">${r.vendorOrigUnit != null ? formatCurrency(r.vendorOrigUnit) : '—'}</td>
`;
tbodyBuy.appendChild(tr);
});
document.getElementById('pricing-total-buy-estimate').textContent = hasEst ? formatCurrency(totEst) : '—';
document.getElementById('pricing-total-buy-vendor').textContent = hasVendor ? formatCurrency(totVendor) : '—';
_setPartialTotal('pricing-total-buy-warehouse', hasWh, totWh, cntWh, rowData.length);
_setPartialTotal('pricing-total-buy-competitor', hasComp, totComp, cntComp, rowData.length);
tfootBuy.classList.remove('hidden');
} }
// ─── Populate Sale table ─────────────────────────────────────────────────
tbodySale.innerHTML = '';
if (!rowData.length) {
tbodySale.innerHTML = '<tr><td colspan="8" class="px-3 py-8 text-center text-gray-400">Нет данных для отображения</td></tr>';
tfootSale.classList.add('hidden');
} else {
let totEst = 0, totWh = 0, totComp = 0;
let hasEst = false, hasWh = false, hasComp = false;
let cntWh = 0, cntComp = 0;
rowData.forEach(r => {
const tr = document.createElement('tr');
tr.classList.add('pricing-row-sale');
if (r.isEstOnly) tr.classList.add('bg-blue-50');
const saleEstUnit = r.estUnit > 0 ? r.estUnit * saleUplift : 0;
const saleWhUnit = r.warehouseUnit != null ? r.warehouseUnit * SALE_FIXED_MULT : null;
const saleCompUnit = r.competitorUnit != null ? r.competitorUnit * SALE_FIXED_MULT : null;
const saleEstTotal = saleEstUnit * r.qty;
const saleWhTotal = saleWhUnit != null ? saleWhUnit * r.qty : null;
const saleCompTotal = saleCompUnit != null ? saleCompUnit * r.qty : null;
tr.dataset.estSale = saleEstTotal;
tr.dataset.qty = r.qty;
if (saleEstTotal > 0) { totEst += saleEstTotal; hasEst = true; }
if (saleWhTotal != null) { totWh += saleWhTotal; hasWh = true; cntWh++; }
if (saleCompTotal != null) { totComp += saleCompTotal; hasComp = true; cntComp++; }
tr.innerHTML = `
<td class="px-3 py-1.5 text-xs">${r.lotCell}</td>
<td class="px-3 py-1.5 font-mono text-xs ${r.vendorPN == null ? 'text-gray-400' : ''}">${r.vendorPN != null ? escapeHtml(r.vendorPN) : '—'}</td>
<td class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs">${escapeHtml(r.desc)}</td>
<td class="px-3 py-1.5 text-right text-xs">${r.qty}</td>
<td class="px-3 py-1.5 text-right text-xs">${saleEstUnit > 0 ? formatCurrency(saleEstUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs">${saleWhUnit != null ? formatCurrency(saleWhUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs">${saleCompUnit != null ? formatCurrency(saleCompUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs pricing-vendor-price-sale text-gray-400"></td>
`;
tbodySale.appendChild(tr);
});
document.getElementById('pricing-total-sale-estimate').textContent = hasEst ? formatCurrency(totEst) : '—';
document.getElementById('pricing-total-sale-vendor').textContent = '—';
_setPartialTotal('pricing-total-sale-warehouse', hasWh, totWh, cntWh, rowData.length);
_setPartialTotal('pricing-total-sale-competitor', hasComp, totComp, cntComp, rowData.length);
tfootSale.classList.remove('hidden');
}
// Restore custom prices after re-render
applyCustomPrice('buy');
applyCustomPrice('sale');
} }
function getPricingEstimateTotal() { // ─── Pricing helpers ─────────────────────────────────────────────────────────
const rows = document.querySelectorAll('#pricing-table-body tr.pricing-row');
let estimateTotal = 0; // Sets a footer total cell. If has prices but coverage < totalRows, marks red with a hover asterisk.
rows.forEach(tr => { estimateTotal += parseFloat(tr.dataset.est) || 0; }); function _setPartialTotal(elId, has, total, count, totalRows) {
return estimateTotal; const el = document.getElementById(elId);
if (!el) return;
el.className = el.className.replace(/\btext-red-\d+\b/g, '').trim();
if (!has) { el.textContent = '—'; return; }
if (count < totalRows) {
el.innerHTML = `<span class="text-red-600">${formatCurrency(total)}</span> <span class="text-red-400 cursor-help" title="Цены указаны не для всех позиций (${count} из ${totalRows})">*</span>`;
} else {
el.textContent = formatCurrency(total);
}
} }
function parseDecimalInput(raw) { function parseDecimalInput(raw) {
@@ -3749,99 +3807,144 @@ function formatUpliftInput(value) {
return value.toFixed(4).replace('.', ','); return value.toFixed(4).replace('.', ',');
} }
function syncPricingLinkedInputs(source) { function _getPricingEstimateTotal(table) {
const customPriceInput = document.getElementById('pricing-custom-price'); const attr = table === 'sale' ? 'estSale' : 'est';
const upliftInput = document.getElementById('pricing-uplift'); const cls = table === 'sale' ? 'pricing-row-sale' : 'pricing-row-buy';
if (!customPriceInput || !upliftInput) return; let total = 0;
const estimateTotal = getPricingEstimateTotal(); document.querySelectorAll(`#pricing-body-${table} tr.${cls}`).forEach(tr => {
if (estimateTotal <= 0) { total += parseFloat(tr.dataset[attr]) || 0;
upliftInput.value = ''; });
return; return total;
}
if (source === 'price') {
const customPrice = parseFloat(customPriceInput.value) || 0;
upliftInput.value = customPrice > 0 ? formatUpliftInput(customPrice / estimateTotal) : '';
return;
}
if (source === 'uplift') {
const uplift = parseDecimalInput(upliftInput.value);
customPriceInput.value = uplift > 0 ? (estimateTotal * uplift).toFixed(2) : '';
}
} }
function onPricingUpliftInput() { // Apply custom (own) price proportionally to Ручная цена column.
syncPricingLinkedInputs('uplift'); // table: 'buy' | 'sale'
const customPrice = parseFloat(document.getElementById('pricing-custom-price').value) || 0; function applyCustomPrice(table) {
applyPricingCustomPrice(customPrice); const inputId = `pricing-custom-price-${table}`;
} const totalElId = `pricing-total-${table}-vendor`;
const rowClass = `pricing-row-${table}`;
const cellClass = `.pricing-vendor-price-${table}`;
const estAttr = table === 'sale' ? 'estSale' : 'est';
const origAttr = table === 'buy' ? 'vendorOrig' : null;
function onPricingCustomPriceInput() { const customPrice = parseFloat(document.getElementById(inputId)?.value) || 0;
syncPricingLinkedInputs('price'); const estimateTotal = _getPricingEstimateTotal(table);
const customPrice = parseFloat(document.getElementById('pricing-custom-price').value) || 0; const rows = document.querySelectorAll(`#pricing-body-${table} tr.${rowClass}`);
applyPricingCustomPrice(customPrice); const vendorCells = document.querySelectorAll(`#pricing-body-${table} ${cellClass}`);
} const totalVendorEl = document.getElementById(totalElId);
function applyPricingCustomPrice(customPrice) { const _pctLabel = (custom, est) => {
const estimateTotal = getPricingEstimateTotal(); if (est <= 0) return '';
const rows = document.querySelectorAll('#pricing-table-body tr.pricing-row'); const pct = ((est - custom) / est * 100);
const sign = pct >= 0 ? '-' : '+';
const vendorCells = document.querySelectorAll('#pricing-table-body .pricing-vendor-price'); return ` (${sign}${Math.abs(pct).toFixed(1)}%)`;
const totalVendorEl = document.getElementById('pricing-total-vendor'); };
const _pctClass = (custom, est) => custom <= est ? 'text-green-600' : 'text-red-600';
if (customPrice > 0 && estimateTotal > 0) { if (customPrice > 0 && estimateTotal > 0) {
// Proportionally redistribute custom price → Цена проектная cells
let assigned = 0; let assigned = 0;
rows.forEach((tr, i) => { rows.forEach((tr, i) => {
const est = parseFloat(tr.dataset.est) || 0; const rowEst = parseFloat(tr.dataset[estAttr]) || 0;
const qty = Math.max(1, parseFloat(tr.dataset.qty) || 1);
const cell = vendorCells[i]; const cell = vendorCells[i];
if (!cell) return; if (!cell) return;
let share; let share;
if (i === rows.length - 1) { if (i === rows.length - 1) {
share = customPrice - assigned; share = customPrice - assigned;
} else { } else {
share = Math.round((est / estimateTotal) * customPrice * 100) / 100; share = Math.round((rowEst / estimateTotal) * customPrice * 100) / 100;
assigned += share; assigned += share;
} }
cell.textContent = formatCurrency(share); cell.textContent = formatCurrency(share / qty);
cell.classList.add('text-blue-700'); cell.className = cell.className.replace(/\btext-(?:gray|green|red|blue)-\d+\b/g, '').trim();
cell.classList.remove('text-gray-400'); cell.classList.add(rowEst > 0 ? _pctClass(share, rowEst) : 'text-blue-700');
}); });
totalVendorEl.textContent = formatCurrency(customPrice); const pctStr = _pctLabel(customPrice, estimateTotal);
totalVendorEl.textContent = formatCurrency(customPrice) + pctStr;
totalVendorEl.className = totalVendorEl.className.replace(/\btext-(?:green|red)-\d+\b/g, '').trim();
totalVendorEl.classList.add(_pctClass(customPrice, estimateTotal));
} else { } else {
// Restore original vendor prices from BOM // Restore originals
rows.forEach((tr, i) => { rows.forEach((tr, i) => {
const cell = vendorCells[i]; const cell = vendorCells[i];
if (!cell) return; if (!cell) return;
const orig = tr.dataset.vendorOrig; cell.className = cell.className.replace(/\btext-(?:gray|green|red|blue)-\d+\b/g, '').trim();
if (orig !== '') { if (origAttr && tr.dataset.vendorOrigUnit !== '') {
cell.textContent = formatCurrency(parseFloat(orig)); cell.textContent = formatCurrency(parseFloat(tr.dataset.vendorOrigUnit));
cell.classList.remove('text-blue-700', 'text-gray-400');
} else { } else {
cell.textContent = '—'; cell.textContent = '—';
cell.classList.add('text-gray-400'); cell.classList.add('text-gray-400');
cell.classList.remove('text-blue-700');
} }
}); });
// Recompute vendor total from originals // Recompute total from originals (buy) or clear (sale)
let origTotal = 0; let hasOrig = false; if (origAttr) {
rows.forEach(tr => { if (tr.dataset.vendorOrig !== '') { origTotal += parseFloat(tr.dataset.vendorOrig) || 0; hasOrig = true; } }); let origTotal = 0; let hasOrig = false;
totalVendorEl.textContent = hasOrig ? formatCurrency(origTotal) : '—'; rows.forEach(tr => { if (tr.dataset[origAttr] !== '') { origTotal += parseFloat(tr.dataset[origAttr]) || 0; hasOrig = true; } });
} totalVendorEl.textContent = hasOrig ? formatCurrency(origTotal) : '—';
} else {
// Discount info // sale: reset to — already handled above
const discountEl = document.getElementById('pricing-discount-info'); totalVendorEl.textContent = '—';
const pctEl = document.getElementById('pricing-discount-pct'); }
if (customPrice > 0 && estimateTotal > 0) { totalVendorEl.className = totalVendorEl.className.replace(/\btext-(?:green|red)-\d+\b/g, '').trim();
const discount = ((estimateTotal - customPrice) / estimateTotal * 100).toFixed(1);
pctEl.textContent = discount + '%';
discountEl.classList.remove('hidden');
} else {
discountEl.classList.add('hidden');
} }
} }
function exportPricingCSV() { function onBuyCustomPriceInput() {
const rows = document.querySelectorAll('#pricing-table-body tr.pricing-row'); applyCustomPrice('buy');
}
function onSaleCustomPriceInput() {
applyCustomPrice('sale');
}
function onSaleMarkupInput() {
renderPricingTab();
}
function setPricingCustomPriceFromVendor() {
// Fill Ручная цена in Buy table from BOM vendor totals
const rows = document.querySelectorAll('#pricing-body-buy tr.pricing-row-buy');
const vendorCells = document.querySelectorAll('#pricing-body-buy .pricing-vendor-price-buy');
let total = 0;
let hasAny = false;
rows.forEach((tr, i) => {
const cell = vendorCells[i];
if (!cell) return;
const origUnit = tr.dataset.vendorOrigUnit;
const origTotal = tr.dataset.vendorOrig;
if (origUnit !== '') {
cell.textContent = formatCurrency(parseFloat(origUnit));
cell.className = cell.className.replace(/\btext-(?:gray|green|red|blue)-\d+\b/g, '').trim();
total += parseFloat(origTotal) || 0;
hasAny = true;
} else {
cell.textContent = '—';
cell.className = cell.className.replace(/\btext-(?:green|red|blue)-\d+\b/g, '').trim();
cell.classList.add('text-gray-400');
}
});
const estimateTotal = _getPricingEstimateTotal('buy');
const totalEl = document.getElementById('pricing-total-buy-vendor');
if (hasAny) {
document.getElementById('pricing-custom-price-buy').value = total.toFixed(2);
const pct = estimateTotal > 0 ? ` (-${((estimateTotal - total) / estimateTotal * 100).toFixed(1)}%)` : '';
totalEl.textContent = formatCurrency(total) + pct;
totalEl.className = totalEl.className.replace(/\btext-(?:green|red)-\d+\b/g, '').trim();
totalEl.classList.add(total <= estimateTotal ? 'text-green-600' : 'text-red-600');
} else {
document.getElementById('pricing-custom-price-buy').value = '';
totalEl.textContent = '—';
}
}
function exportPricingCSV(table) {
const bodyId = table === 'sale' ? 'pricing-body-sale' : 'pricing-body-buy';
const rowClass = table === 'sale' ? 'pricing-row-sale' : 'pricing-row-buy';
const totalIds = table === 'sale'
? { est: 'pricing-total-sale-estimate', wh: 'pricing-total-sale-warehouse', comp: 'pricing-total-sale-competitor', vendor: 'pricing-total-sale-vendor' }
: { est: 'pricing-total-buy-estimate', wh: 'pricing-total-buy-warehouse', comp: 'pricing-total-buy-competitor', vendor: 'pricing-total-buy-vendor' };
const rows = document.querySelectorAll(`#${bodyId} tr.${rowClass}`);
if (!rows.length) { showToast('Нет данных для экспорта', 'error'); return; } if (!rows.length) { showToast('Нет данных для экспорта', 'error'); return; }
const csvEscape = v => { const csvEscape = v => {
@@ -3850,35 +3953,34 @@ function exportPricingCSV() {
return /[,"\n]/.test(s) ? `"${s}"` : s; return /[,"\n]/.test(s) ? `"${s}"` : s;
}; };
const headers = ['Lot', 'P/N вендора', 'Описание', 'Кол-во', 'Цена проектная']; const headers = ['Lot', 'PN вендора', 'Описание', 'Кол-во', 'Estimate', 'Склад', 'Конкуренты', 'Ручная цена'];
const lines = [headers.map(csvEscape).join(',')]; const lines = [headers.map(csvEscape).join(',')];
rows.forEach(tr => { rows.forEach(tr => {
const cells = tr.querySelectorAll('td'); const cells = tr.querySelectorAll('td');
const lot = cells[0] ? cells[0].textContent.trim() : ''; const cols = [0,1,2,3,4,5,6,7].map(i => cells[i] ? cells[i].textContent.trim() : '');
const vendorPN = cells[1] ? cells[1].textContent.trim() : ''; lines.push(cols.map(csvEscape).join(','));
const description = cells[2] ? cells[2].textContent.trim() : '';
const qty = cells[3] ? cells[3].textContent.trim() : '';
const vendorPrice = cells[5] ? cells[5].textContent.trim() : '';
lines.push([lot, vendorPN, description, qty, vendorPrice].map(csvEscape).join(','));
}); });
// Totals row // Totals row
const vendorTotal = document.getElementById('pricing-total-vendor').textContent.trim(); const tEst = document.getElementById(totalIds.est)?.textContent.trim() || '';
lines.push(['', '', '', 'Итого:', vendorTotal].map(csvEscape).join(',')); const tWh = document.getElementById(totalIds.wh)?.textContent.trim() || '';
const tComp = document.getElementById(totalIds.comp)?.textContent.trim() || '';
const tVendor = document.getElementById(totalIds.vendor)?.textContent.trim() || '';
// Strip % annotation from vendor total for CSV
const tVendorClean = tVendor.replace(/\s*\(.*\)$/, '').trim();
lines.push(['', '', '', 'Итого:', tEst, tWh, tComp, tVendorClean].map(csvEscape).join(','));
const blob = new Blob(['\uFEFF' + lines.join('\r\n')], {type: 'text/csv;charset=utf-8;'}); const blob = new Blob(['\uFEFF' + lines.join('\r\n')], {type: 'text/csv;charset=utf-8;'});
const url = URL.createObjectURL(blob); const url = URL.createObjectURL(blob);
const a = document.createElement('a'); const a = document.createElement('a');
a.href = url; a.href = url;
const today = new Date(); const today = new Date();
const yyyy = today.getFullYear(); const datePart = `${today.getFullYear()}-${String(today.getMonth()+1).padStart(2,'0')}-${String(today.getDate()).padStart(2,'0')}`;
const mm = String(today.getMonth() + 1).padStart(2, '0');
const dd = String(today.getDate()).padStart(2, '0');
const datePart = `${yyyy}-${mm}-${dd}`;
const codePart = (projectCode || 'NO-PROJECT').trim(); const codePart = (projectCode || 'NO-PROJECT').trim();
const namePart = (configName || 'config').trim(); const namePart = (configName || 'config').trim();
a.download = `${datePart} (${codePart}) ${namePart} SPEC.csv`; const suffix = table === 'sale' ? 'SALE' : 'BUY';
a.download = `${datePart} (${codePart}) ${namePart} SPEC-${suffix}.csv`;
a.click(); a.click();
URL.revokeObjectURL(url); URL.revokeObjectURL(url);
} }
-82
View File
@@ -1,82 +0,0 @@
{{define "title"}}Вход - QuoteForge{{end}}
{{define "content"}}
<div class="max-w-sm mx-auto mt-16">
<div class="bg-white rounded-lg shadow p-6">
<h1 class="text-xl font-bold text-center mb-6">Вход в систему</h1>
<form id="login-form">
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Логин</label>
<input type="text" name="username" id="username" required
class="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
value="admin">
</div>
<div class="mb-4">
<label class="block text-sm font-medium text-gray-700 mb-1">Пароль</label>
<input type="password" name="password" id="password" required
class="w-full px-3 py-2 border rounded focus:outline-none focus:ring-2 focus:ring-blue-500"
value="admin123">
</div>
<div id="error" class="text-red-600 text-sm mb-4 hidden"></div>
<button type="submit" id="submit-btn"
class="w-full bg-blue-600 text-white py-2 rounded hover:bg-blue-700">
Войти
</button>
</form>
<p class="text-center text-sm text-gray-500 mt-4">
<a href="/" class="text-blue-600">← На главную</a>
</p>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function() {
const form = document.getElementById('login-form');
if (!form) return;
form.addEventListener('submit', async function(e) {
e.preventDefault();
const username = document.getElementById('username').value;
const password = document.getElementById('password').value;
const errorEl = document.getElementById('error');
const btn = document.getElementById('submit-btn');
errorEl.classList.add('hidden');
btn.disabled = true;
btn.textContent = 'Вход...';
try {
const resp = await fetch('/api/auth/login', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({username, password})
});
const data = await resp.json();
if (resp.ok && data.access_token) {
localStorage.setItem('token', data.access_token);
localStorage.setItem('refresh_token', data.refresh_token);
localStorage.setItem('user', JSON.stringify(data.user));
window.location.href = '/configs';
} else {
errorEl.textContent = data.error || 'Неверный логин или пароль';
errorEl.classList.remove('hidden');
btn.disabled = false;
btn.textContent = 'Войти';
}
} catch(err) {
errorEl.textContent = 'Ошибка соединения с сервером';
errorEl.classList.remove('hidden');
btn.disabled = false;
btn.textContent = 'Войти';
}
});
});
</script>
{{end}}
{{template "base" .}}
+98 -51
View File
@@ -5,7 +5,7 @@
<h1 class="text-2xl font-bold text-gray-900">Партномера</h1> <h1 class="text-2xl font-bold text-gray-900">Партномера</h1>
<!-- Summary cards --> <!-- Summary cards -->
<div id="summary-cards" class="grid grid-cols-2 md:grid-cols-4 gap-4 hidden"> <div id="summary-cards" class="grid grid-cols-2 md:grid-cols-3 gap-4 hidden">
<div class="bg-white rounded-lg shadow p-4"> <div class="bg-white rounded-lg shadow p-4">
<div class="text-xs text-gray-500 mb-1">Активный лист</div> <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-version" class="font-mono font-semibold text-gray-800 text-sm truncate"></div>
@@ -19,40 +19,12 @@
<div class="text-xs text-gray-500 mb-1">Всего PN</div> <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 id="card-pn-total" class="text-2xl font-bold text-gray-800"></div>
</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>
<div id="summary-empty" class="hidden bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-sm text-yellow-800"> <div id="summary-empty" class="hidden bg-yellow-50 border border-yellow-200 rounded-lg p-4 text-sm text-yellow-800">
Нет активного листа сопоставлений. Нажмите «Синхронизировать» для загрузки с сервера. Нет активного листа сопоставлений. Нажмите «Синхронизировать» для загрузки с сервера.
</div> </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) --> <!-- All books list (collapsed by default) -->
<div class="bg-white rounded-lg shadow overflow-hidden"> <div class="bg-white rounded-lg shadow overflow-hidden">
<!-- Header row — always visible --> <!-- Header row — always visible -->
@@ -92,14 +64,50 @@
</div> </div>
</div> </div>
</div> </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="onItemsSearchInput(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-left">Описание</th>
</tr>
</thead>
<tbody id="active-items-body"></tbody>
</table>
</div>
<div id="items-pagination" class="hidden px-4 py-3 border-t flex items-center justify-between text-sm text-gray-600">
<span id="items-page-info"></span>
<div class="flex gap-2">
<button id="items-prev" onclick="changeItemsPage(-1)" class="px-3 py-1 rounded border hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed">← Назад</button>
<button id="items-next" onclick="changeItemsPage(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> <script>
let allItems = [];
let allBooks = []; let allBooks = [];
let booksPage = 1; let booksPage = 1;
const BOOKS_PER_PAGE = 10; const BOOKS_PER_PAGE = 10;
const ITEMS_PER_PAGE = 100;
let activeBookServerID = null;
let activeItems = [];
let itemsPage = 1;
let itemsTotal = 0;
let itemsSearch = '';
let _itemsSearchTimer = null;
function toggleBooksSection() { function toggleBooksSection() {
const body = document.getElementById('books-section-body'); const body = document.getElementById('books-section-body');
@@ -179,29 +187,45 @@ function changeBooksPage(delta) {
} }
async function loadActiveBookItems(book) { async function loadActiveBookItems(book) {
activeBookServerID = book.server_id;
return loadActiveBookItemsPage(1, itemsSearch, book);
}
async function loadActiveBookItemsPage(page = 1, search = '', book = null) {
const targetBook = book || allBooks.find(b => b.server_id === activeBookServerID);
if (!targetBook) return;
let resp, data; let resp, data;
try { try {
resp = await fetch(`/api/partnumber-books/${book.server_id}`); const params = new URLSearchParams({
page: String(page),
per_page: String(ITEMS_PER_PAGE),
});
if (search.trim()) {
params.set('search', search.trim());
}
resp = await fetch(`/api/partnumber-books/${targetBook.server_id}?` + params.toString());
data = await resp.json(); data = await resp.json();
} catch (e) { } catch (e) {
return; return;
} }
if (!resp.ok) return; if (!resp.ok) return;
allItems = data.items || []; activeItems = data.items || [];
itemsPage = data.page || page;
itemsTotal = Number(data.total || 0);
itemsSearch = data.search || search || '';
const lots = new Set(allItems.map(i => i.lot_name)); document.getElementById('card-version').textContent = targetBook.version;
const primaryCount = allItems.filter(i => i.is_primary_pn).length; document.getElementById('card-date').textContent = targetBook.created_at;
document.getElementById('card-lots').textContent = Number(data.lot_count || 0);
document.getElementById('card-version').textContent = book.version; document.getElementById('card-pn-total').textContent = Number(data.book_total || 0);
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('summary-cards').classList.remove('hidden');
document.getElementById('active-book-section').classList.remove('hidden'); document.getElementById('active-book-section').classList.remove('hidden');
document.getElementById('pn-search').value = itemsSearch;
renderItems(allItems); renderItems(activeItems);
renderItemsPagination();
} }
function renderItems(items) { function renderItems(items) {
@@ -210,23 +234,47 @@ function renderItems(items) {
items.forEach(item => { items.forEach(item => {
const tr = document.createElement('tr'); const tr = document.createElement('tr');
tr.className = 'border-b hover:bg-gray-50'; tr.className = 'border-b hover:bg-gray-50';
const lots = Array.isArray(item.lots_json) ? item.lots_json : [];
const lotsText = lots.map(l => `${l.lot_name} x${l.qty}`).join(', ');
tr.innerHTML = ` tr.innerHTML = `
<td class="px-4 py-1.5 font-mono text-xs">${item.partnumber}</td> <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-xs font-medium text-blue-700">${lotsText}</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> <td class="px-4 py-1.5 text-xs text-gray-500">${item.description || ''}</td>
`; `;
tbody.appendChild(tr); tbody.appendChild(tr);
}); });
document.getElementById('pn-count').textContent = `${items.length} из ${allItems.length}`; const totalLabel = itemsSearch ? `${items.length} из ${itemsTotal} по фильтру` : `${items.length} из ${itemsTotal}`;
document.getElementById('pn-count').textContent = totalLabel;
} }
function filterItems(query) { function renderItemsPagination() {
const q = query.trim().toLowerCase(); const totalPages = Math.max(1, Math.ceil(itemsTotal / ITEMS_PER_PAGE));
if (!q) { renderItems(allItems); return; } const start = itemsTotal === 0 ? 0 : ((itemsPage - 1) * ITEMS_PER_PAGE) + 1;
renderItems(allItems.filter(i => const end = Math.min(itemsPage * ITEMS_PER_PAGE, itemsTotal);
i.partnumber.toLowerCase().includes(q) || i.lot_name.toLowerCase().includes(q) const box = document.getElementById('items-pagination');
)); if (itemsTotal > ITEMS_PER_PAGE) {
box.classList.remove('hidden');
document.getElementById('items-page-info').textContent = `Сопоставления ${start}${end} из ${itemsTotal}`;
document.getElementById('items-prev').disabled = itemsPage === 1;
document.getElementById('items-next').disabled = itemsPage >= totalPages;
} else {
box.classList.add('hidden');
}
}
function changeItemsPage(delta) {
const totalPages = Math.max(1, Math.ceil(itemsTotal / ITEMS_PER_PAGE));
const nextPage = Math.max(1, Math.min(totalPages, itemsPage + delta));
if (nextPage === itemsPage) return;
loadActiveBookItemsPage(nextPage, itemsSearch);
}
function onItemsSearchInput(value) {
clearTimeout(_itemsSearchTimer);
_itemsSearchTimer = setTimeout(() => {
itemsSearch = value.trim();
loadActiveBookItemsPage(1, itemsSearch);
}, 250);
} }
async function syncPartnumberBooks() { async function syncPartnumberBooks() {
@@ -253,4 +301,3 @@ document.addEventListener('DOMContentLoaded', loadBooks);
{{end}} {{end}}
{{template "base" .}} {{template "base" .}}
+30 -19
View File
@@ -59,9 +59,8 @@
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Категория</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Категория</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Описание</th> <th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
<th id="th-qty" class="hidden px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Доступно</th> <th id="th-qty" class="hidden px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Доступно</th>
<th id="th-partnumbers" class="hidden px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Partnumbers</th>
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Цена, $</th> <th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Цена, $</th>
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Настройки</th> <th id="th-settings" class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Настройки</th>
</tr> </tr>
</thead> </thead>
<tbody id="items-body" class="bg-white divide-y divide-gray-200"> <tbody id="items-body" class="bg-white divide-y divide-gray-200">
@@ -150,18 +149,23 @@
} }
} }
function isStockSource() {
const src = (currentSource || '').toLowerCase();
return src === 'warehouse' || src === 'competitor';
}
function isWarehouseSource() { function isWarehouseSource() {
return (currentSource || '').toLowerCase() === 'warehouse'; return (currentSource || '').toLowerCase() === 'warehouse';
} }
function itemsColspan() { function itemsColspan() {
return isWarehouseSource() ? 7 : 5; return isStockSource() ? 4 : 5;
} }
function toggleWarehouseColumns() { function toggleWarehouseColumns() {
const visible = isWarehouseSource(); const stock = isStockSource();
document.getElementById('th-qty').classList.toggle('hidden', !visible); document.getElementById('th-qty').classList.toggle('hidden', true);
document.getElementById('th-partnumbers').classList.toggle('hidden', !visible); document.getElementById('th-settings').classList.toggle('hidden', stock);
} }
function formatQty(qty) { function formatQty(qty) {
@@ -234,27 +238,34 @@
return; return;
} }
const showWarehouse = isWarehouseSource(); const stock = isStockSource();
const p = stock ? 'px-3 py-2' : 'px-6 py-3';
const descMax = stock ? 30 : 60;
const html = items.map(item => { const html = items.map(item => {
const price = item.price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); const price = item.price.toLocaleString('en-US', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
const description = item.lot_description || '-'; const description = item.lot_description || '-';
const truncatedDesc = description.length > 60 ? description.substring(0, 60) + '...' : description; const truncatedDesc = description.length > descMax ? description.substring(0, descMax) + '...' : description;
const qty = formatQty(item.available_qty);
const partnumbers = Array.isArray(item.partnumbers) && item.partnumbers.length > 0 ? item.partnumbers.join(', ') : '—';
// Price cell — add spread badge for competitor
let priceHtml = price;
if (!isWarehouseSource() && item.price_spread_pct > 0) {
priceHtml += ` <span class="text-xs text-amber-600 font-medium" title="Разброс цен конкурентов">±${item.price_spread_pct.toFixed(0)}%</span>`;
}
return ` return `
<tr class="hover:bg-gray-50"> <tr class="hover:bg-gray-50">
<td class="px-6 py-3 whitespace-nowrap"> <td class="${p} max-w-[160px]">
<span class="font-mono text-sm">${item.lot_name}</span> <span class="font-mono text-sm break-all">${escapeHtml(item.lot_name)}</span>
</td> </td>
<td class="px-6 py-3 whitespace-nowrap"> <td class="${p} whitespace-nowrap">
<span class="px-2 py-1 text-xs bg-gray-100 rounded">${item.category || '-'}</span> <span class="px-2 py-1 text-xs bg-gray-100 rounded">${escapeHtml(item.category || '-')}</span>
</td> </td>
<td class="px-6 py-3 text-sm text-gray-500" title="${description}">${truncatedDesc}</td> <td class="${p} text-sm text-gray-500" title="${escapeHtml(description)}">${escapeHtml(truncatedDesc)}</td>
${showWarehouse ? `<td class="px-6 py-3 whitespace-nowrap text-right font-mono">${qty}</td>` : ''} <td class="${p} whitespace-nowrap text-right font-mono">${priceHtml}</td>
${showWarehouse ? `<td class="px-6 py-3 text-sm text-gray-600" title="${escapeHtml(partnumbers)}">${escapeHtml(partnumbers)}</td>` : ''} ${!stock ? `<td class="${p} whitespace-nowrap text-sm"><span class="text-xs bg-gray-100 px-2 py-1 rounded">${formatPriceSettings(item)}</span></td>` : ''}
<td class="px-6 py-3 whitespace-nowrap text-right font-mono">${price}</td>
<td class="px-6 py-3 whitespace-nowrap text-sm"><span class="text-xs bg-gray-100 px-2 py-1 rounded">${formatPriceSettings(item)}</span></td>
</tr> </tr>
`; `;
}).join(''); }).join('');
+244 -144
View File
@@ -2,21 +2,21 @@
{{define "content"}} {{define "content"}}
<div class="space-y-4"> <div class="space-y-4">
<div class="flex items-center justify-between gap-3"> <div class="flex items-start justify-between gap-3">
<div class="flex items-center gap-3"> <div class="flex flex-wrap items-center gap-2 sm:gap-3 min-w-0">
<a href="/projects" class="text-gray-500 hover:text-gray-700" title="Все проекты"> <a href="/projects" class="shrink-0 text-gray-500 hover:text-gray-700" title="Все проекты">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <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="M3 9.75L12 3l9 6.75v9A2.25 2.25 0 0118.75 21h-13.5A2.25 2.25 0 013 18.75v-9z"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9.75L12 3l9 6.75v9A2.25 2.25 0 0118.75 21h-13.5A2.25 2.25 0 013 18.75v-9z"></path>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 21v-6h6v6"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 21v-6h6v6"></path>
</svg> </svg>
</a> </a>
<div class="text-2xl font-bold flex items-center gap-2"> <div class="flex flex-wrap items-center gap-2 text-xl sm:text-2xl font-bold min-w-0">
<a id="project-code-link" href="/projects" class="text-blue-700 hover:underline"> <a id="project-code-link" href="/projects" class="min-w-0 truncate text-blue-700 hover:underline">
<span id="project-code"></span> <span id="project-code"></span>
</a> </a>
<span class="text-gray-400">-</span> <span class="text-gray-400 shrink-0">-</span>
<div class="relative"> <div class="relative shrink-0">
<button id="project-variant-button" type="button" class="inline-flex items-center gap-2 text-base font-medium px-3 py-1.5 rounded-lg bg-gray-100 hover:bg-gray-200 border border-gray-200"> <button id="project-variant-button" type="button" class="inline-flex items-center gap-2 text-sm sm:text-base font-medium px-3 py-1.5 rounded-lg bg-gray-100 hover:bg-gray-200 border border-gray-200">
<span id="project-variant-label">main</span> <span id="project-variant-label">main</span>
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24"> <svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path> <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
@@ -26,24 +26,24 @@
<div id="project-variant-list" class="py-1"></div> <div id="project-variant-list" class="py-1"></div>
</div> </div>
</div> </div>
<button onclick="openNewVariantModal()" class="inline-flex w-full sm:w-auto justify-center items-center px-3 py-1.5 text-sm font-medium bg-purple-600 text-white rounded-lg hover:bg-purple-700">
+ Вариант
</button>
</div> </div>
</div> </div>
</div> </div>
<div id="action-buttons" class="mt-4 grid grid-cols-1 sm:grid-cols-6 gap-3"> <div id="action-buttons" class="mt-4 grid grid-cols-1 sm:grid-cols-6 gap-3">
<button onclick="openNewVariantModal()" class="py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-medium">
+ Новый вариант
</button>
<button onclick="openCreateModal()" class="py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium"> <button onclick="openCreateModal()" class="py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
+ Создать новую квоту Новая конфигурация
</button> </button>
<button onclick="openImportModal()" class="py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 font-medium"> <button onclick="openVendorImportModal()" class="py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 font-medium">
Импорт квоты Импорт выгрузки вендора
</button> </button>
<button onclick="openProjectSettingsModal()" class="py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-800 font-medium"> <button onclick="openProjectSettingsModal()" class="py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-800 font-medium">
Параметры Параметры
</button> </button>
<button onclick="exportProject()" class="py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium"> <button onclick="openExportModal()" class="py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium">
Экспорт CSV Экспорт CSV
</button> </button>
<button id="delete-variant-btn" onclick="deleteVariant()" class="py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium hidden"> <button id="delete-variant-btn" onclick="deleteVariant()" class="py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium hidden">
@@ -72,10 +72,10 @@
<div id="create-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50"> <div id="create-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6"> <div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<h2 class="text-xl font-semibold mb-4">Новая квота в проекте</h2> <h2 class="text-xl font-semibold mb-4">Новая конфигурация в проекте</h2>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Название квоты</label> <label class="block text-sm font-medium text-gray-700 mb-1">Название конфигурации</label>
<input type="text" id="create-name" placeholder="Например: OPP-2026-001" <input type="text" id="create-name" placeholder="Например: OPP-2026-001"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500"> class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
</div> </div>
@@ -87,6 +87,65 @@
</div> </div>
</div> </div>
<div id="vendor-import-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4 p-6">
<h2 class="text-xl font-semibold mb-4">Импорт выгрузки вендора</h2>
<div class="space-y-4">
<div class="text-sm text-gray-600">
Загружает `CFXML`-выгрузку в текущий проект и создаёт несколько конфигураций, если они есть в файле.
</div>
<div>
<label for="vendor-import-file" class="block text-sm font-medium text-gray-700 mb-1">Файл выгрузки</label>
<input id="vendor-import-file" type="file" accept=".xml,text/xml,application/xml"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-amber-500 focus:border-amber-500">
</div>
<div id="vendor-import-status" class="hidden text-sm rounded border px-3 py-2"></div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button onclick="closeVendorImportModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
<button id="vendor-import-submit" onclick="importVendorWorkspace()" class="px-4 py-2 bg-amber-600 text-white rounded hover:bg-amber-700">Импортировать</button>
</div>
</div>
</div>
<div id="project-export-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<h2 class="text-xl font-semibold mb-4">Экспорт CSV</h2>
<div class="space-y-4">
<div class="text-sm text-gray-600">
Экспортирует проект в формате вкладки ценообразования. Если включён `BOM`, строки строятся по BOM; иначе по текущему Estimate.
</div>
<div class="space-y-3">
<label class="flex items-center gap-3 text-sm text-gray-700">
<input id="export-col-lot" type="checkbox" class="rounded border-gray-300" checked>
<span>LOT</span>
</label>
<label class="flex items-center gap-3 text-sm text-gray-700">
<input id="export-col-bom" type="checkbox" class="rounded border-gray-300">
<span>BOM</span>
</label>
<label class="flex items-center gap-3 text-sm text-gray-700">
<input id="export-col-estimate" type="checkbox" class="rounded border-gray-300" checked>
<span>Estimate</span>
</label>
<label class="flex items-center gap-3 text-sm text-gray-700">
<input id="export-col-stock" type="checkbox" class="rounded border-gray-300">
<span>Stock</span>
</label>
<label class="flex items-center gap-3 text-sm text-gray-700">
<input id="export-col-competitor" type="checkbox" class="rounded border-gray-300">
<span>Конкуренты</span>
</label>
</div>
<div id="project-export-status" class="hidden text-sm rounded border px-3 py-2"></div>
</div>
<div class="flex justify-end space-x-3 mt-6">
<button onclick="closeExportModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
<button id="project-export-submit" onclick="exportProject()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">Скачать CSV</button>
</div>
</div>
</div>
<div id="new-variant-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50"> <div id="new-variant-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg p-6"> <div class="bg-white rounded-lg shadow-xl w-full max-w-lg p-6">
<h2 class="text-xl font-semibold mb-4">Новый вариант</h2> <h2 class="text-xl font-semibold mb-4">Новый вариант</h2>
@@ -116,7 +175,7 @@
<div id="config-action-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50"> <div id="config-action-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6"> <div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<h2 class="text-xl font-semibold mb-4">Действия с квотой</h2> <h2 class="text-xl font-semibold mb-4">Действия с конфигурацией</h2>
<div class="space-y-4"> <div class="space-y-4">
<div> <div>
<label class="block text-sm font-medium text-gray-700 mb-1">Название</label> <label class="block text-sm font-medium text-gray-700 mb-1">Название</label>
@@ -154,25 +213,6 @@
</div> </div>
</div> </div>
<div id="import-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<h2 class="text-xl font-semibold mb-4">Импорт квоты в проект</h2>
<div class="space-y-3">
<label class="block text-sm font-medium text-gray-700">Квота</label>
<input id="import-config-input"
list="import-config-options"
placeholder="Начните вводить название квоты"
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
<datalist id="import-config-options"></datalist>
<div class="text-xs text-gray-500">Квота будет перемещена в текущий проект.</div>
</div>
<div class="flex justify-end gap-2 mt-6">
<button onclick="closeImportModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
<button onclick="importConfigToProject()" class="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700">Импортировать</button>
</div>
</div>
</div>
<div id="project-settings-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50"> <div id="project-settings-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6"> <div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
<h2 class="text-xl font-semibold mb-4">Параметры проекта</h2> <h2 class="text-xl font-semibold mb-4">Параметры проекта</h2>
@@ -347,7 +387,7 @@ function applyStatusModeUI() {
} }
function renderConfigs(configs) { function renderConfigs(configs) {
const emptyText = configStatusMode === 'archived' ? 'Архив пуст' : 'Нет квот в проекте'; const emptyText = configStatusMode === 'archived' ? 'Архив пуст' : 'Нет конфигураций в проекте';
if (configs.length === 0) { if (configs.length === 0) {
document.getElementById('configs-list').innerHTML = document.getElementById('configs-list').innerHTML =
'<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">' + emptyText + '</div>'; '<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">' + emptyText + '</div>';
@@ -540,6 +580,86 @@ function closeCreateModal() {
document.getElementById('create-modal').classList.remove('flex'); document.getElementById('create-modal').classList.remove('flex');
} }
function setVendorImportStatus(message, type) {
const box = document.getElementById('vendor-import-status');
if (!box) return;
if (!message) {
box.textContent = '';
box.className = 'hidden text-sm rounded border px-3 py-2';
return;
}
let classes = 'text-sm rounded border px-3 py-2 ';
if (type === 'error') {
classes += 'border-red-200 bg-red-50 text-red-700';
} else if (type === 'success') {
classes += 'border-emerald-200 bg-emerald-50 text-emerald-700';
} else {
classes += 'border-amber-200 bg-amber-50 text-amber-700';
}
box.className = classes;
box.textContent = message;
}
function openVendorImportModal() {
document.getElementById('vendor-import-file').value = '';
setVendorImportStatus('', '');
document.getElementById('vendor-import-submit').disabled = false;
document.getElementById('vendor-import-submit').textContent = 'Импортировать';
document.getElementById('vendor-import-modal').classList.remove('hidden');
document.getElementById('vendor-import-modal').classList.add('flex');
}
function closeVendorImportModal() {
document.getElementById('vendor-import-modal').classList.add('hidden');
document.getElementById('vendor-import-modal').classList.remove('flex');
}
async function importVendorWorkspace() {
const input = document.getElementById('vendor-import-file');
const submit = document.getElementById('vendor-import-submit');
if (!input || !input.files || !input.files[0]) {
setVendorImportStatus('Выберите XML-файл выгрузки', 'error');
return;
}
const formData = new FormData();
formData.append('file', input.files[0]);
submit.disabled = true;
submit.textContent = 'Импорт...';
setVendorImportStatus('Импортирую файл в проект...', 'info');
try {
const resp = await fetch('/api/projects/' + projectUUID + '/vendor-import', {
method: 'POST',
body: formData
});
const data = await resp.json().catch(() => ({}));
if (!resp.ok) {
throw new Error(data.error || 'Не удалось импортировать выгрузку');
}
const imported = Array.isArray(data.configs) ? data.configs : [];
const importedNames = imported.map(c => c.name).filter(Boolean);
const message = importedNames.length
? 'Импортировано ' + imported.length + ': ' + importedNames.join(', ')
: 'Импортировано конфигураций: ' + (data.imported || 0);
setVendorImportStatus(message, 'success');
if (typeof showToast === 'function') {
showToast('Импорт завершён', 'success');
}
await loadConfigs();
setTimeout(closeVendorImportModal, 900);
} catch (e) {
setVendorImportStatus(e.message || 'Не удалось импортировать выгрузку', 'error');
if (typeof showToast === 'function') {
showToast(e.message || 'Не удалось импортировать выгрузку', 'error');
}
} finally {
submit.disabled = false;
submit.textContent = 'Импортировать';
}
}
async function createConfig() { async function createConfig() {
const name = document.getElementById('create-name').value.trim(); const name = document.getElementById('create-name').value.trim();
if (!name) { if (!name) {
@@ -552,7 +672,7 @@ async function createConfig() {
body: JSON.stringify({name: name, items: [], notes: '', server_count: 1}) body: JSON.stringify({name: name, items: [], notes: '', server_count: 1})
}); });
if (!resp.ok) { if (!resp.ok) {
alert('Не удалось создать квоту'); alert('Не удалось создать конфигурацию');
return; return;
} }
closeCreateModal(); closeCreateModal();
@@ -560,7 +680,7 @@ async function createConfig() {
} }
async function deleteConfig(uuid) { async function deleteConfig(uuid) {
if (!confirm('Переместить квоту в архив?')) return; if (!confirm('Переместить конфигурацию в архив?')) return;
await fetch('/api/configs/' + uuid, {method: 'DELETE'}); await fetch('/api/configs/' + uuid, {method: 'DELETE'});
loadConfigs(); loadConfigs();
} }
@@ -568,7 +688,7 @@ async function deleteConfig(uuid) {
async function reactivateConfig(uuid) { async function reactivateConfig(uuid) {
const resp = await fetch('/api/configs/' + uuid + '/reactivate', {method: 'POST'}); const resp = await fetch('/api/configs/' + uuid + '/reactivate', {method: 'POST'});
if (!resp.ok) { if (!resp.ok) {
alert('Не удалось восстановить квоту'); alert('Не удалось восстановить конфигурацию');
return; return;
} }
const moved = await fetch('/api/configs/' + uuid + '/project', { const moved = await fetch('/api/configs/' + uuid + '/project', {
@@ -577,7 +697,7 @@ async function reactivateConfig(uuid) {
body: JSON.stringify({project_uuid: projectUUID}) body: JSON.stringify({project_uuid: projectUUID})
}); });
if (!moved.ok) { if (!moved.ok) {
alert('Квота восстановлена, но не удалось вернуть в проект'); alert('Конфигурация восстановлена, но не удалось вернуть в проект');
} }
loadConfigs(); loadConfigs();
} }
@@ -752,7 +872,7 @@ async function saveConfigAction() {
body: JSON.stringify({name: name}) body: JSON.stringify({name: name})
}); });
if (!cloneResp.ok) { if (!cloneResp.ok) {
notify('Не удалось скопировать квоту', 'error'); notify('Не удалось скопировать конфигурацию', 'error');
return; return;
} }
closeConfigActionModal(); closeConfigActionModal();
@@ -773,7 +893,7 @@ async function saveConfigAction() {
body: JSON.stringify({name: name}) body: JSON.stringify({name: name})
}); });
if (!renameResp.ok) { if (!renameResp.ok) {
notify('Не удалось переименовать квоту', 'error'); notify('Не удалось переименовать конфигурацию', 'error');
return; return;
} }
changed = true; changed = true;
@@ -786,7 +906,7 @@ async function saveConfigAction() {
body: JSON.stringify({project_uuid: targetProjectUUID}) body: JSON.stringify({project_uuid: targetProjectUUID})
}); });
if (!moveResp.ok) { if (!moveResp.ok) {
notify('Не удалось перенести квоту', 'error'); notify('Не удалось перенести конфигурацию', 'error');
return; return;
} }
changed = true; changed = true;
@@ -805,21 +925,6 @@ async function saveConfigAction() {
} }
} }
function openImportModal() {
const activeOther = allConfigs.length ? null : null; // no-op placeholder
void activeOther;
document.getElementById('import-config-input').value = '';
document.getElementById('import-config-options').innerHTML = '';
loadImportOptions();
document.getElementById('import-modal').classList.remove('hidden');
document.getElementById('import-modal').classList.add('flex');
}
function closeImportModal() {
document.getElementById('import-modal').classList.add('hidden');
document.getElementById('import-modal').classList.remove('flex');
}
function openProjectSettingsModal() { function openProjectSettingsModal() {
if (!project) return; if (!project) return;
if (project.is_system) { if (project.is_system) {
@@ -879,89 +984,10 @@ async function saveProjectSettings() {
closeProjectSettingsModal(); closeProjectSettingsModal();
} }
async function loadImportOptions() {
const resp = await fetch('/api/configs?page=1&per_page=500&status=active');
if (!resp.ok) return;
const data = await resp.json();
const options = document.getElementById('import-config-options');
options.innerHTML = '';
(data.configurations || [])
.filter(c => c.project_uuid !== projectUUID)
.forEach(c => {
const opt = document.createElement('option');
opt.value = c.name;
options.appendChild(opt);
});
}
async function importConfigToProject() {
const query = document.getElementById('import-config-input').value.trim();
if (!query) {
alert('Выберите квоту');
return;
}
const resp = await fetch('/api/configs?page=1&per_page=500&status=active');
if (!resp.ok) {
alert('Не удалось загрузить список квот');
return;
}
const data = await resp.json();
const sourceConfigs = (data.configurations || []).filter(c => c.project_uuid !== projectUUID);
let targets = [];
if (query.includes('*')) {
targets = sourceConfigs.filter(c => wildcardMatch(c.name || '', query));
} else {
const found = sourceConfigs.find(c => (c.name || '').toLowerCase() === query.toLowerCase());
if (found) {
targets = [found];
}
}
if (!targets.length) {
alert('Подходящие квоты не найдены');
return;
}
let moved = 0;
let failed = 0;
for (const cfg of targets) {
const move = await fetch('/api/configs/' + cfg.uuid + '/project', {
method: 'PATCH',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify({project_uuid: projectUUID})
});
if (move.ok) {
moved++;
} else {
failed++;
}
}
if (!moved) {
alert('Не удалось импортировать квоты');
return;
}
closeImportModal();
await loadConfigs();
if (targets.length > 1 || failed > 0) {
alert('Импорт завершен: перенесено ' + moved + ', ошибок ' + failed);
}
}
function wildcardMatch(value, pattern) {
const escaped = pattern
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
.replace(/\*/g, '.*');
const regex = new RegExp('^' + escaped + '$', 'i');
return regex.test(value);
}
async function deleteVariant() { async function deleteVariant() {
if (!project) return; if (!project) return;
const variantLabel = normalizeVariantLabel(project.variant); const variantLabel = normalizeVariantLabel(project.variant);
if (!confirm('Удалить вариант «' + variantLabel + '»? Все квоты будут архивированы.')) return; if (!confirm('Удалить вариант «' + variantLabel + '»? Все конфигурации будут архивированы.')) return;
const resp = await fetch('/api/projects/' + projectUUID, {method: 'DELETE'}); const resp = await fetch('/api/projects/' + projectUUID, {method: 'DELETE'});
if (!resp.ok) { if (!resp.ok) {
const data = await resp.json().catch(() => ({})); const data = await resp.json().catch(() => ({}));
@@ -988,9 +1014,9 @@ function updateDeleteVariantButton() {
} }
document.getElementById('create-modal').addEventListener('click', function(e) { if (e.target === this) closeCreateModal(); }); document.getElementById('create-modal').addEventListener('click', function(e) { if (e.target === this) closeCreateModal(); });
document.getElementById('vendor-import-modal').addEventListener('click', function(e) { if (e.target === this) closeVendorImportModal(); });
document.getElementById('new-variant-modal').addEventListener('click', function(e) { if (e.target === this) closeNewVariantModal(); }); document.getElementById('new-variant-modal').addEventListener('click', function(e) { if (e.target === this) closeNewVariantModal(); });
document.getElementById('config-action-modal').addEventListener('click', function(e) { if (e.target === this) closeConfigActionModal(); }); document.getElementById('config-action-modal').addEventListener('click', function(e) { if (e.target === this) closeConfigActionModal(); });
document.getElementById('import-modal').addEventListener('click', function(e) { if (e.target === this) closeImportModal(); });
document.getElementById('project-settings-modal').addEventListener('click', function(e) { if (e.target === this) closeProjectSettingsModal(); }); document.getElementById('project-settings-modal').addEventListener('click', function(e) { if (e.target === this) closeProjectSettingsModal(); });
document.getElementById('config-action-project-input').addEventListener('input', function(e) { document.getElementById('config-action-project-input').addEventListener('input', function(e) {
const code = resolveProjectCodeFromInput(e.target.value); const code = resolveProjectCodeFromInput(e.target.value);
@@ -1007,8 +1033,8 @@ document.getElementById('config-action-copy').addEventListener('change', functio
document.addEventListener('keydown', function(e) { document.addEventListener('keydown', function(e) {
if (e.key === 'Escape') { if (e.key === 'Escape') {
closeCreateModal(); closeCreateModal();
closeVendorImportModal();
closeConfigActionModal(); closeConfigActionModal();
closeImportModal();
closeProjectSettingsModal(); closeProjectSettingsModal();
} }
}); });
@@ -1162,8 +1188,82 @@ function updateFooterTotal() {
} }
} }
function exportProject() { function openExportModal() {
window.location.href = '/api/projects/' + projectUUID + '/export'; const modal = document.getElementById('project-export-modal');
if (!modal) return;
setProjectExportStatus('', '');
modal.classList.remove('hidden');
modal.classList.add('flex');
}
function closeExportModal() {
const modal = document.getElementById('project-export-modal');
if (!modal) return;
modal.classList.add('hidden');
modal.classList.remove('flex');
}
function setProjectExportStatus(message, tone) {
const status = document.getElementById('project-export-status');
if (!status) return;
if (!message) {
status.className = 'hidden text-sm rounded border px-3 py-2';
status.textContent = '';
return;
}
const palette = tone === 'error'
? 'text-red-700 bg-red-50 border-red-200'
: 'text-gray-700 bg-gray-50 border-gray-200';
status.className = 'text-sm rounded border px-3 py-2 ' + palette;
status.textContent = message;
}
async function exportProject() {
const submitBtn = document.getElementById('project-export-submit');
const payload = {
include_lot: !!document.getElementById('export-col-lot')?.checked,
include_bom: !!document.getElementById('export-col-bom')?.checked,
include_estimate: !!document.getElementById('export-col-estimate')?.checked,
include_stock: !!document.getElementById('export-col-stock')?.checked,
include_competitor: !!document.getElementById('export-col-competitor')?.checked
};
if (submitBtn) submitBtn.disabled = true;
setProjectExportStatus('', '');
try {
const resp = await fetch('/api/projects/' + projectUUID + '/export', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
body: JSON.stringify(payload)
});
if (!resp.ok) {
let message = 'Не удалось экспортировать CSV';
try {
const data = await resp.json();
if (data && data.error) message = data.error;
} catch (_) {}
setProjectExportStatus(message, 'error');
return;
}
const blob = await resp.blob();
const link = document.createElement('a');
const url = window.URL.createObjectURL(blob);
const disposition = resp.headers.get('Content-Disposition') || '';
const match = disposition.match(/filename="([^"]+)"/);
link.href = url;
link.download = match ? match[1] : 'project-export.csv';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
closeExportModal();
} catch (e) {
setProjectExportStatus('Ошибка сети при экспорте CSV', 'error');
} finally {
if (submitBtn) submitBtn.disabled = false;
}
} }
document.addEventListener('DOMContentLoaded', async function() { document.addEventListener('DOMContentLoaded', async function() {
+4 -4
View File
@@ -315,7 +315,7 @@ async function loadProjects() {
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>'; 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>';
html += '</button>'; html += '</button>';
html += '<button onclick="addConfigToProject(\'' + p.uuid + '\')" class="text-indigo-700 hover:text-indigo-900" title="Добавить квоту">'; html += '<button onclick="addConfigToProject(\'' + p.uuid + '\')" class="text-indigo-700 hover:text-indigo-900" 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="M12 4v16m8-8H4"></path></svg>'; 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="M12 4v16m8-8H4"></path></svg>';
html += '</button>'; html += '</button>';
} else { } else {
@@ -472,7 +472,7 @@ async function reactivateProject(projectUUID) {
} }
async function addConfigToProject(projectUUID) { async function addConfigToProject(projectUUID) {
const name = prompt('Название новой квоты'); const name = prompt('Название новой конфигурации');
if (!name || !name.trim()) return; if (!name || !name.trim()) return;
const resp = await fetch('/api/projects/' + projectUUID + '/configs', { const resp = await fetch('/api/projects/' + projectUUID + '/configs', {
method: 'POST', method: 'POST',
@@ -480,7 +480,7 @@ async function addConfigToProject(projectUUID) {
body: JSON.stringify({name: name.trim(), items: [], notes: '', server_count: 1}) body: JSON.stringify({name: name.trim(), items: [], notes: '', server_count: 1})
}); });
if (!resp.ok) { if (!resp.ok) {
alert('Не удалось создать квоту'); alert('Не удалось создать конфигурацию');
return; return;
} }
loadProjects(); loadProjects();
@@ -510,7 +510,7 @@ async function copyProject(projectUUID, projectName) {
const listResp = await fetch('/api/projects/' + projectUUID + '/configs'); const listResp = await fetch('/api/projects/' + projectUUID + '/configs');
if (!listResp.ok) { if (!listResp.ok) {
alert('Проект скопирован без квот (не удалось загрузить исходные квоты)'); alert('Проект скопирован без конфигураций (не удалось загрузить исходные конфигурации)');
loadProjects(); loadProjects();
return; return;
} }