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:
@@ -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.
|
||||
Reference in New Issue
Block a user