Add vendor workspace import and pricing export workflow

This commit is contained in:
Mikhail Chusavitin
2026-03-07 21:03:40 +03:00
parent 08ecfd0826
commit 7c3752f110
30 changed files with 3042 additions and 482 deletions

View File

@@ -115,5 +115,10 @@ QuoteForge integrates with the existing `RFQ_LOG` database:
**Sync service tables:**
- `qt_client_local_migrations` — migration catalog (SELECT only)
- `qt_client_schema_state` — applied migrations state
- `qt_client_schema_state` — applied migrations state and operational client status per device (`username + hostname`)
Fields written by QuoteForge:
`last_applied_migration_id`, `app_version`, `last_sync_at`, `last_sync_status`,
`pending_changes_count`, `pending_errors_count`, `configurations_count`, `projects_count`,
`estimate_pricelist_version`, `warehouse_pricelist_version`, `competitor_pricelist_version`,
`last_sync_error_code`, `last_sync_error_text`, `last_checked_at`, `updated_at`
- `qt_pricelist_sync_status` — pricelist sync status

View File

@@ -107,11 +107,33 @@ Database: `RFQ_LOG`
| `stock_log` | Latest stock qty by partnumber (pricelist enrichment) | SELECT |
| `qt_projects` | Projects | SELECT, INSERT, UPDATE |
| `qt_client_local_migrations` | Migration catalog | SELECT only |
| `qt_client_schema_state` | Applied migrations state | SELECT, INSERT, UPDATE |
| `qt_client_schema_state` | Applied migrations state + client operational status per `username + hostname` | SELECT, INSERT, UPDATE |
| `qt_pricelist_sync_status` | Pricelist sync status | SELECT, INSERT, UPDATE |
| `qt_partnumber_books` | Partnumber book snapshots (written by PriceForge) | SELECT |
| `qt_partnumber_book_items` | PN→LOT mapping rows (written by PriceForge) | SELECT |
| `qt_vendor_partnumber_seen` | Vendor PN tracking for unresolved/ignored BOM rows (`is_ignored`) | INSERT, UPDATE |
| `qt_vendor_partnumber_seen` | Vendor PN tracking for unresolved/ignored BOM rows (`is_ignored`) | INSERT only for new `partnumber`; existing rows must not be modified |
`qt_client_schema_state` current contract:
- identity key: `username + hostname`
- migration state:
`last_applied_migration_id`, `app_version`, `last_checked_at`, `updated_at`
- operational state:
`last_sync_at`, `last_sync_status`
- queue health:
`pending_changes_count`, `pending_errors_count`
- local dataset size:
`configurations_count`, `projects_count`
- price context:
`estimate_pricelist_version`, `warehouse_pricelist_version`, `competitor_pricelist_version`
- last known sync problem:
`last_sync_error_code`, `last_sync_error_text`
`last_sync_error_*` source priority:
1. blocked readiness state from `local_sync_guard_state`
2. latest non-empty `pending_changes.last_error`
3. `NULL` when no known sync problem exists
### Grant Permissions to Existing User

View File

@@ -70,10 +70,15 @@
| DELETE | `/api/projects/:uuid` | Archive project variant (soft-delete via `is_active=false`; fails if project has no `variant` set — main projects cannot be deleted this way) |
| GET | `/api/projects/:uuid/configs` | Project configurations |
| PATCH | `/api/projects/:uuid/configs/reorder` | Reorder active project configurations (`ordered_uuids`) and persist `line_no` |
| POST | `/api/projects/:uuid/vendor-import` | Import a vendor `CFXML` workspace into the existing project |
`GET /api/projects/:uuid/configs` ordering:
`line ASC`, then `created_at DESC`, then `id DESC`.
`POST /api/projects/:uuid/vendor-import` accepts `multipart/form-data` with one required file field:
- `file` — vendor configurator export in `CFXML` format
### Sync
| Method | Endpoint | Purpose | Flow |
@@ -115,7 +120,7 @@ Notes:
| Method | Endpoint | Purpose |
|--------|----------|---------|
| GET | `/api/partnumber-books` | List local book snapshots |
| GET | `/api/partnumber-books/:id` | Items for a book by `server_id` |
| GET | `/api/partnumber-books/:id` | Items for a book by `server_id` (`page`, `per_page`, `search`) |
| POST | `/api/sync/partnumber-books` | Pull book snapshots from MariaDB |
See [09-vendor-spec.md](09-vendor-spec.md) for schema and pull logic.
@@ -126,6 +131,8 @@ See [09-vendor-spec.md](09-vendor-spec.md) for `vendor_spec` JSON schema and BOM
| Method | Endpoint | Purpose |
|--------|----------|---------|
| POST | `/api/export/csv` | Export configuration to CSV |
| GET | `/api/projects/:uuid/export` | Legacy project CSV export in block BOM format |
| POST | `/api/projects/:uuid/export` | Project CSV export in pricing-tab format with selectable columns (`include_lot`, `include_bom`, `include_estimate`, `include_stock`, `include_competitor`) |
**Export filename format:** `YYYY-MM-DD (ProjectCode) ConfigName Article.csv`
(uses `project.Code`, not `project.Name`)

View File

@@ -15,7 +15,8 @@ The vendor spec feature allows importing a vendor BOM (Bill of Materials) into a
| `vendor_spec` JSON | `local_configurations.vendor_spec` (TEXT, JSON-encoded) | Two-way via `pending_changes` |
| Partnumber book snapshots | `local_partnumber_books` + `local_partnumber_book_items` | Pull-only from PriceForge |
`vendor_spec` is a JSON array of `VendorSpecItem` objects stored inside the configuration row. It syncs to MariaDB `qt_configurations.vendor_spec` via the existing pending_changes mechanism.
`vendor_spec` is a JSON array of `VendorSpecItem` objects stored inside the configuration row.
It syncs to MariaDB `qt_configurations.vendor_spec` via the existing pending_changes mechanism.
### `vendor_spec` JSON Schema
@@ -160,6 +161,201 @@ Persistence note: the application stores the final user-visible mappings in `lot
---
## CFXML Workspace Import Contract
QuoteForge may import a vendor configurator workspace in `CFXML` format as an existing project update path.
This import path must convert one external workspace into one QuoteForge project containing multiple configurations.
### Import Unit Boundaries
- One `CFXML` workspace file = one QuoteForge project import session.
- One top-level configuration group inside the workspace = one QuoteForge configuration.
- Software rows are **not** imported as standalone configurations.
- All software rows must be attached to the configuration group they belong to.
### Configuration Grouping
Top-level `ProductLineItem` rows are grouped by:
- `ProprietaryGroupIdentifier`
This field is the canonical boundary of one imported configuration.
Rules:
1. Read all top-level `ProductLineItem` rows in document order.
2. Group them by `ProprietaryGroupIdentifier`.
3. Preserve document order of groups by the first encountered `ProductLineNumber`.
4. Import each group as exactly one QuoteForge configuration.
`ConfigurationGroupLineNumberReference` is not sufficient for grouping imported configurations because
multiple independent configuration groups may share the same value in one workspace.
### Primary Row Selection (no SKU hardcode)
The importer must not hardcode vendor, model, or server SKU values.
Within each `ProprietaryGroupIdentifier` group, the importer selects one primary top-level row using
structural rules only:
1. Prefer rows with `ProductTypeCode = Hardware`.
2. If multiple rows match, prefer the row with the largest number of `ProductSubLineItem` children.
3. If there is still a tie, prefer the first row by `ProductLineNumber`.
The primary row provides configuration-level metadata such as:
- configuration name
- server count
- server model / description
- article / support code candidate
### Software Inclusion Rule
All top-level rows belonging to the same `ProprietaryGroupIdentifier` must be imported into the same
QuoteForge configuration, including:
- `Hardware`
- `Software`
- instruction / service rows represented as software-like items
Effects:
- a workspace never creates a separate configuration made only of software;
- `software1`, `software2`, license rows, and instruction rows stay inside the related configuration;
- the user sees one complete configuration instead of fragmented partial imports.
### Mapping to QuoteForge Project / Configuration
For one imported configuration group:
- QuoteForge configuration `name` <- primary row `ProductName`
- QuoteForge configuration `server_count` <- primary row `Quantity`
- QuoteForge configuration `server_model` <- primary row `ProductDescription`
- QuoteForge configuration `article` or `support_code` <- primary row `ProprietaryProductIdentifier`
- QuoteForge configuration `line` <- stable order by group appearance in the workspace
Project-level fields such as QuoteForge `code`, `name`, and `variant` are not reliably defined by `CFXML`
itself and should come from the existing target project context or explicit user input.
### Mapping to `vendor_spec`
The importer must build one combined `vendor_spec` array per configuration group.
Source rows:
- all `ProductSubLineItem` rows from the primary top-level row;
- all `ProductSubLineItem` rows from every non-primary top-level row in the same group;
- if a top-level row has no `ProductSubLineItem`, the top-level row itself may be converted into one
`vendor_spec` row so that software-only content is not lost.
Each imported row maps into one `VendorSpecItem`:
- `sort_order` <- stable sequence within the group
- `vendor_partnumber` <- `ProprietaryProductIdentifier`
- `quantity` <- `Quantity`
- `description` <- `ProductDescription`
- `unit_price` <- `UnitListPrice.FinancialAmount.MonetaryAmount` when present
- `total_price` <- `quantity * unit_price` when unit price is present
- `lot_mappings` <- resolved immediately from the active partnumber book when a unique match exists
The importer stores vendor-native rows in `vendor_spec`, then immediately runs the same logical flow as BOM
Resolve + Apply:
- resolve vendor PN rows through the active partnumber book
- persist canonical `lot_mappings[]`
- build normalized configuration `items` from `row.quantity * quantity_per_pn`
- fill `items.unit_price` from the latest local `estimate` pricelist
- recalculate configuration `total_price`
### Import Pipeline
Recommended parser pipeline:
1. Parse XML into top-level `ProductLineItem` rows.
2. Group rows by `ProprietaryGroupIdentifier`.
3. Select one primary row per group using structural rules.
4. Build one QuoteForge configuration DTO per group.
5. Merge all hardware/software rows of the group into one `vendor_spec`.
6. Resolve imported PN rows into canonical `lot_mappings[]` using the active partnumber book.
7. Build configuration `items` from resolved `lot_mappings[]`.
8. Price those `items` from the latest local `estimate` pricelist.
9. Save or update the QuoteForge configuration inside the existing project.
### Recommended Internal DTO
```go
type ImportedProject struct {
SourceFormat string
SourceFilePath string
SourceDocID string
Code string
Name string
Variant string
Configurations []ImportedConfiguration
}
type ImportedConfiguration struct {
GroupID string
Name string
Line int
ServerCount int
ServerModel string
Article string
SupportCode string
CurrencyCode string
TopLevelRows []ImportedTopLevelRow
VendorSpec []ImportedVendorRow
}
type ImportedTopLevelRow struct {
ProductLineNumber string
ItemNo string
GroupID string
ProductType string
ProductCode string
ProductName string
Description string
Quantity int
UnitPrice *float64
IsPrimary bool
SubRows []ImportedVendorRow
}
type ImportedVendorRow struct {
SortOrder int
SourceLineNumber string
SourceParentLine string
SourceProductType string
VendorPartnumber string
Description string
Quantity int
UnitPrice *float64
TotalPrice *float64
ProductCharacter string
ProductCharPath string
}
```
### Current Product Assumption
For QuoteForge product behavior, the correct user-facing interpretation is:
- one external project/workspace contains several configurations;
- each configuration contains both hardware and software rows that belong to it;
- the importer must preserve that grouping exactly.
---
## Qty Aggregation Logic
After resolution, qty per LOT is computed as:
@@ -294,6 +490,7 @@ Estimate-only rows are shown as separate rows with:
| PUT | `/api/configs/:uuid/vendor-spec` | Replace BOM (full update) |
| POST | `/api/configs/:uuid/vendor-spec/resolve` | Resolve PNs → LOTs (no cart mutation) |
| POST | `/api/configs/:uuid/vendor-spec/apply` | Apply resolved LOTs to cart |
| POST | `/api/projects/:uuid/vendor-import` | Import `CFXML` workspace into an existing project and create grouped configurations |
| GET | `/api/partnumber-books` | List local book snapshots |
| GET | `/api/partnumber-books/:id` | Items for a book by server_id |
| POST | `/api/sync/partnumber-books` | Pull book snapshots from MariaDB |
@@ -306,15 +503,20 @@ After each `resolveBOM()` call, QuoteForge pushes PN rows to `POST /api/sync/par
- unresolved BOM rows (`ignored = false`)
- raw BOM rows explicitly marked as ignored in UI (`ignored = true`) — these rows are **not** saved to `vendor_spec`, but are reported for server-side tracking
The handler calls `sync.PushPartnumberSeen()` which upserts into `qt_vendor_partnumber_seen`:
The handler calls `sync.PushPartnumberSeen()` which inserts into `qt_vendor_partnumber_seen`.
If a row with the same `partnumber` already exists, QuoteForge must leave it untouched:
- do not update `last_seen_at`
- do not update `is_ignored`
- do not update `description`
Canonical insert behavior:
```sql
INSERT INTO qt_vendor_partnumber_seen (source_type, vendor, partnumber, description, is_ignored, last_seen_at)
VALUES ('manual', '', ?, ?, ?, NOW())
ON DUPLICATE KEY UPDATE
last_seen_at = VALUES(last_seen_at),
is_ignored = VALUES(is_ignored),
description = COALESCE(NULLIF(VALUES(description), ''), description)
partnumber = partnumber
```
Uniqueness key: `partnumber` only (after PriceForge migration 025). PriceForge uses this table for populating the partnumber book.