refactor: привести кодовую базу в соответствие с канонами bible

- 400 → 422 для всех ошибок валидации входных данных (handlers: export, quote, sync, vendor_spec, partnumber_books, pricelist)
- SQL-запросы вынесены из handlers в localdb (partnumber_books, pricelist, support_bundle); ValidateMariaDBConnection перенесён в internal/db/validate.go
- List-ответы унифицированы: ключ items, поля total_count/page/per_page/total_pages (component, pricelist, partnumber_books); шаблоны обновлены
- Молчаливые ошибки заменены на slog.Warn/Error (support_bundle, vendor_spec, component, configuration, local_configuration, localdb)
- N+1 запросы устранены: batch-запросы в export.go и vendor_workspace_import.go
- fmt.Println → slog в cmd/ (qfs, migrate, migrate_ops_projects, migrate_project_updated_at)
- Заголовки recovery/verify добавлены во все 28 SQL-миграций
- Добавлены bible-local/runtime-flows.md и bible-local/decisions/
- Обновлён субмодуль bible до v0.2.0-13

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-06-13 14:38:01 +03:00
parent e548305396
commit 184f54b663
59 changed files with 1164 additions and 196 deletions
+1 -1
Submodule bible updated: 52444350c1...1977730d93
+563
View File
@@ -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=<code>` 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 48 — 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}'
```
+1
View File
@@ -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
+31
View File
@@ -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: <short title>
**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.
+86
View File
@@ -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.
+2 -2
View File
@@ -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 {
+7 -8
View File
@@ -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 := ""
+2 -2
View File
@@ -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.")
}
}
+1 -2
View File
@@ -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)
}
+60
View File
@@ -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)
}
+7 -2
View File
@@ -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,
})
}
+7 -7
View File
@@ -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
}
+6 -6
View File
@@ -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
+14 -7
View File
@@ -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),
+26 -38
View File
@@ -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
}
+9 -9
View File
@@ -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)
}
}
+6 -6
View File
@@ -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
}
+2 -51
View File
@@ -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)
}
+12 -4
View File
@@ -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)
}
}
}
}
+1 -1
View File
@@ -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
}
+9 -4
View File
@@ -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
}
+69 -3
View File
@@ -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
}
+18 -8
View File
@@ -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,
+5 -2
View File
@@ -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
}
+25 -18
View File
@@ -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 {
+11 -4
View File
@@ -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:
+8 -4
View File
@@ -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,
+6
View File
@@ -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
+6
View File
@@ -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';
+6
View File
@@ -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';
+6
View File
@@ -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
+6
View File
@@ -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,
@@ -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
@@ -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.
@@ -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;
+6
View File
@@ -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 (
@@ -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,
@@ -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;
@@ -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;
@@ -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;
+6
View File
@@ -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;
+6
View File
@@ -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,
@@ -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,
@@ -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,
@@ -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;
@@ -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;
@@ -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 := (
@@ -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;
@@ -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;
@@ -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;
+6
View File
@@ -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
+6
View File
@@ -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
+6
View File
@@ -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
@@ -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;
+6
View File
@@ -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';
+2 -2
View File
@@ -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] = [];
+2 -2
View File
@@ -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;
+1 -1
View File
@@ -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 = `
<tr>
+2 -2
View File
@@ -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 = `
<tr>