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:
+1
-1
Submodule bible updated: 52444350c1...1977730d93
@@ -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 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}'
|
||||
```
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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.
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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,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
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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] = [];
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
Reference in New Issue
Block a user