diff --git a/bible b/bible index 5244435..1977730 160000 --- a/bible +++ b/bible @@ -1 +1 @@ -Subproject commit 52444350c1c5d580b5849ad6d7c449c95a5c2261 +Subproject commit 1977730d93d9094836b05cd43d8a2e8e074361c1 diff --git a/bible-local/10-agent-api-guide.md b/bible-local/10-agent-api-guide.md new file mode 100644 index 0000000..1b8f169 --- /dev/null +++ b/bible-local/10-agent-api-guide.md @@ -0,0 +1,563 @@ +# 10 - Agent API Guide: Pricing Servers from a TZ + +This guide is written for an AI agent that needs to price a server configuration +(техническое задание, ТЗ) using the QuoteForge HTTP API. + +## Runtime assumptions + +- QuoteForge runs locally, binds to `127.0.0.1:8080` by default. +- No authentication is required — the app is single-user, loopback-only. +- All responses are JSON. All request bodies are JSON unless stated otherwise. +- The port can be overridden with the `QF_SERVER_PORT` environment variable. + +Base URL for all examples: `http://127.0.0.1:8080` + +--- + +## Configuration composition rules + +These rules are mandatory and must be respected before saving any configuration. + +### 1. Every configuration must belong to a project + +Configurations cannot be created in isolation. The correct sequence is: + +1. Create a project (`POST /api/projects`) and save the returned `uuid`. +2. Create the configuration inside that project by passing `project_uuid` in the + config body, or by using `POST /api/projects/:uuid/configs`. + +If the project for a given TZ already exists, retrieve its `uuid` first: +``` +GET /api/projects?page=1&per_page=100 +``` +then pass the matching `uuid` in `project_uuid`. + +### 2. Every server configuration must contain all four required component groups + +A configuration is not valid for pricing unless items from all four of the +following category groups are present: + +| Category code | Meaning | Notes | +|---------------|------------------|---------------------------------------------------| +| `MB` | Motherboard | exactly one MB per configuration | +| `CPU` | Processor | one or more CPUs | +| `MEM` | Memory / RAM | one or more memory modules | +| `PS` / `PSU` | Power supply | `PSU` is the current code; `PS` is legacy — both are accepted | + +Before saving, verify the assembled BOM with `POST /api/quote/validate`: +the response `errors` array will contain `"Component not found: …"` entries +for unknown lot names, and `warnings` will list lots without a price. +Reject the configuration and report back to the user if any of the four +required categories is missing. + +### 3. Category codes to use when searching + +Use `category=` in `GET /api/components` to narrow results: + +``` +GET /api/components?category=MB&search=X13&has_price=true +GET /api/components?category=CPU&search=Xeon+Gold&has_price=true +GET /api/components?category=MEM&search=32GB+DDR5&has_price=true +GET /api/components?category=PSU&search=800W&has_price=true +``` + +Retrieve the full list of active categories at any time: +``` +GET /api/categories +``` + +--- + +## Typical workflow for pricing a server + +``` +1. Check the app is up GET /api/ping +2. Find or create a project GET /api/projects → POST /api/projects +3. Find the latest pricelist GET /api/pricelists/latest?source=estimate +4. Look up lot names for MB GET /api/components?category=MB&search=… +5. Look up lot names for CPU GET /api/components?category=CPU&search=… +6. Look up lot names for MEM GET /api/components?category=MEM&search=… +7. Look up lot names for PSU GET /api/components?category=PSU&search=… +8. (Repeat for other components) GET /api/components?category=…&search=… +9. Validate and calculate the quote POST /api/quote/validate +10. (Optional) Compare price tiers POST /api/quote/price-levels +11. Save configuration in the project POST /api/projects/:uuid/configs +``` + +--- + +## Step 1 — Verify the app is running + +``` +GET /api/ping +``` + +Response `200 OK`: +```json +{"status": "ok"} +``` + +--- + +## Step 2 — Find or create a project + +Each TZ maps to one project. Use the TZ identifier as the `code` field. + +### Find an existing project + +``` +GET /api/projects?page=1&per_page=100 +``` + +Response `200 OK`: +```json +{ + "projects": [ + { + "uuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "code": "TZ-123", + "variant": "", + "name": "Проект по ТЗ №123", + "tracker_url": "", + "is_active": true, + "created_at": "2026-06-01T00:00:00Z" + } + ], + "total": 1, + "page": 1, + "per_page": 100 +} +``` + +### Create a new project + +``` +POST /api/projects +Content-Type: application/json +``` + +Request body: +```json +{ + "code": "TZ-123", + "name": "Проект по ТЗ №123", + "tracker_url": "" +} +``` + +Fields: + +| field | type | required | description | +|---------------|--------|----------|--------------------------------------------------------------------| +| `code` | string | yes | short identifier, unique per variant; use the TZ number or ticket | +| `variant` | string | no | variant label within the same `code`; default is empty string | +| `name` | string | no | human-readable title | +| `tracker_url` | string | no | link to a ticket or issue tracker | + +Response `201 Created`: +```json +{ + "uuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "code": "TZ-123", + "variant": "", + "name": "Проект по ТЗ №123", + "is_active": true, + "created_at": "2026-06-11T10:00:00Z" +} +``` + +Save the `uuid` — it is required to create configurations inside this project. + +--- + +## Step 3 — Find the latest pricelist + +QuoteForge maintains three pricing tiers. The `source` values are: + +| source | meaning | +|--------------|-----------------------------| +| `estimate` | list / catalogue price | +| `warehouse` | stock price (purchase cost) | +| `competitor` | competitor reference price | + +``` +GET /api/pricelists/latest?source=estimate +``` + +Response `200 OK`: +```json +{ + "id": 42, + "source": "estimate", + "version": "2026-05-28", + "item_count": 12500, + "is_active": true, + "created_at": "2026-05-28T06:00:00Z" +} +``` + +The `id` field is a numeric pricelist identifier. Pass it as `pricelist_id` +when calculating a quote to pin pricing to a specific pricelist. + +To list all available pricelists: +``` +GET /api/pricelists?source=estimate&active_only=true +``` + +--- + +## Steps 4–8 — Look up component lot names + +Each component is identified by a `lot_name` (internal SKU). The TZ typically +contains model names or descriptions; use the search endpoint to resolve them. + +``` +GET /api/components?search=Xeon+Gold+6342&category=CPU&has_price=true&page=1&per_page=20 +``` + +Query parameters: + +| parameter | default | description | +|------------------|---------|---------------------------------------------------| +| `search` | — | free-text search in lot name and description | +| `category` | — | filter by category code (`MB`, `CPU`, `MEM`, `PSU`, …) | +| `has_price` | false | return only components that have a price | +| `include_hidden` | false | include hidden/retired components | +| `page` | 1 | page number | +| `per_page` | 20 | page size | + +Response `200 OK`: +```json +{ + "components": [ + { + "lot_name": "CPU-XEON-6342", + "description": "Intel Xeon Gold 6342, 24C/48T, 2.8 GHz, LGA4189", + "category": "CPU", + "category_name": "CPU", + "model": "Xeon Gold 6342" + } + ], + "total": 1, + "page": 1, + "per_page": 20 +} +``` + +To look up a single component by exact lot name: +``` +GET /api/components/CPU-XEON-6342 +``` + +To list all known categories: +``` +GET /api/categories +``` + +--- + +## Step 9 — Validate and calculate the quote + +Before saving, validate the assembled BOM. This catches unknown lot names and +missing prices, and also confirms that all required categories are covered. + +``` +POST /api/quote/validate +Content-Type: application/json +``` + +Request body: +```json +{ + "items": [ + {"lot_name": "MB-X13DAI-N", "quantity": 1}, + {"lot_name": "CPU-XEON-6342", "quantity": 2}, + {"lot_name": "RAM-32GB-DDR4-3200", "quantity": 8}, + {"lot_name": "SSD-480GB-SATA", "quantity": 2}, + {"lot_name": "PSU-800W-TITANIUM", "quantity": 2} + ], + "pricelist_id": 42 +} +``` + +Response `200 OK`: +```json +{ + "valid": true, + "items": [ + { + "lot_name": "MB-X13DAI-N", + "quantity": 1, + "unit_price": 95000.00, + "total_price": 95000.00, + "description": "Supermicro X13DAi-N dual-socket server board", + "category": "MB", + "has_price": true + }, + { + "lot_name": "CPU-XEON-6342", + "quantity": 2, + "unit_price": 87500.00, + "total_price": 175000.00, + "description": "Intel Xeon Gold 6342, 24C/48T, 2.8 GHz", + "category": "CPU", + "has_price": true + }, + { + "lot_name": "RAM-32GB-DDR4-3200", + "quantity": 8, + "unit_price": 12000.00, + "total_price": 96000.00, + "description": "32 GB DDR4-3200 ECC RDIMM", + "category": "MEM", + "has_price": true + }, + { + "lot_name": "PSU-800W-TITANIUM", + "quantity": 2, + "unit_price": 18500.00, + "total_price": 37000.00, + "description": "800W 80+ Titanium redundant PSU", + "category": "PSU", + "has_price": true + } + ], + "errors": [], + "warnings": [], + "total": 403000.00 +} +``` + +**Agent check after validation:** + +1. `valid` must be `true` — all lot names resolved. +2. `errors` must be empty — no unknown components. +3. The returned `items` array must contain at least one entry from each required + category: `MB`, `CPU`, `MEM`, and `PS` or `PSU`. +4. Items with `has_price: false` are allowed but should be flagged to the user. + +If any check fails, do not save the configuration. Report the issue and ask the +user to clarify or replace the problematic component. + +For simple price totals without validation metadata use `POST /api/quote/calculate` +— identical request body, response contains only `items` and `total`. + +--- + +## Step 10 (optional) — Compare price tiers + +To see estimate, warehouse, and competitor prices side-by-side for a BOM: + +``` +POST /api/quote/price-levels +Content-Type: application/json +``` + +Request body: +```json +{ + "items": [ + {"lot_name": "CPU-XEON-6342", "quantity": 2}, + {"lot_name": "RAM-32GB-DDR4-3200", "quantity": 8} + ], + "pricelist_ids": { + "estimate": 42, + "warehouse": 31, + "competitor": 15 + }, + "no_cache": false +} +``` + +`pricelist_ids` is optional. When omitted the latest pricelist for each source +is used automatically. + +Response `200 OK`: +```json +{ + "items": [ + { + "lot_name": "CPU-XEON-6342", + "quantity": 2, + "estimate_price": 87500.00, + "warehouse_price": 71000.00, + "competitor_price": 85000.00, + "delta_wh_estimate_abs": -16500.00, + "delta_wh_estimate_pct": -18.86, + "delta_comp_estimate_abs": -2500.00, + "delta_comp_estimate_pct": -2.86, + "delta_comp_wh_abs": 14000.00, + "delta_comp_wh_pct": 19.72, + "price_missing": [] + } + ], + "resolved_pricelist_ids": { + "estimate": 42, + "warehouse": 31, + "competitor": 15 + } +} +``` + +`price_missing` lists the source names for which no price was found for that lot. +Delta fields are `null` when either operand price is missing. + +--- + +## Step 11 — Save a configuration inside the project + +Use the project-scoped endpoint so the configuration is immediately linked to +the correct project without a separate move operation. + +``` +POST /api/projects/:project_uuid/configs +Content-Type: application/json +``` + +The request body is identical to `POST /api/configs` — the `project_uuid` field +in the body is ignored when using the project-scoped route; the URL parameter +takes precedence. + +Request body: +```json +{ + "name": "Сервер по ТЗ №123 — вариант А", + "items": [ + {"lot_name": "MB-X13DAI-N", "quantity": 1, "unit_price": 95000.00}, + {"lot_name": "CPU-XEON-6342", "quantity": 2, "unit_price": 87500.00}, + {"lot_name": "RAM-32GB-DDR4-3200", "quantity": 8, "unit_price": 12000.00}, + {"lot_name": "SSD-480GB-SATA", "quantity": 2, "unit_price": 8500.00}, + {"lot_name": "PSU-800W-TITANIUM", "quantity": 2, "unit_price": 18500.00} + ], + "server_model": "2U", + "support_code": "NBD", + "server_count": 1, + "pricelist_id": 42, + "warehouse_pricelist_id": 31, + "competitor_pricelist_id": 15, + "config_type": "server", + "notes": "Автоматически создано агентом на основании ТЗ №123" +} +``` + +Key fields: + +| field | type | required | description | +|--------------------------|--------|----------|-----------------------------------------------------| +| `name` | string | yes | human-readable name | +| `items` | array | yes | `{lot_name, quantity, unit_price}` from validate | +| `server_model` | string | no | chassis/form-factor code; used for article generation | +| `support_code` | string | no | support tier code; used for article generation | +| `server_count` | int | no | number of identical servers; total is multiplied | +| `pricelist_id` | uint | no | estimate pricelist to attach | +| `warehouse_pricelist_id` | uint | no | warehouse pricelist to attach | +| `competitor_pricelist_id`| uint | no | competitor pricelist to attach | +| `config_type` | string | no | `"server"` (default) or `"storage"` | +| `notes` | string | no | free text | +| `custom_price` | float | no | override total price | +| `disable_price_refresh` | bool | no | prevent automatic price refresh on open | +| `only_in_stock` | bool | no | filter to in-stock components only | + +Response `201 Created`: +```json +{ + "uuid": "550e8400-e29b-41d4-a716-446655440000", + "name": "Сервер по ТЗ №123 — вариант А", + "items": [...], + "total_price": 403000.00, + "server_count": 1, + "config_type": "server", + "article": "2U-6342x2-32GBx8-NBD", + "project_uuid": "aaaaaaaa-bbbb-cccc-dddd-eeeeeeeeeeee", + "created_at": "2026-06-11T10:00:00Z" +} +``` + +The `uuid` can be used for all subsequent operations on this configuration. + +--- + +## Working with saved configurations + +``` +GET /api/configs/:uuid — retrieve a saved configuration +PUT /api/configs/:uuid — full update (same body as create) +POST /api/configs/:uuid/refresh-prices — re-price from latest pricelist +POST /api/configs/:uuid/clone — duplicate: body {"name": "clone name"} +GET /api/configs/:uuid/versions — revision history +GET /api/configs?page=1&per_page=20 — list all configurations +``` + +--- + +## Error responses + +All error responses follow the same shape: + +```json +{"error": "human-readable message"} +``` + +Common status codes: + +| code | meaning | +|------|-------------------------------------------------------| +| 400 | invalid request body or validation failure | +| 404 | entity (component, pricelist, config) not found | +| 423 | sync readiness is blocked; retry after sync completes | +| 500 | internal server error | + +--- + +## Minimal end-to-end example + +```bash +BASE=http://127.0.0.1:8080 + +# 1. Verify the app is up +curl -s $BASE/api/ping + +# 2. Create a project for this TZ +PROJECT_UUID=$(curl -s -X POST $BASE/api/projects \ + -H "Content-Type: application/json" \ + -d '{"code": "TZ-123", "name": "Проект по ТЗ №123"}' | jq -r .uuid) + +# 3. Get latest estimate pricelist +PRICELIST_ID=$(curl -s "$BASE/api/pricelists/latest?source=estimate" | jq .id) + +# 4. Find lot names for required categories +curl -s "$BASE/api/components?category=MB&search=X13&has_price=true" | jq '.components[].lot_name' +curl -s "$BASE/api/components?category=CPU&search=Xeon&has_price=true" | jq '.components[].lot_name' +curl -s "$BASE/api/components?category=MEM&search=32GB&has_price=true" | jq '.components[].lot_name' +curl -s "$BASE/api/components?category=PSU&search=800W&has_price=true" | jq '.components[].lot_name' + +# 5. Validate the BOM (must contain MB, CPU, MEM, PSU/PS) +curl -s -X POST $BASE/api/quote/validate \ + -H "Content-Type: application/json" \ + -d "{ + \"pricelist_id\": $PRICELIST_ID, + \"items\": [ + {\"lot_name\": \"MB-X13DAI-N\", \"quantity\": 1}, + {\"lot_name\": \"CPU-XEON-6342\", \"quantity\": 2}, + {\"lot_name\": \"RAM-32GB-DDR4-3200\", \"quantity\": 8}, + {\"lot_name\": \"PSU-800W-TITANIUM\", \"quantity\": 2} + ] + }" | jq '{valid, errors, warnings, total}' + +# 6. Save the configuration inside the project +curl -s -X POST "$BASE/api/projects/$PROJECT_UUID/configs" \ + -H "Content-Type: application/json" \ + -d "{ + \"name\": \"Сервер по ТЗ №123\", + \"pricelist_id\": $PRICELIST_ID, + \"server_model\": \"2U\", + \"server_count\": 1, + \"config_type\": \"server\", + \"items\": [ + {\"lot_name\": \"MB-X13DAI-N\", \"quantity\": 1, \"unit_price\": 95000}, + {\"lot_name\": \"CPU-XEON-6342\", \"quantity\": 2, \"unit_price\": 87500}, + {\"lot_name\": \"RAM-32GB-DDR4-3200\", \"quantity\": 8, \"unit_price\": 12000}, + {\"lot_name\": \"PSU-800W-TITANIUM\", \"quantity\": 2, \"unit_price\": 18500} + ] + }" | jq '{uuid, total_price, article}' +``` diff --git a/bible-local/README.md b/bible-local/README.md index e832220..052fb04 100644 --- a/bible-local/README.md +++ b/bible-local/README.md @@ -14,6 +14,7 @@ Project-specific architecture and operational contracts. | [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 | +| [10-agent-api-guide.md](10-agent-api-guide.md) | End-to-end API guide for agents pricing servers from a TZ | ## Rules diff --git a/bible-local/decisions/README.md b/bible-local/decisions/README.md new file mode 100644 index 0000000..825c62f --- /dev/null +++ b/bible-local/decisions/README.md @@ -0,0 +1,31 @@ +# Architectural Decision Log + +One file per decision, named `YYYY-MM-DD-short-topic.md`. + +Write a new entry when: +- Choosing between non-obvious implementation approaches. +- Intentionally rejecting a feature or pattern. +- A bug causes a rule change. +- Freezing or deprecating something. + +Format: + +```markdown +# Decision: + +**Date:** YYYY-MM-DD +**Status:** active | superseded by YYYY-MM-DD-topic.md + +## Context +Situation making this decision necessary. + +## Decision +What was decided, stated clearly. + +## Consequences +What this means going forward; what is forbidden or required. +``` + +When a decision is superseded: add "superseded by" to the old file and create the new one. +Do NOT delete old entries. +Record the decision in the SAME COMMIT as the implementation code. diff --git a/bible-local/runtime-flows.md b/bible-local/runtime-flows.md new file mode 100644 index 0000000..65cedc4 --- /dev/null +++ b/bible-local/runtime-flows.md @@ -0,0 +1,86 @@ +# Runtime Flows + +Critical mutation paths, deduplication logic, and cross-entity side effects. +Update this file in the same commit as any change to the flows below. + +--- + +## 1. Configuration save (create/update) + +1. Handler receives JSON body; validates via `ShouldBindJSON`. +2. `LocalConfigurationService.Create` or `Update` is called. +3. Service computes `total_price` from `req.Items.Total()` (sum of `unit_price * quantity` per item). +4. A new revision snapshot is created via `createWithVersion`; revision number increments. +5. `quoteService.RecordUsage` is called best-effort (warn on failure, do not abort save). +6. Configuration row written to SQLite (`local_configurations`); version row appended to `local_configuration_versions`. +7. Pending change queued in `pending_changes` for later sync push. + +**DO NOT** read prices from `local_components` during save - prices must already be on items. +**DO NOT** skip version creation on rename/reorder/project-move - those operations call different paths that must NOT call `createWithVersion`. + +--- + +## 2. Refresh prices (POST /api/configs/:uuid/refresh-prices) + +1. Handler calls `LocalConfigurationService.RefreshPricesNoAuth(uuid, pricelistServerID)`. +2. If online, `SyncPricelistsIfNeeded` runs best-effort (warn on failure, do not block). +3. Resolves target pricelist in order: + a. Explicitly requested pricelist (`pricelistServerID` param). + b. Pricelist stored in configuration row (`localCfg.PricelistID`). + c. Latest local pricelist as fallback. +4. For each item in the config, looks up price from `local_pricelist_items` via `GetLocalPricesForLots` (batch, single query). +5. Items with matching prices are updated; items with no price keep their existing `unit_price`. +6. Updated configuration saved as a new version (same flow as §1 from step 4 onward). + +**DO NOT** read prices from `qt_pricelist_items` (MariaDB) directly - prices come from SQLite cache only. + +--- + +## 3. Pricelist sync (POST /api/sync/pricelists) + +1. Readiness guard checked; returns 423 if guard blocks sync. +2. `SyncService.SyncPricelists` pulls from `qt_pricelists` and `qt_pricelist_items` (MariaDB). +3. For each pricelist: header upserted first, then items replaced atomically via `ReplaceLocalPricelistItems`. +4. After all pricelists: `RecalculateAllLocalPricelistUsage` marks which pricelists are referenced by active configurations. +5. Sync result (status, error, timestamp) written to `app_settings` via `SetPricelistSyncResult`. + +**DO NOT** write pricelist header without items in the same transaction - must be atomic. +**DO NOT** query MariaDB from runtime handlers outside sync/setup flows. + +--- + +## 4. Vendor spec apply (POST /api/configs/:uuid/vendor-spec/apply) + +1. Incoming `items[]` (lot_name, quantity, unit_price) replace the configuration's `items` entirely. +2. New item list saved through `LocalConfigurationService.UpdateItemsNoAuth`. +3. A new revision is created reflecting the BOM-derived item state. + +**DO NOT** apply vendor spec without going through the service layer - handler must not write items directly to DB. + +--- + +## 5. Configuration versioning invariants + +- `local_configuration_versions` is append-only; rows are never updated or deleted. +- Version deduplication: if new snapshot hash equals current head, no new version is created. +- Rollback = create new HEAD revision from old snapshot data (does not restore version pointer to old row). +- UI must always show "main" (implicit head) as the active state; never point to a numbered revision after save. +- Operations that do NOT create a new version: rename, reorder within project, project move, pricelist selector change only. + +--- + +## 6. Pending changes queue + +- Every local write (create/update/delete) appends a row to `pending_changes`. +- `POST /api/sync/push` drains the queue by writing to MariaDB. +- If a push fails, `increment_attempts` and `last_error` are updated; row stays in queue. +- `RepairPendingChanges` reconciles orphaned changes (configuration/project deleted locally). + +--- + +## 7. Error handling boundary rules + +- Handlers: log 500 responses with `slog.Error`; surface error message via `RespondError`. +- Services: wrap errors with `fmt.Errorf("context: %w", err)`; do NOT log inside service. +- Repositories: return raw errors; no logging. +- Best-effort operations (usage stats, background sync): log `slog.Warn` and continue. diff --git a/cmd/migrate/main.go b/cmd/migrate/main.go index ba90c2b..33730fb 100644 --- a/cmd/migrate/main.go +++ b/cmd/migrate/main.go @@ -2,8 +2,8 @@ package main import ( "flag" - "fmt" "log" + "log/slog" "time" "git.mchus.pro/mchus/quoteforge/internal/appstate" @@ -153,7 +153,7 @@ func main() { log.Printf(" Skipped: %d", skipped) log.Printf(" Errors: %d", errors) - fmt.Println("\nDone! You can now run the server with: go run ./cmd/qfs") + slog.Info("Done! You can now run the server with: go run ./cmd/qfs") } func derefUint(v *uint) uint { diff --git a/cmd/migrate_ops_projects/main.go b/cmd/migrate_ops_projects/main.go index 1aa2f79..8bafb39 100644 --- a/cmd/migrate_ops_projects/main.go +++ b/cmd/migrate_ops_projects/main.go @@ -5,6 +5,7 @@ import ( "flag" "fmt" "log" + "log/slog" "os" "regexp" "sort" @@ -79,12 +80,12 @@ func main() { printPlan(actions) if len(actions) == 0 { - fmt.Println("Nothing to migrate.") + slog.Info("Nothing to migrate.") return } if !*apply { - fmt.Println("\nPreview complete. Re-run with -apply to execute.") + slog.Info("Preview complete. Re-run with -apply to execute.") return } @@ -94,7 +95,7 @@ func main() { log.Fatalf("confirmation failed: %v", confirmErr) } if !ok { - fmt.Println("Aborted.") + slog.Info("Aborted.") return } } @@ -103,7 +104,7 @@ func main() { log.Fatalf("migration failed: %v", err) } - fmt.Println("Migration completed successfully.") + slog.Info("Migration completed successfully.") } func ensureProjectsTable(db *gorm.DB) error { @@ -212,10 +213,8 @@ func printPlan(actions []migrationAction) { } } - fmt.Printf("Planned actions: %d\n", len(actions)) - fmt.Printf("Projects to create: %d\n", createCount) - fmt.Printf("Projects to reactivate: %d\n", reactivateCount) - fmt.Println("\nDetails:") + slog.Info("Plan summary", "actions", len(actions), "create", createCount, "reactivate", reactivateCount) + slog.Info("Details:") for _, a := range actions { extra := "" diff --git a/cmd/migrate_project_updated_at/main.go b/cmd/migrate_project_updated_at/main.go index 7b311c2..e94776c 100644 --- a/cmd/migrate_project_updated_at/main.go +++ b/cmd/migrate_project_updated_at/main.go @@ -2,8 +2,8 @@ package main import ( "flag" - "fmt" "log" + "log/slog" "sort" "time" @@ -161,7 +161,7 @@ func printPlan(plan []updatePlanRow, apply bool) { log.Printf("%s [%s] local=%s server=%s", row.Code, variant, formatStamp(row.LocalUpdatedAt), formatStamp(row.ServerUpdatedAt)) } if !apply { - fmt.Println("Re-run with -apply to write server updated_at into local SQLite.") + slog.Info("Re-run with -apply to write server updated_at into local SQLite.") } } diff --git a/cmd/qfs/main.go b/cmd/qfs/main.go index acce2ee..ad06fd2 100644 --- a/cmd/qfs/main.go +++ b/cmd/qfs/main.go @@ -289,8 +289,7 @@ func main() { } func showStartupConsoleWarning() { - // Visible in console output. - fmt.Println(startupConsoleWarning) + slog.Warn(startupConsoleWarning) // Keep the warning always visible in the console window title when supported. fmt.Printf("\033]0;%s\007", startupConsoleWarning) } diff --git a/internal/db/validate.go b/internal/db/validate.go new file mode 100644 index 0000000..0c76474 --- /dev/null +++ b/internal/db/validate.go @@ -0,0 +1,60 @@ +package db + +import ( + "errors" + "fmt" + "time" + + gormmysql "gorm.io/driver/mysql" + "gorm.io/gorm" + "gorm.io/gorm/logger" +) + +var errPermissionProbeRollback = errors.New("permission probe rollback") + +// ValidateMariaDBConnection opens a one-off connection using dsn, pings, checks +// the required lot table exists, and probes write access to qt_client_schema_state. +// Returns (lot row count, canWrite, error). +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) +} diff --git a/internal/handlers/component.go b/internal/handlers/component.go index 0af5b14..10af18e 100644 --- a/internal/handlers/component.go +++ b/internal/handlers/component.go @@ -64,11 +64,16 @@ func (h *ComponentHandler) List(c *gin.Context) { } } + totalPages := int((total + int64(perPage) - 1) / int64(perPage)) + if totalPages < 1 { + totalPages = 1 + } c.JSON(http.StatusOK, &services.ComponentListResult{ - Components: components, - Total: total, + Items: components, + TotalCount: total, Page: page, PerPage: perPage, + TotalPages: totalPages, }) } diff --git a/internal/handlers/export.go b/internal/handlers/export.go index c943d94..8c056f5 100644 --- a/internal/handlers/export.go +++ b/internal/handlers/export.go @@ -60,7 +60,7 @@ type ProjectExportOptionsRequest struct { func (h *ExportHandler) ExportCSV(c *gin.Context) { var req ExportRequest if err := c.ShouldBindJSON(&req); err != nil { - RespondError(c, http.StatusBadRequest, "invalid request", err) + RespondError(c, http.StatusUnprocessableEntity, "invalid request", err) return } @@ -68,7 +68,7 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) { // Validate before streaming (can return JSON error) if len(data.Configs) == 0 || len(data.Configs[0].Items) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"}) + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "no items to export"}) return } @@ -160,7 +160,7 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) { // Validate before streaming (can return JSON error) if len(data.Configs) == 0 || len(data.Configs[0].Items) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "no items to export"}) + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "no items to export"}) return } @@ -206,7 +206,7 @@ func (h *ExportHandler) ExportProjectCSV(c *gin.Context) { } if len(result.Configs) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "no configurations to export"}) + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "no configurations to export"}) return } @@ -228,7 +228,7 @@ func (h *ExportHandler) ExportConfigPricingCSV(c *gin.Context) { var req ProjectExportOptionsRequest if err := c.ShouldBindJSON(&req); err != nil { - RespondError(c, http.StatusBadRequest, "invalid request", err) + RespondError(c, http.StatusUnprocessableEntity, "invalid request", err) return } @@ -285,7 +285,7 @@ func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) { var req ProjectExportOptionsRequest if err := c.ShouldBindJSON(&req); err != nil { - RespondError(c, http.StatusBadRequest, "invalid request", err) + RespondError(c, http.StatusUnprocessableEntity, "invalid request", err) return } @@ -301,7 +301,7 @@ func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) { return } if len(result.Configs) == 0 { - c.JSON(http.StatusBadRequest, gin.H{"error": "no configurations to export"}) + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "no configurations to export"}) return } diff --git a/internal/handlers/export_test.go b/internal/handlers/export_test.go index e12f3c0..5ed8254 100644 --- a/internal/handlers/export_test.go +++ b/internal/handlers/export_test.go @@ -128,8 +128,8 @@ func TestExportCSV_InvalidRequest(t *testing.T) { handler.ExportCSV(c) // Should return 400 Bad Request - if w.Code != http.StatusBadRequest { - t.Errorf("Expected status 400, got %d", w.Code) + if w.Code != http.StatusUnprocessableEntity { + t.Errorf("Expected status 422, got %d", w.Code) } // Should return JSON error @@ -162,8 +162,8 @@ func TestExportCSV_EmptyItems(t *testing.T) { handler.ExportCSV(c) // Should return 400 Bad Request (validation error from gin binding) - if w.Code != http.StatusBadRequest { - t.Logf("Status code: %d (expected 400 for empty items)", w.Code) + if w.Code != http.StatusUnprocessableEntity { + t.Logf("Status code: %d (expected 422 for empty items)", w.Code) } } @@ -294,8 +294,8 @@ func TestExportConfigCSV_EmptyItems(t *testing.T) { handler.ExportConfigCSV(c) // Should return 400 Bad Request - if w.Code != http.StatusBadRequest { - t.Errorf("Expected status 400, got %d", w.Code) + if w.Code != http.StatusUnprocessableEntity { + t.Errorf("Expected status 422, got %d", w.Code) } // Should return JSON error diff --git a/internal/handlers/partnumber_books.go b/internal/handlers/partnumber_books.go index a9dcc1c..9f8e464 100644 --- a/internal/handlers/partnumber_books.go +++ b/internal/handlers/partnumber_books.go @@ -51,8 +51,11 @@ func (h *PartnumberBooksHandler) List(c *gin.Context) { } c.JSON(http.StatusOK, gin.H{ - "books": summaries, - "total": len(summaries), + "items": summaries, + "total_count": len(summaries), + "page": 1, + "per_page": len(summaries), + "total_pages": 1, }) } @@ -62,7 +65,7 @@ func (h *PartnumberBooksHandler) GetItems(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 64) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid book ID"}) + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid book ID"}) return } @@ -77,9 +80,8 @@ func (h *PartnumberBooksHandler) GetItems(c *gin.Context) { perPage = 100 } - // Find local book by server_id - var book localdb.LocalPartnumberBook - if err := h.localDB.DB().Where("server_id = ?", id).First(&book).Error; err != nil { + book, err := h.localDB.GetLocalPartnumberBookByServerID(uint(id)) + if err != nil { c.JSON(http.StatusNotFound, gin.H{"error": "partnumber book not found"}) return } @@ -90,15 +92,20 @@ func (h *PartnumberBooksHandler) GetItems(c *gin.Context) { return } + totalPages := int((total + int64(perPage) - 1) / int64(perPage)) + if totalPages < 1 { + totalPages = 1 + } c.JSON(http.StatusOK, gin.H{ "book_id": book.ServerID, "version": book.Version, "is_active": book.IsActive, "partnumbers": book.PartnumbersJSON, "items": items, - "total": total, + "total_count": total, "page": page, "per_page": perPage, + "total_pages": totalPages, "search": search, "book_total": bookRepo.CountBookItems(book.ID), "lot_count": bookRepo.CountDistinctLots(book.ID), diff --git a/internal/handlers/pricelist.go b/internal/handlers/pricelist.go index e613dbc..565947a 100644 --- a/internal/handlers/pricelist.go +++ b/internal/handlers/pricelist.go @@ -106,11 +106,16 @@ func (h *PricelistHandler) List(c *gin.Context) { }) } + totalPages := (total + perPage - 1) / perPage + if totalPages < 1 { + totalPages = 1 + } c.JSON(http.StatusOK, gin.H{ - "pricelists": summaries, - "total": total, - "page": page, - "per_page": perPage, + "items": summaries, + "total_count": total, + "page": page, + "per_page": perPage, + "total_pages": totalPages, }) } @@ -119,7 +124,7 @@ func (h *PricelistHandler) Get(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"}) + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid pricelist ID"}) return } @@ -146,7 +151,7 @@ func (h *PricelistHandler) GetItems(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"}) + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid pricelist ID"}) return } @@ -165,40 +170,21 @@ func (h *PricelistHandler) GetItems(c *gin.Context) { if perPage < 1 { perPage = 50 } - var items []localdb.LocalPricelistItem - dbq := h.localDB.DB().Model(&localdb.LocalPricelistItem{}).Where("pricelist_id = ?", localPL.ID) - if strings.TrimSpace(search) != "" { - dbq = dbq.Where("lot_name LIKE ?", "%"+strings.TrimSpace(search)+"%") - } - var total int64 - if err := dbq.Count(&total).Error; err != nil { - RespondError(c, http.StatusInternalServerError, "internal server error", err) - return - } - offset := (page - 1) * perPage - if err := dbq.Order("lot_name").Offset(offset).Limit(perPage).Find(&items).Error; err != nil { + items, total, err := h.localDB.GetLocalPricelistItemsPage(localPL.ID, strings.TrimSpace(search), page, perPage) + if err != nil { RespondError(c, http.StatusInternalServerError, "internal server error", err) 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 + descMap, err := h.localDB.GetLocalComponentDescriptionsByLotNames(lotNames) + if err != nil { + RespondError(c, http.StatusInternalServerError, "internal server error", err) + return } resultItems := make([]gin.H, 0, len(items)) @@ -217,12 +203,14 @@ func (h *PricelistHandler) GetItems(c *gin.Context) { }) } + totalPages := int((total + int64(perPage) - 1) / int64(perPage)) c.JSON(http.StatusOK, gin.H{ - "source": localPL.Source, - "items": resultItems, - "total": total, - "page": page, - "per_page": perPage, + "source": localPL.Source, + "items": resultItems, + "total_count": total, + "page": page, + "per_page": perPage, + "total_pages": totalPages, }) } @@ -230,7 +218,7 @@ func (h *PricelistHandler) GetLotNames(c *gin.Context) { idStr := c.Param("id") id, err := strconv.ParseUint(idStr, 10, 32) if err != nil { - c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"}) + c.JSON(http.StatusUnprocessableEntity, gin.H{"error": "invalid pricelist ID"}) return } diff --git a/internal/handlers/pricelist_test.go b/internal/handlers/pricelist_test.go index fa9ec4c..285dbac 100644 --- a/internal/handlers/pricelist_test.go +++ b/internal/handlers/pricelist_test.go @@ -141,21 +141,21 @@ func TestPricelistList_ActiveOnlyExcludesPricelistsWithoutItems(t *testing.T) { } var resp struct { - Pricelists []struct { + Items []struct { ID uint `json:"id"` - } `json:"pricelists"` - Total int `json:"total"` + } `json:"items"` + TotalCount int `json:"total_count"` } if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { t.Fatalf("unmarshal response: %v", err) } - if resp.Total != 1 { - t.Fatalf("expected total=1, got %d", resp.Total) + if resp.TotalCount != 1 { + t.Fatalf("expected total=1, got %d", resp.TotalCount) } - if len(resp.Pricelists) != 1 { - t.Fatalf("expected 1 pricelist, got %d", len(resp.Pricelists)) + if len(resp.Items) != 1 { + t.Fatalf("expected 1 pricelist, got %d", len(resp.Items)) } - if resp.Pricelists[0].ID != 10 { - t.Fatalf("expected pricelist id=10, got %d", resp.Pricelists[0].ID) + if resp.Items[0].ID != 10 { + t.Fatalf("expected pricelist id=10, got %d", resp.Items[0].ID) } } diff --git a/internal/handlers/quote.go b/internal/handlers/quote.go index ae8f084..4487849 100644 --- a/internal/handlers/quote.go +++ b/internal/handlers/quote.go @@ -18,13 +18,13 @@ func NewQuoteHandler(quoteService *services.QuoteService) *QuoteHandler { func (h *QuoteHandler) Validate(c *gin.Context) { var req services.QuoteRequest if err := c.ShouldBindJSON(&req); err != nil { - RespondError(c, http.StatusBadRequest, "invalid request", err) + RespondError(c, http.StatusUnprocessableEntity, "invalid request", err) return } result, err := h.quoteService.ValidateAndCalculate(&req) if err != nil { - RespondError(c, http.StatusBadRequest, "invalid request", err) + RespondError(c, http.StatusUnprocessableEntity, "invalid request", err) return } @@ -34,13 +34,13 @@ func (h *QuoteHandler) Validate(c *gin.Context) { func (h *QuoteHandler) Calculate(c *gin.Context) { var req services.QuoteRequest if err := c.ShouldBindJSON(&req); err != nil { - RespondError(c, http.StatusBadRequest, "invalid request", err) + RespondError(c, http.StatusUnprocessableEntity, "invalid request", err) return } result, err := h.quoteService.ValidateAndCalculate(&req) if err != nil { - RespondError(c, http.StatusBadRequest, "invalid request", err) + RespondError(c, http.StatusUnprocessableEntity, "invalid request", err) return } @@ -53,13 +53,13 @@ func (h *QuoteHandler) Calculate(c *gin.Context) { func (h *QuoteHandler) PriceLevels(c *gin.Context) { var req services.PriceLevelsRequest if err := c.ShouldBindJSON(&req); err != nil { - RespondError(c, http.StatusBadRequest, "invalid request", err) + RespondError(c, http.StatusUnprocessableEntity, "invalid request", err) return } result, err := h.quoteService.CalculatePriceLevels(&req) if err != nil { - RespondError(c, http.StatusBadRequest, "invalid request", err) + RespondError(c, http.StatusUnprocessableEntity, "invalid request", err) return } diff --git a/internal/handlers/setup.go b/internal/handlers/setup.go index 98c48a9..af6afdc 100644 --- a/internal/handlers/setup.go +++ b/internal/handlers/setup.go @@ -1,7 +1,6 @@ package handlers import ( - "errors" "fmt" "html/template" "log/slog" @@ -15,9 +14,6 @@ import ( "git.mchus.pro/mchus/quoteforge/internal/localdb" "github.com/gin-gonic/gin" mysqlDriver "github.com/go-sql-driver/mysql" - gormmysql "gorm.io/driver/mysql" - "gorm.io/gorm" - "gorm.io/gorm/logger" ) type SetupHandler struct { @@ -27,8 +23,6 @@ type SetupHandler 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) { funcMap := template.FuncMap{ "sub": func(a, b int) int { return a - b }, @@ -93,7 +87,7 @@ func (h *SetupHandler) TestConnection(c *gin.Context) { } dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second) - lotCount, canWrite, err := validateMariaDBConnection(dsn) + lotCount, canWrite, err := db.ValidateMariaDBConnection(dsn) if err != nil { _ = c.Error(err) c.JSON(http.StatusOK, gin.H{ @@ -135,7 +129,7 @@ func (h *SetupHandler) SaveConnection(c *gin.Context) { // Test connection first dsn := buildMySQLDSN(host, port, database, user, password, 5*time.Second) - if _, _, err := validateMariaDBConnection(dsn); err != nil { + if _, _, err := db.ValidateMariaDBConnection(dsn); err != nil { _ = c.Error(err) c.JSON(http.StatusBadRequest, gin.H{ "success": false, @@ -214,46 +208,3 @@ func buildMySQLDSN(host string, port int, database, user, password string, timeo 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) -} diff --git a/internal/handlers/support_bundle.go b/internal/handlers/support_bundle.go index 956467d..fc6e44b 100644 --- a/internal/handlers/support_bundle.go +++ b/internal/handlers/support_bundle.go @@ -5,6 +5,7 @@ import ( "encoding/json" "fmt" "io" + "log/slog" "net" "net/http" "os" @@ -39,7 +40,10 @@ func NewSupportBundleHandler(local *localdb.LocalDB, connMgr *db.ConnectionManag // GET /api/support-bundle func (h *SupportBundleHandler) DownloadBundle(c *gin.Context) { now := time.Now().UTC() - hostname, _ := os.Hostname() + hostname, err := os.Hostname() + if err != nil { + slog.Warn("support bundle: could not get hostname", "err", err) + } c.Header("Content-Type", "application/zip") c.Header("Content-Disposition", fmt.Sprintf(`attachment; filename="qfs-bundle-%s.zip"`, now.Format("20060102-150405"))) @@ -153,8 +157,10 @@ func (h *SupportBundleHandler) DownloadBundle(c *gin.Context) { } // schema_migrations.json - var migrations []localdb.LocalSchemaMigration - _ = h.localDB.DB().Order("applied_at ASC").Find(&migrations).Error + migrations, err := h.localDB.GetSchemaMigrations() + if err != nil { + slog.Warn("support bundle: could not load schema migrations", "err", err) + } writeJSON("schema_migrations.json", migrations) // app.log (tail 5 MiB) @@ -169,7 +175,9 @@ func (h *SupportBundleHandler) DownloadBundle(c *gin.Context) { } if _, err := f.Seek(offset, io.SeekStart); err == nil { if w, err := zw.Create("app.log"); err == nil { - _, _ = io.Copy(w, f) + if _, err := io.Copy(w, f); err != nil { + slog.Warn("support bundle: error copying log file", "err", err) + } } } } diff --git a/internal/handlers/sync.go b/internal/handlers/sync.go index 1c4ca99..3c0366f 100644 --- a/internal/handlers/sync.go +++ b/internal/handlers/sync.go @@ -739,7 +739,7 @@ func (h *SyncHandler) ReportPartnumberSeen(c *gin.Context) { } `json:"items"` } if err := c.ShouldBindJSON(&body); err != nil { - RespondError(c, http.StatusBadRequest, "invalid request", err) + RespondError(c, http.StatusUnprocessableEntity, "invalid request", err) return } diff --git a/internal/handlers/vendor_spec.go b/internal/handlers/vendor_spec.go index 1f6dc24..1feb1b9 100644 --- a/internal/handlers/vendor_spec.go +++ b/internal/handlers/vendor_spec.go @@ -2,6 +2,7 @@ package handlers import ( "errors" + "log/slog" "net/http" "strings" @@ -65,7 +66,7 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) { VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"` } if err := c.ShouldBindJSON(&body); err != nil { - RespondError(c, http.StatusBadRequest, "invalid request", err) + RespondError(c, http.StatusUnprocessableEntity, "invalid request", err) return } @@ -136,7 +137,7 @@ func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) { VendorSpec []localdb.VendorSpecItem `json:"vendor_spec"` } if err := c.ShouldBindJSON(&body); err != nil { - RespondError(c, http.StatusBadRequest, "invalid request", err) + RespondError(c, http.StatusUnprocessableEntity, "invalid request", err) return } @@ -149,7 +150,11 @@ func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) { return } - book, _ := bookRepo.GetActiveBook() + book, err := bookRepo.GetActiveBook() + if err != nil { + slog.Warn("vendor spec resolve: no active partnumber book", "err", err) + book = nil + } aggregated, err := services.AggregateLOTs(resolved, book, bookRepo) if err != nil { RespondError(c, http.StatusInternalServerError, "internal server error", err) @@ -179,7 +184,7 @@ func (h *VendorSpecHandler) ApplyVendorSpec(c *gin.Context) { } `json:"items"` } if err := c.ShouldBindJSON(&body); err != nil { - RespondError(c, http.StatusBadRequest, "invalid request", err) + RespondError(c, http.StatusUnprocessableEntity, "invalid request", err) return } diff --git a/internal/localdb/localdb.go b/internal/localdb/localdb.go index 4652a0d..ef9db0a 100644 --- a/internal/localdb/localdb.go +++ b/internal/localdb/localdb.go @@ -497,7 +497,10 @@ func localReadOnlyCacheQuarantineTableName(tableName string, kind string) string // HasSettings returns true if connection settings exist func (l *LocalDB) HasSettings() bool { var count int64 - l.db.Model(&ConnectionSettings{}).Count(&count) + if err := l.db.Model(&ConnectionSettings{}).Count(&count).Error; err != nil { + slog.Error("localdb: HasSettings count failed", "err", err) + return false + } return count > 0 } @@ -1044,14 +1047,18 @@ func (l *LocalDB) DeactivateConfiguration(uuid string) error { // CountConfigurations returns the number of local configurations func (l *LocalDB) CountConfigurations() int64 { var count int64 - l.db.Model(&LocalConfiguration{}).Count(&count) + if err := l.db.Model(&LocalConfiguration{}).Count(&count).Error; err != nil { + slog.Error("localdb: CountConfigurations failed", "err", err) + } return count } // CountProjects returns the number of local projects func (l *LocalDB) CountProjects() int64 { var count int64 - l.db.Model(&LocalProject{}).Count(&count) + if err := l.db.Model(&LocalProject{}).Count(&count).Error; err != nil { + slog.Error("localdb: CountProjects failed", "err", err) + } return count } @@ -1819,3 +1826,62 @@ func (l *LocalDB) SetSyncGuardState(status, reasonCode, reasonText string, requi }), }).Create(state).Error } + +// GetLocalPartnumberBookByServerID returns a local partnumber book by its server-side ID. +func (l *LocalDB) GetLocalPartnumberBookByServerID(serverID uint) (*LocalPartnumberBook, error) { + var book LocalPartnumberBook + if err := l.db.Where("server_id = ?", serverID).First(&book).Error; err != nil { + return nil, err + } + return &book, nil +} + +// GetLocalPricelistItemsPage returns a paginated, searchable list of items for a pricelist. +func (l *LocalDB) GetLocalPricelistItemsPage(pricelistID uint, search string, page, perPage int) ([]LocalPricelistItem, int64, error) { + dbq := l.db.Model(&LocalPricelistItem{}).Where("pricelist_id = ?", pricelistID) + if search != "" { + dbq = dbq.Where("lot_name LIKE ?", "%"+search+"%") + } + var total int64 + if err := dbq.Count(&total).Error; err != nil { + return nil, 0, fmt.Errorf("count pricelist items: %w", err) + } + offset := (page - 1) * perPage + var items []LocalPricelistItem + if err := dbq.Order("lot_name").Offset(offset).Limit(perPage).Find(&items).Error; err != nil { + return nil, 0, fmt.Errorf("fetch pricelist items: %w", err) + } + return items, total, nil +} + +// GetLocalComponentDescriptionsByLotNames returns a map of lot_name → lot_description for the given lots. +func (l *LocalDB) GetLocalComponentDescriptionsByLotNames(lotNames []string) (map[string]string, error) { + if len(lotNames) == 0 { + return map[string]string{}, nil + } + type row struct { + LotName string + LotDescription string + } + var rows []row + if err := l.db.Table("local_components"). + Select("lot_name, lot_description"). + Where("lot_name IN ?", lotNames). + Scan(&rows).Error; err != nil { + return nil, fmt.Errorf("fetch component descriptions: %w", err) + } + m := make(map[string]string, len(rows)) + for _, r := range rows { + m[r.LotName] = r.LotDescription + } + return m, nil +} + +// GetSchemaMigrations returns all applied local schema migrations ordered by applied_at. +func (l *LocalDB) GetSchemaMigrations() ([]LocalSchemaMigration, error) { + var migrations []LocalSchemaMigration + if err := l.db.Order("applied_at ASC").Find(&migrations).Error; err != nil { + return nil, fmt.Errorf("fetch schema migrations: %w", err) + } + return migrations, nil +} diff --git a/internal/services/component.go b/internal/services/component.go index 5494a90..8b9b146 100644 --- a/internal/services/component.go +++ b/internal/services/component.go @@ -2,6 +2,7 @@ package services import ( "fmt" + "log/slog" "strings" "git.mchus.pro/mchus/quoteforge/internal/models" @@ -41,10 +42,11 @@ func ParsePartNumber(lotName string) (category, model string) { } type ComponentListResult struct { - Components []ComponentView `json:"components"` - Total int64 `json:"total"` + Items []ComponentView `json:"items"` + TotalCount int64 `json:"total_count"` Page int `json:"page"` PerPage int `json:"per_page"` + TotalPages int `json:"total_pages"` } type ComponentView struct { @@ -63,10 +65,11 @@ func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage // Components should be loaded via /api/sync/components first if s.componentRepo == nil { return &ComponentListResult{ - Components: []ComponentView{}, - Total: 0, + Items: []ComponentView{}, + TotalCount: 0, Page: page, PerPage: perPage, + TotalPages: 1, }, nil } @@ -107,11 +110,16 @@ func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage views[i] = view } + totalPages := int((total + int64(perPage) - 1) / int64(perPage)) + if totalPages < 1 { + totalPages = 1 + } return &ComponentListResult{ - Components: views, - Total: total, + Items: views, + TotalCount: total, Page: page, PerPage: perPage, + TotalPages: totalPages, }, nil } @@ -126,8 +134,10 @@ func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error) return nil, err } - // Track usage - _ = s.componentRepo.IncrementRequestCount(lotName) + // Track usage (best-effort) + if err := s.componentRepo.IncrementRequestCount(lotName); err != nil { + slog.Warn("component: could not increment request count", "lot", lotName, "err", err) + } view := &ComponentView{ LotName: c.LotName, diff --git a/internal/services/configuration.go b/internal/services/configuration.go index b64e50e..9bcaa3d 100644 --- a/internal/services/configuration.go +++ b/internal/services/configuration.go @@ -2,6 +2,7 @@ package services import ( "errors" + "log/slog" "time" "git.mchus.pro/mchus/quoteforge/internal/models" @@ -117,8 +118,10 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq return nil, err } - // Record usage stats - _ = s.quoteService.RecordUsage(req.Items) + // Record usage stats (best-effort) + if err := s.quoteService.RecordUsage(req.Items); err != nil { + slog.Warn("configuration: could not record usage stats", "err", err) + } return config, nil } diff --git a/internal/services/export.go b/internal/services/export.go index cbed3cf..7bef03f 100644 --- a/internal/services/export.go +++ b/internal/services/export.go @@ -567,45 +567,52 @@ func (s *ExportService) resolvePricingTotals(cfg *models.Configuration, localCfg } } + estimatePrices := s.batchLookupPrices(estimateID, lots) + stockPrices := s.batchLookupPrices(warehouseID, lots) + competitorPrices := s.batchLookupPrices(competitorID, lots) + for _, lot := range lots { level := pricingLevels{} - level.Estimate = s.lookupPricePointer(estimateID, lot) - level.Stock = s.lookupPricePointer(warehouseID, lot) - level.Competitor = s.lookupPricePointer(competitorID, lot) + if p, ok := estimatePrices[lot]; ok { + level.Estimate = floatPtr(p) + } + if p, ok := stockPrices[lot]; ok { + level.Stock = floatPtr(p) + } + if p, ok := competitorPrices[lot]; ok { + level.Competitor = floatPtr(p) + } 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) == "" { +// batchLookupPrices fetches prices for all lots from a pricelist in a single query. +func (s *ExportService) batchLookupPrices(serverPricelistID *uint, lots []string) map[string]float64 { + if s.localDB == nil || serverPricelistID == nil || *serverPricelistID == 0 || len(lots) == 0 { 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 { + prices, err := s.localDB.GetLocalPricesForLots(localPL.ID, lots) + if err != nil { return nil } - return floatPtr(price) + return prices } 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 + if s.localDB == nil || len(lots) == 0 { + return map[string]string{} } - for _, lot := range lots { - component, err := s.localDB.GetLocalComponent(lot) - if err != nil { - continue - } - result[lot] = component.LotDescription + descriptions, err := s.localDB.GetLocalComponentDescriptionsByLotNames(lots) + if err != nil { + return map[string]string{} } - return result + return descriptions } func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, includeBOM bool) []string { diff --git a/internal/services/local_configuration.go b/internal/services/local_configuration.go index 313292a..0d97c62 100644 --- a/internal/services/local_configuration.go +++ b/internal/services/local_configuration.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "log/slog" "strings" "time" @@ -118,8 +119,10 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf } cfg.Line = localCfg.Line - // Record usage stats - _ = s.quoteService.RecordUsage(req.Items) + // Record usage stats (best-effort) + if err := s.quoteService.RecordUsage(req.Items); err != nil { + slog.Warn("local configuration: could not record usage stats", "err", err) + } return cfg, nil } @@ -407,7 +410,9 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str // Refresh local pricelists when online. if s.isOnline() { - _ = s.syncService.SyncPricelistsIfNeeded() + if err := s.syncService.SyncPricelistsIfNeeded(); err != nil { + slog.Warn("local configuration: background pricelist sync failed", "err", err) + } } // Use the pricelist stored in the config; fall back to latest if unavailable. @@ -791,7 +796,9 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string, pricelistSe } if s.isOnline() { - _ = s.syncService.SyncPricelistsIfNeeded() + if err := s.syncService.SyncPricelistsIfNeeded(); err != nil { + slog.Warn("local configuration: background pricelist sync failed", "err", err) + } } // Resolve which pricelist to use: diff --git a/internal/services/vendor_workspace_import.go b/internal/services/vendor_workspace_import.go index 324fc46..e8a307d 100644 --- a/internal/services/vendor_workspace_import.go +++ b/internal/services/vendor_workspace_import.go @@ -269,13 +269,17 @@ func aggregateVendorSpecToItems(spec localdb.VendorSpec, estimatePricelist *loca } sort.Strings(order) + + var priceMap map[string]float64 + if estimatePricelist != nil && local != nil && len(order) > 0 { + priceMap, _ = local.GetLocalPricesForLots(estimatePricelist.ID, 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 - } + if priceMap != nil { + unitPrice = priceMap[lotName] } items = append(items, localdb.LocalConfigItem{ LotName: lotName, diff --git a/migrations/001_add_lot_category.sql b/migrations/001_add_lot_category.sql index 33f212f..33c67cf 100644 --- a/migrations/001_add_lot_category.sql +++ b/migrations/001_add_lot_category.sql @@ -1,3 +1,9 @@ +-- Tables affected: lot +-- recovery.not-started: check first; ADD COLUMN fails if lot_category already exists +-- recovery.partial: DROP INDEX IF EXISTS idx_lot_category ON lot; ALTER TABLE lot DROP COLUMN lot_category; +-- recovery.completed: no action needed +-- verify: lot_category column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='lot' AND column_name='lot_category' HAVING COUNT(*)=0 + -- Migration: Add lot_category column to lot table -- Run this migration manually on the database diff --git a/migrations/002_add_custom_price.sql b/migrations/002_add_custom_price.sql index 896bb33..8782689 100644 --- a/migrations/002_add_custom_price.sql +++ b/migrations/002_add_custom_price.sql @@ -1,2 +1,8 @@ +-- Tables affected: qt_configurations +-- recovery.not-started: check first; ADD COLUMN fails if custom_price already exists +-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN custom_price; +-- recovery.completed: no action needed +-- verify: custom_price column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='custom_price' HAVING COUNT(*)=0 + -- Add custom_price column to qt_configurations table ALTER TABLE qt_configurations ADD COLUMN custom_price DECIMAL(12,2) NULL COMMENT 'User-defined custom total price'; diff --git a/migrations/003_add_is_hidden.sql b/migrations/003_add_is_hidden.sql index fbc8e5d..f85fc34 100644 --- a/migrations/003_add_is_hidden.sql +++ b/migrations/003_add_is_hidden.sql @@ -1,2 +1,8 @@ +-- Tables affected: qt_lot_metadata +-- recovery.not-started: check first; ADD COLUMN fails if is_hidden already exists +-- recovery.partial: ALTER TABLE qt_lot_metadata DROP COLUMN is_hidden; +-- recovery.completed: no action needed +-- verify: is_hidden column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_lot_metadata' AND column_name='is_hidden' HAVING COUNT(*)=0 + -- Add is_hidden column to qt_lot_metadata table ALTER TABLE qt_lot_metadata ADD COLUMN is_hidden BOOLEAN DEFAULT FALSE COMMENT 'Hide component from configurator'; diff --git a/migrations/004_add_price_updated_at.sql b/migrations/004_add_price_updated_at.sql index b6077fb..bfd46c5 100644 --- a/migrations/004_add_price_updated_at.sql +++ b/migrations/004_add_price_updated_at.sql @@ -1,3 +1,9 @@ +-- Tables affected: qt_configurations +-- recovery.not-started: check first; ADD COLUMN fails if price_updated_at already exists +-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN price_updated_at; +-- recovery.completed: no action needed +-- verify: price_updated_at column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='price_updated_at' HAVING COUNT(*)=0 + -- Add price_updated_at column to qt_configurations table ALTER TABLE qt_configurations ADD COLUMN price_updated_at TIMESTAMP NULL DEFAULT NULL diff --git a/migrations/005_add_owner_username.sql b/migrations/005_add_owner_username.sql index 5d93832..ca69c5a 100644 --- a/migrations/005_add_owner_username.sql +++ b/migrations/005_add_owner_username.sql @@ -1,3 +1,9 @@ +-- Tables affected: qt_configurations +-- recovery.not-started: check first; ADD COLUMN fails if owner_username already exists +-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN owner_username; +-- recovery.completed: no action needed +-- verify: owner_username column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='owner_username' HAVING COUNT(*)=0 + -- Store configuration owner as username (instead of relying on numeric user_id) ALTER TABLE qt_configurations ADD COLUMN owner_username VARCHAR(100) NOT NULL DEFAULT '' AFTER user_id, diff --git a/migrations/006_add_local_configuration_versions.sql b/migrations/006_add_local_configuration_versions.sql index f4159a2..7e2367b 100644 --- a/migrations/006_add_local_configuration_versions.sql +++ b/migrations/006_add_local_configuration_versions.sql @@ -1,3 +1,9 @@ +-- Tables affected: local_configuration_versions (SQLite), local_configurations (SQLite) +-- recovery.not-started: safe to re-run only if table does not exist; fails if table or column already present +-- recovery.partial: roll back: DROP TABLE IF EXISTS local_configuration_versions; run SQLite migration recovery +-- recovery.completed: no action needed +-- verify: local_configuration_versions table missing | SELECT 1 FROM sqlite_master WHERE type='table' AND name='local_configuration_versions' HAVING COUNT(*)=0 + -- Add full-snapshot versioning for local configurations (SQLite) -- 1) Create local_configuration_versions -- 2) Add current_version_id to local_configurations diff --git a/migrations/007_detach_configurations_from_qt_users.sql b/migrations/007_detach_configurations_from_qt_users.sql index e8669b7..7d4df68 100644 --- a/migrations/007_detach_configurations_from_qt_users.sql +++ b/migrations/007_detach_configurations_from_qt_users.sql @@ -1,3 +1,9 @@ +-- Tables affected: qt_configurations +-- recovery.not-started: safe to re-run; uses INFORMATION_SCHEMA check before DROP FOREIGN KEY +-- recovery.partial: no rollback needed; FK was dropped intentionally +-- recovery.completed: no action needed +-- verify: user_id column is still NOT NULL | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='user_id' AND is_nullable='NO' HAVING COUNT(*)>0 + -- Detach qt_configurations from qt_users (ownership is owner_username text) -- Safe for MySQL 8+/MariaDB 10.2+ via INFORMATION_SCHEMA checks. diff --git a/migrations/008_add_app_version_to_configurations.sql b/migrations/008_add_app_version_to_configurations.sql index ecfe82d..4a53222 100644 --- a/migrations/008_add_app_version_to_configurations.sql +++ b/migrations/008_add_app_version_to_configurations.sql @@ -1,3 +1,9 @@ +-- Tables affected: qt_configurations +-- recovery.not-started: check first; ADD COLUMN fails if app_version already exists +-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN app_version; +-- recovery.completed: no action needed +-- verify: app_version column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='app_version' HAVING COUNT(*)=0 + -- Track application version used for configuration writes (create/update via sync) ALTER TABLE qt_configurations ADD COLUMN app_version VARCHAR(64) NULL DEFAULT NULL AFTER owner_username; diff --git a/migrations/009_add_projects.sql b/migrations/009_add_projects.sql index 0a8a143..0a3f0c3 100644 --- a/migrations/009_add_projects.sql +++ b/migrations/009_add_projects.sql @@ -1,3 +1,9 @@ +-- Tables affected: qt_projects, qt_configurations +-- recovery.not-started: check first; CREATE TABLE and ADD COLUMN fail if already exist +-- recovery.partial: ALTER TABLE qt_configurations DROP FOREIGN KEY fk_qt_configurations_project_uuid; ALTER TABLE qt_configurations DROP COLUMN project_uuid; DROP TABLE IF EXISTS qt_projects; +-- recovery.completed: no action needed +-- verify: qt_projects table missing | SELECT 1 FROM information_schema.TABLES WHERE table_schema=DATABASE() AND table_name='qt_projects' HAVING COUNT(*)=0 + -- Add projects and attach configurations to projects CREATE TABLE qt_projects ( diff --git a/migrations/010_add_pricelist_sync_status.sql b/migrations/010_add_pricelist_sync_status.sql index 7a6d036..c87a57b 100644 --- a/migrations/010_add_pricelist_sync_status.sql +++ b/migrations/010_add_pricelist_sync_status.sql @@ -1,3 +1,9 @@ +-- Tables affected: qt_pricelist_sync_status +-- recovery.not-started: safe to re-run; CREATE TABLE IF NOT EXISTS +-- recovery.partial: DROP TABLE IF EXISTS qt_pricelist_sync_status; +-- recovery.completed: no action needed +-- verify: qt_pricelist_sync_status table missing | SELECT 1 FROM information_schema.TABLES WHERE table_schema=DATABASE() AND table_name='qt_pricelist_sync_status' HAVING COUNT(*)=0 + CREATE TABLE IF NOT EXISTS qt_pricelist_sync_status ( username VARCHAR(100) NOT NULL, last_sync_at DATETIME NOT NULL, diff --git a/migrations/010_add_pricelist_to_configurations.sql b/migrations/010_add_pricelist_to_configurations.sql index 67a7ac4..f51320d 100644 --- a/migrations/010_add_pricelist_to_configurations.sql +++ b/migrations/010_add_pricelist_to_configurations.sql @@ -1,3 +1,9 @@ +-- Tables affected: qt_configurations +-- recovery.not-started: check first; ADD COLUMN fails if pricelist_id already exists +-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN pricelist_id; +-- recovery.completed: no action needed +-- verify: pricelist_id column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='pricelist_id' HAVING COUNT(*)=0 + -- Add pricelist binding to configurations ALTER TABLE qt_configurations ADD COLUMN pricelist_id BIGINT UNSIGNED NULL AFTER server_count; diff --git a/migrations/011_add_app_version_to_pricelist_sync_status.sql b/migrations/011_add_app_version_to_pricelist_sync_status.sql index 32c0cf6..967b2da 100644 --- a/migrations/011_add_app_version_to_pricelist_sync_status.sql +++ b/migrations/011_add_app_version_to_pricelist_sync_status.sql @@ -1,2 +1,8 @@ +-- Tables affected: qt_pricelist_sync_status +-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS +-- recovery.partial: ALTER TABLE qt_pricelist_sync_status DROP COLUMN app_version; +-- recovery.completed: no action needed +-- verify: app_version column in qt_pricelist_sync_status missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_pricelist_sync_status' AND column_name='app_version' HAVING COUNT(*)=0 + ALTER TABLE qt_pricelist_sync_status ADD COLUMN IF NOT EXISTS app_version VARCHAR(64) NULL; diff --git a/migrations/012_add_project_tracker_url.sql b/migrations/012_add_project_tracker_url.sql index 0e96fb1..7a2658e 100644 --- a/migrations/012_add_project_tracker_url.sql +++ b/migrations/012_add_project_tracker_url.sql @@ -1,3 +1,9 @@ +-- Tables affected: qt_projects +-- recovery.not-started: check first; ADD COLUMN fails if tracker_url already exists +-- recovery.partial: ALTER TABLE qt_projects DROP COLUMN tracker_url; +-- recovery.completed: no action needed +-- verify: tracker_url column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_projects' AND column_name='tracker_url' HAVING COUNT(*)=0 + ALTER TABLE qt_projects ADD COLUMN tracker_url VARCHAR(500) NULL AFTER name; diff --git a/migrations/013_add_pricelist_source.sql b/migrations/013_add_pricelist_source.sql index f07e7de..02b051a 100644 --- a/migrations/013_add_pricelist_source.sql +++ b/migrations/013_add_pricelist_source.sql @@ -1,3 +1,9 @@ +-- Tables affected: qt_pricelists +-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS +-- recovery.partial: ALTER TABLE qt_pricelists DROP COLUMN source; +-- recovery.completed: no action needed +-- verify: source column in qt_pricelists missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_pricelists' AND column_name='source' HAVING COUNT(*)=0 + ALTER TABLE qt_pricelists ADD COLUMN IF NOT EXISTS source ENUM('estimate', 'warehouse', 'competitor') NOT NULL DEFAULT 'estimate' AFTER id; diff --git a/migrations/014_add_stock_log.sql b/migrations/014_add_stock_log.sql index 1e6b872..767d026 100644 --- a/migrations/014_add_stock_log.sql +++ b/migrations/014_add_stock_log.sql @@ -1,3 +1,9 @@ +-- Tables affected: stock_log +-- recovery.not-started: safe to re-run; CREATE TABLE IF NOT EXISTS +-- recovery.partial: DROP TABLE IF EXISTS stock_log; +-- recovery.completed: no action needed +-- verify: stock_log table missing | SELECT 1 FROM information_schema.TABLES WHERE table_schema=DATABASE() AND table_name='stock_log' HAVING COUNT(*)=0 + CREATE TABLE IF NOT EXISTS stock_log ( stock_log_id BIGINT UNSIGNED AUTO_INCREMENT PRIMARY KEY, lot VARCHAR(255) NOT NULL, diff --git a/migrations/016_add_price_level_pricelist_bindings.sql b/migrations/016_add_price_level_pricelist_bindings.sql index 876397b..e71c728 100644 --- a/migrations/016_add_price_level_pricelist_bindings.sql +++ b/migrations/016_add_price_level_pricelist_bindings.sql @@ -1,3 +1,9 @@ +-- Tables affected: qt_configurations +-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS +-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN warehouse_pricelist_id, DROP COLUMN competitor_pricelist_id; +-- recovery.completed: no action needed +-- verify: warehouse_pricelist_id column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='warehouse_pricelist_id' HAVING COUNT(*)=0 + -- Add per-source pricelist bindings for configurations ALTER TABLE qt_configurations ADD COLUMN IF NOT EXISTS warehouse_pricelist_id BIGINT UNSIGNED NULL AFTER pricelist_id, diff --git a/migrations/018_add_stock_ignore_rules.sql b/migrations/018_add_stock_ignore_rules.sql index 6171067..f5ab29c 100644 --- a/migrations/018_add_stock_ignore_rules.sql +++ b/migrations/018_add_stock_ignore_rules.sql @@ -1,3 +1,9 @@ +-- Tables affected: stock_ignore_rules +-- recovery.not-started: safe to re-run; CREATE TABLE IF NOT EXISTS +-- recovery.partial: DROP TABLE IF EXISTS stock_ignore_rules; +-- recovery.completed: no action needed +-- verify: stock_ignore_rules table missing | SELECT 1 FROM information_schema.TABLES WHERE table_schema=DATABASE() AND table_name='stock_ignore_rules' HAVING COUNT(*)=0 + CREATE TABLE IF NOT EXISTS stock_ignore_rules ( id BIGINT UNSIGNED NOT NULL AUTO_INCREMENT, target VARCHAR(20) NOT NULL, diff --git a/migrations/019_rename_stock_log_lot_to_partnumber.sql b/migrations/019_rename_stock_log_lot_to_partnumber.sql index c3110b4..2e5ebf9 100644 --- a/migrations/019_rename_stock_log_lot_to_partnumber.sql +++ b/migrations/019_rename_stock_log_lot_to_partnumber.sql @@ -1,2 +1,8 @@ +-- Tables affected: stock_log +-- recovery.not-started: check first; CHANGE COLUMN fails if partnumber already exists +-- recovery.partial: ALTER TABLE stock_log CHANGE COLUMN partnumber lot VARCHAR(255) NOT NULL; +-- recovery.completed: no action needed +-- verify: partnumber column in stock_log missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='stock_log' AND column_name='partnumber' HAVING COUNT(*)=0 + ALTER TABLE stock_log CHANGE COLUMN lot partnumber VARCHAR(255) NOT NULL; diff --git a/migrations/020_add_only_in_stock_to_configurations.sql b/migrations/020_add_only_in_stock_to_configurations.sql index 0f48a8d..01e4bef 100644 --- a/migrations/020_add_only_in_stock_to_configurations.sql +++ b/migrations/020_add_only_in_stock_to_configurations.sql @@ -1,3 +1,9 @@ +-- Tables affected: qt_configurations +-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS +-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN only_in_stock; +-- recovery.completed: no action needed +-- verify: only_in_stock column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='only_in_stock' HAVING COUNT(*)=0 + -- Add only_in_stock toggle to configuration settings persisted in MariaDB. ALTER TABLE qt_configurations ADD COLUMN IF NOT EXISTS only_in_stock BOOLEAN NOT NULL DEFAULT FALSE AFTER disable_price_refresh; diff --git a/migrations/021_add_pricelist_items_pricelist_lot_index.sql b/migrations/021_add_pricelist_items_pricelist_lot_index.sql index 69c6bf7..03318d2 100644 --- a/migrations/021_add_pricelist_items_pricelist_lot_index.sql +++ b/migrations/021_add_pricelist_items_pricelist_lot_index.sql @@ -1,3 +1,9 @@ +-- Tables affected: qt_pricelist_items +-- recovery.not-started: safe to re-run; uses INFORMATION_SCHEMA check before adding index +-- recovery.partial: DROP INDEX IF EXISTS idx_qt_pricelist_items_pricelist_lot ON qt_pricelist_items; +-- recovery.completed: no action needed +-- verify: composite index on qt_pricelist_items missing | SELECT 1 FROM information_schema.STATISTICS WHERE table_schema=DATABASE() AND table_name='qt_pricelist_items' AND index_name='idx_qt_pricelist_items_pricelist_lot' HAVING COUNT(*)=0 + -- Ensure fast lookup for /api/quote/price-levels batched queries: -- SELECT ... FROM qt_pricelist_items WHERE pricelist_id = ? AND lot_name IN (...) SET @has_idx := ( diff --git a/migrations/022_add_article_to_configurations.sql b/migrations/022_add_article_to_configurations.sql index 5e424ef..de38246 100644 --- a/migrations/022_add_article_to_configurations.sql +++ b/migrations/022_add_article_to_configurations.sql @@ -1,2 +1,8 @@ +-- Tables affected: qt_configurations +-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS +-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN article; +-- recovery.completed: no action needed +-- verify: article column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='article' HAVING COUNT(*)=0 + ALTER TABLE qt_configurations ADD COLUMN IF NOT EXISTS article VARCHAR(80) NULL AFTER server_count; diff --git a/migrations/023_add_server_model_to_configurations.sql b/migrations/023_add_server_model_to_configurations.sql index bb2be6d..bd62581 100644 --- a/migrations/023_add_server_model_to_configurations.sql +++ b/migrations/023_add_server_model_to_configurations.sql @@ -1,2 +1,8 @@ +-- Tables affected: qt_configurations +-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS +-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN server_model; +-- recovery.completed: no action needed +-- verify: server_model column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='server_model' HAVING COUNT(*)=0 + ALTER TABLE qt_configurations ADD COLUMN IF NOT EXISTS server_model VARCHAR(100) NULL AFTER server_count; diff --git a/migrations/024_add_support_code_to_configurations.sql b/migrations/024_add_support_code_to_configurations.sql index 8caaa96..e3ce26f 100644 --- a/migrations/024_add_support_code_to_configurations.sql +++ b/migrations/024_add_support_code_to_configurations.sql @@ -1,2 +1,8 @@ +-- Tables affected: qt_configurations +-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS +-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN support_code; +-- recovery.completed: no action needed +-- verify: support_code column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='support_code' HAVING COUNT(*)=0 + ALTER TABLE qt_configurations ADD COLUMN IF NOT EXISTS support_code VARCHAR(20) NULL AFTER server_model; diff --git a/migrations/025_add_project_code.sql b/migrations/025_add_project_code.sql index 8a2274c..45a0e20 100644 --- a/migrations/025_add_project_code.sql +++ b/migrations/025_add_project_code.sql @@ -1,3 +1,9 @@ +-- Tables affected: qt_projects +-- recovery.not-started: check first; idempotent backfill but ADD COLUMN fails if code already exists +-- recovery.partial: ALTER TABLE qt_projects DROP INDEX idx_qt_projects_code; ALTER TABLE qt_projects DROP COLUMN code; +-- recovery.completed: no action needed +-- verify: code column in qt_projects missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_projects' AND column_name='code' HAVING COUNT(*)=0 + -- Add project code and enforce uniqueness ALTER TABLE qt_projects diff --git a/migrations/026_add_project_variant.sql b/migrations/026_add_project_variant.sql index 89e4808..b4b9943 100644 --- a/migrations/026_add_project_variant.sql +++ b/migrations/026_add_project_variant.sql @@ -1,3 +1,9 @@ +-- Tables affected: qt_projects +-- recovery.not-started: check first; ADD COLUMN fails if variant already exists +-- recovery.partial: DROP INDEX IF EXISTS idx_qt_projects_code_variant ON qt_projects; ALTER TABLE qt_projects DROP COLUMN variant; +-- recovery.completed: no action needed +-- verify: variant column in qt_projects missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_projects' AND column_name='variant' HAVING COUNT(*)=0 + -- Add project variant and reset codes from project names ALTER TABLE qt_projects diff --git a/migrations/027_project_name_nullable.sql b/migrations/027_project_name_nullable.sql index 6454299..66cc754 100644 --- a/migrations/027_project_name_nullable.sql +++ b/migrations/027_project_name_nullable.sql @@ -1,3 +1,9 @@ +-- Tables affected: qt_projects +-- recovery.not-started: safe to re-run; MODIFY COLUMN is idempotent +-- recovery.partial: ALTER TABLE qt_projects MODIFY COLUMN name VARCHAR(200) NOT NULL; +-- recovery.completed: no action needed +-- verify: name column in qt_projects is still NOT NULL | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_projects' AND column_name='name' AND is_nullable='NO' HAVING COUNT(*)>0 + -- Allow NULL project names ALTER TABLE qt_projects diff --git a/migrations/028_add_line_no_to_configurations.sql b/migrations/028_add_line_no_to_configurations.sql index bef8f6e..26ba483 100644 --- a/migrations/028_add_line_no_to_configurations.sql +++ b/migrations/028_add_line_no_to_configurations.sql @@ -1,3 +1,9 @@ +-- Tables affected: qt_configurations +-- recovery.not-started: safe to re-run; ADD COLUMN IF NOT EXISTS +-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN line_no; +-- recovery.completed: no action needed +-- verify: line_no column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='line_no' HAVING COUNT(*)=0 + ALTER TABLE qt_configurations ADD COLUMN IF NOT EXISTS line_no INT NULL AFTER only_in_stock; diff --git a/migrations/029_add_config_type.sql b/migrations/029_add_config_type.sql index d41625d..adc4d44 100644 --- a/migrations/029_add_config_type.sql +++ b/migrations/029_add_config_type.sql @@ -1,2 +1,8 @@ +-- Tables affected: qt_configurations +-- recovery.not-started: check first; ADD COLUMN fails if config_type already exists +-- recovery.partial: ALTER TABLE qt_configurations DROP COLUMN config_type; +-- recovery.completed: no action needed +-- verify: config_type column missing | SELECT 1 FROM information_schema.COLUMNS WHERE table_schema=DATABASE() AND table_name='qt_configurations' AND column_name='config_type' HAVING COUNT(*)=0 + ALTER TABLE qt_configurations ADD COLUMN config_type VARCHAR(20) NOT NULL DEFAULT 'server'; diff --git a/web/templates/index.html b/web/templates/index.html index 9f105d1..baa10a2 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -889,7 +889,7 @@ async function loadAllComponents() { try { const resp = await fetch('/api/components?per_page=5000'); const data = await resp.json(); - allComponents = data.components || []; + allComponents = data.items || []; window._bomAllComponents = allComponents; } catch(e) { console.error('Failed to load components', e); @@ -940,7 +940,7 @@ async function loadActivePricelists(force = false) { try { const resp = await fetch(`/api/pricelists?active_only=true&source=${source}&per_page=200`); const data = await resp.json(); - activePricelistsBySource[source] = data.pricelists || []; + activePricelistsBySource[source] = data.items || []; // Do not reset the stored pricelist — it may be inactive but must be preserved } catch (e) { activePricelistsBySource[source] = []; diff --git a/web/templates/partnumber_books.html b/web/templates/partnumber_books.html index 463884f..82c4fc2 100644 --- a/web/templates/partnumber_books.html +++ b/web/templates/partnumber_books.html @@ -127,7 +127,7 @@ async function loadBooks() { return; } - allBooks = data.books || []; + allBooks = data.items || []; document.getElementById('books-list-loading').classList.add('hidden'); if (!allBooks.length) { @@ -213,7 +213,7 @@ async function loadActiveBookItemsPage(page = 1, search = '', book = null) { activeItems = data.items || []; itemsPage = data.page || page; - itemsTotal = Number(data.total || 0); + itemsTotal = Number(data.total_count || 0); itemsSearch = data.search || search || ''; document.getElementById('card-version').textContent = targetBook.version; diff --git a/web/templates/pricelist_detail.html b/web/templates/pricelist_detail.html index 6757457..7a3441e 100644 --- a/web/templates/pricelist_detail.html +++ b/web/templates/pricelist_detail.html @@ -137,7 +137,7 @@ toggleWarehouseColumns(); renderItems(data.items || []); - renderItemsPagination(data.total, data.page, data.per_page); + renderItemsPagination(data.total_count, data.page, data.per_page); } catch (e) { document.getElementById('items-body').innerHTML = ` diff --git a/web/templates/pricelists.html b/web/templates/pricelists.html index 3b98e09..23658fa 100644 --- a/web/templates/pricelists.html +++ b/web/templates/pricelists.html @@ -83,8 +83,8 @@ const resp = await fetch(`/api/pricelists?page=${page}&per_page=20`); const data = await resp.json(); - renderPricelists(data.pricelists || []); - renderPagination(data.total, data.page, data.per_page); + renderPricelists(data.items || []); + renderPagination(data.total_count, data.page, data.per_page); } catch (e) { document.getElementById('pricelists-body').innerHTML = `