feat(bom): canonical lot mappings and updated vendor spec docs
This commit is contained in:
@@ -36,7 +36,7 @@ Active book: `WHERE is_active=1 ORDER BY created_at DESC, id DESC LIMIT 1`
|
|||||||
|
|
||||||
| Table | Purpose | Key Fields |
|
| Table | Purpose | Key Fields |
|
||||||
|-------|---------|------------|
|
|-------|---------|------------|
|
||||||
| `local_configurations` | Saved configurations | `id`, `uuid` (unique), `items` (JSON), `vendor_spec` (JSON), `line_no`, `pricelist_id`, `warehouse_pricelist_id`, `competitor_pricelist_id`, `current_version_id`, `sync_status` |
|
| `local_configurations` | Saved configurations | `id`, `uuid` (unique), `items` (JSON), `vendor_spec` (JSON: PN/qty/description + canonical `lot_mappings[]`), `line_no`, `pricelist_id`, `warehouse_pricelist_id`, `competitor_pricelist_id`, `current_version_id`, `sync_status` |
|
||||||
| `local_configuration_versions` | Immutable snapshots (revisions) | `id`, `configuration_id` (FK), `version_no`, `data` (JSON), `change_note`, `created_at` |
|
| `local_configuration_versions` | Immutable snapshots (revisions) | `id`, `configuration_id` (FK), `version_no`, `data` (JSON), `change_note`, `created_at` |
|
||||||
| `local_projects` | Projects | `id`, `uuid` (unique), `name`, `code`, `sync_status` |
|
| `local_projects` | Projects | `id`, `uuid` (unique), `name`, `code`, `sync_status` |
|
||||||
|
|
||||||
@@ -109,6 +109,7 @@ Database: `RFQ_LOG`
|
|||||||
| `qt_pricelist_sync_status` | Pricelist sync status | 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_books` | Partnumber book snapshots (written by PriceForge) | SELECT |
|
||||||
| `qt_partnumber_book_items` | PN→LOT mapping rows (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 |
|
||||||
|
|
||||||
### Grant Permissions to Existing User
|
### Grant Permissions to Existing User
|
||||||
|
|
||||||
@@ -130,6 +131,7 @@ GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO '<DB_USER>'@
|
|||||||
|
|
||||||
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO '<DB_USER>'@'%';
|
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO '<DB_USER>'@'%';
|
||||||
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO '<DB_USER>'@'%';
|
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO '<DB_USER>'@'%';
|
||||||
|
GRANT INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen TO '<DB_USER>'@'%';
|
||||||
|
|
||||||
FLUSH PRIVILEGES;
|
FLUSH PRIVILEGES;
|
||||||
```
|
```
|
||||||
@@ -153,6 +155,7 @@ GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO 'quote_user'@'
|
|||||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO 'quote_user'@'%';
|
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO 'quote_user'@'%';
|
||||||
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO 'quote_user'@'%';
|
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO 'quote_user'@'%';
|
||||||
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO 'quote_user'@'%';
|
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO 'quote_user'@'%';
|
||||||
|
GRANT INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen TO 'quote_user'@'%';
|
||||||
|
|
||||||
FLUSH PRIVILEGES;
|
FLUSH PRIVILEGES;
|
||||||
SHOW GRANTS FOR 'quote_user'@'%';
|
SHOW GRANTS FOR 'quote_user'@'%';
|
||||||
|
|||||||
@@ -89,6 +89,7 @@
|
|||||||
| POST | `/api/sync/pricelists` | Pull pricelists | MariaDB → SQLite |
|
| POST | `/api/sync/pricelists` | Pull pricelists | MariaDB → SQLite |
|
||||||
| POST | `/api/sync/all` | Full sync: push + pull + import | bidirectional |
|
| POST | `/api/sync/all` | Full sync: push + pull + import | bidirectional |
|
||||||
| POST | `/api/sync/repair` | Repair broken entries in pending_changes | SQLite |
|
| POST | `/api/sync/repair` | Repair broken entries in pending_changes | SQLite |
|
||||||
|
| POST | `/api/sync/partnumber-seen` | Report unresolved/ignored vendor PNs for server-side tracking | QuoteForge → MariaDB |
|
||||||
|
|
||||||
**If sync is blocked by the readiness guard:** all POST sync methods return `423 Locked` with `reason_code` and `reason_text`.
|
**If sync is blocked by the readiness guard:** all POST sync methods return `423 Locked` with `reason_code` and `reason_text`.
|
||||||
|
|
||||||
@@ -101,6 +102,14 @@
|
|||||||
| POST | `/api/configs/:uuid/vendor-spec/resolve` | Resolve PNs → LOTs (read-only) |
|
| POST | `/api/configs/:uuid/vendor-spec/resolve` | Resolve PNs → LOTs (read-only) |
|
||||||
| POST | `/api/configs/:uuid/vendor-spec/apply` | Apply resolved LOTs to cart |
|
| POST | `/api/configs/:uuid/vendor-spec/apply` | Apply resolved LOTs to cart |
|
||||||
|
|
||||||
|
Notes:
|
||||||
|
- `GET` / `PUT /api/configs/:uuid/vendor-spec` exchange normalized BOM rows (`vendor_spec`), not raw pasted Excel layout.
|
||||||
|
- BOM row contract stores canonical LOT mapping list as seen in BOM UI:
|
||||||
|
- `lot_mappings[]`
|
||||||
|
- each mapping contains `lot_name` + `quantity_per_pn`
|
||||||
|
- `POST /api/configs/:uuid/vendor-spec/apply` rebuilds cart items from explicit BOM mappings:
|
||||||
|
- all LOTs from `lot_mappings[]`
|
||||||
|
|
||||||
### Partnumber Books (read-only)
|
### Partnumber Books (read-only)
|
||||||
|
|
||||||
| Method | Endpoint | Purpose |
|
| Method | Endpoint | Purpose |
|
||||||
@@ -110,6 +119,7 @@
|
|||||||
| POST | `/api/sync/partnumber-books` | Pull book snapshots from MariaDB |
|
| POST | `/api/sync/partnumber-books` | Pull book snapshots from MariaDB |
|
||||||
|
|
||||||
See [09-vendor-spec.md](09-vendor-spec.md) for schema and pull logic.
|
See [09-vendor-spec.md](09-vendor-spec.md) for schema and pull logic.
|
||||||
|
See [09-vendor-spec.md](09-vendor-spec.md) for `vendor_spec` JSON schema and BOM UI mapping contract.
|
||||||
|
|
||||||
### Export
|
### Export
|
||||||
|
|
||||||
|
|||||||
@@ -28,16 +28,54 @@ The vendor spec feature allows importing a vendor BOM (Bill of Materials) into a
|
|||||||
"description": "...",
|
"description": "...",
|
||||||
"unit_price": 4500.00,
|
"unit_price": 4500.00,
|
||||||
"total_price": 9000.00,
|
"total_price": 9000.00,
|
||||||
"resolved_lot_name": "LOT_A",
|
"lot_mappings": [
|
||||||
"resolution_source": "book",
|
{ "lot_name": "LOT_A", "quantity_per_pn": 1 },
|
||||||
"manual_lot_suggestion": null
|
{ "lot_name": "LOT_B", "quantity_per_pn": 2 }
|
||||||
|
]
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
```
|
```
|
||||||
|
|
||||||
`resolution_source` values: `"book"` | `"manual_suggestion"` | `"unresolved"`
|
`lot_mappings[]` is the canonical persisted LOT mapping list for a BOM row.
|
||||||
|
Each mapping entry stores:
|
||||||
|
|
||||||
`manual_lot_suggestion` stores the user's inline LOT input — scoped to this configuration only, never written to the global book.
|
- `lot_name`
|
||||||
|
- `quantity_per_pn` (how many units of this LOT are included in **one vendor PN**)
|
||||||
|
|
||||||
|
### PN → LOT Mapping Contract (single LOT, multiplier, bundle)
|
||||||
|
|
||||||
|
QuoteForge expects the server to return/store BOM rows (`vendor_spec`) using a single canonical mapping list:
|
||||||
|
|
||||||
|
- `lot_mappings[]` contains **all** LOT mappings for the PN row (single LOT and bundle cases alike)
|
||||||
|
- the list stores exactly what the user sees in BOM (LOT + "LOT в 1 PN")
|
||||||
|
- the DB contract does **not** split mappings into "base LOT" vs "bundle LOTs"
|
||||||
|
|
||||||
|
#### Final quantity contribution to Estimate
|
||||||
|
|
||||||
|
For one BOM row with vendor PN quantity `pn_qty`:
|
||||||
|
|
||||||
|
- each mapping contribution:
|
||||||
|
- `lot_qty = pn_qty * lot_mappings[i].quantity_per_pn`
|
||||||
|
|
||||||
|
#### Example: one PN maps to multiple LOTs
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"vendor_partnumber": "SYS-821GE-TNHR",
|
||||||
|
"quantity": 3,
|
||||||
|
"lot_mappings": [
|
||||||
|
{ "lot_name": "CHASSIS_X13_8GPU", "quantity_per_pn": 1 },
|
||||||
|
{ "lot_name": "PS_3000W_Titanium", "quantity_per_pn": 2 },
|
||||||
|
{ "lot_name": "RAILKIT_X13", "quantity_per_pn": 1 }
|
||||||
|
]
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This row contributes to Estimate:
|
||||||
|
|
||||||
|
- `CHASSIS_X13_8GPU` → `3 * 1 = 3`
|
||||||
|
- `PS_3000W_Titanium` → `3 * 2 = 6`
|
||||||
|
- `RAILKIT_X13` → `3 * 1 = 3`
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -112,11 +150,13 @@ GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO '<DB_USER>'@'%';
|
|||||||
|
|
||||||
## Resolution Algorithm (3-step)
|
## Resolution Algorithm (3-step)
|
||||||
|
|
||||||
For each `vendor_partnumber` in the BOM:
|
For each `vendor_partnumber` in the BOM, QuoteForge builds/updates UI-visible LOT mappings:
|
||||||
|
|
||||||
1. **Active book lookup** — query `local_partnumber_book_items WHERE book_id = activeBook.id AND partnumber = ?`. If found → `resolved_lot_name = match.lot_name`, `resolution_source = "book"`.
|
1. **Active book lookup** — query `local_partnumber_book_items WHERE book_id = activeBook.id AND partnumber = ?`.
|
||||||
2. **Manual suggestion** — if `manual_lot_suggestion` is non-empty (user typed it before) → pre-fill as grey suggestion, `resolution_source = "manual_suggestion"`.
|
2. **Populate BOM UI** — if a match exists, BOM row shows a LOT value (user can still edit it).
|
||||||
3. **Unresolved** — red background, inline autocomplete input shown to user.
|
3. **Unresolved** — red row + inline LOT input with strict autocomplete.
|
||||||
|
|
||||||
|
Persistence note: the application stores the final user-visible mappings in `lot_mappings[]` (not separate "resolved/manual" persisted fields).
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
@@ -143,23 +183,77 @@ Examples (book: LOT_A → x1[primary], x2, x3):
|
|||||||
The configurator (`/configurator`) has three tabs:
|
The configurator (`/configurator`) has three tabs:
|
||||||
|
|
||||||
1. **Estimate** — existing cart/component configurator (unchanged).
|
1. **Estimate** — existing cart/component configurator (unchanged).
|
||||||
2. **BOM вендора** — paste from Excel, auto-resolution, manual LOT input for unresolved rows, "Пересчитать эстимейт" button (confirmation dialog before overwriting cart), "Очистить" button.
|
2. **BOM** — paste/import vendor BOM, manual column mapping, LOT matching, bundle decomposition (`1 PN -> multiple LOTs`), "Пересчитать эстимейт", "Очистить".
|
||||||
3. **Ценообразование** — pricing summary table + custom price input.
|
3. **Ценообразование** — pricing summary table + custom price input.
|
||||||
|
|
||||||
BOM data is shared between tabs 2 and 3.
|
BOM data is shared between tabs 2 and 3.
|
||||||
|
|
||||||
### BOM Paste Format (auto-detected, tab-separated from Excel)
|
### BOM Import UI (raw table, manual column mapping)
|
||||||
|
|
||||||
Columns are detected automatically by content analysis — any number of columns is supported:
|
After paste (`Ctrl+V`) QuoteForge renders an editable raw table (not auto-detected parsing).
|
||||||
|
|
||||||
- **price column** — last column where ≥70% of values are parseable numbers (handles `$5,114.00` and `5 114,00` formats)
|
- The pasted rows are shown **as-is** (including header rows, if present).
|
||||||
- **qty column** — first column where all values are integers
|
- The user selects a type for each column manually:
|
||||||
- **PN column** — last text column before qty
|
- `P/N`
|
||||||
- **description column** — longest text column after qty
|
- `Кол-во`
|
||||||
|
- `Цена`
|
||||||
|
- `Описание`
|
||||||
|
- `Не использовать`
|
||||||
|
- Required mapping:
|
||||||
|
- exactly one `P/N`
|
||||||
|
- exactly one `Кол-во`
|
||||||
|
- Optional mapping:
|
||||||
|
- `Цена` (0..1)
|
||||||
|
- `Описание` (0..1)
|
||||||
|
- Rows can be:
|
||||||
|
- ignored (UI-only, excluded from `vendor_spec`)
|
||||||
|
- deleted
|
||||||
|
- Raw cells are editable inline after paste.
|
||||||
|
|
||||||
If the second column of the first row is non-numeric → treat as header and skip.
|
Notes:
|
||||||
|
- There is **no auto column detection**.
|
||||||
|
- There is **no auto header-row skip**.
|
||||||
|
- Raw import layout itself is not stored on server; only normalized `vendor_spec` is stored.
|
||||||
|
|
||||||
Price parsing handles: `$` prefix, spaces as thousands separator, comma-as-decimal (European format).
|
### LOT matching in BOM table
|
||||||
|
|
||||||
|
The BOM table adds service columns on the right:
|
||||||
|
|
||||||
|
- `LOT`
|
||||||
|
- `LOT в 1 PN`
|
||||||
|
- actions (`+`, ignore, delete)
|
||||||
|
|
||||||
|
`LOT` behavior:
|
||||||
|
- The first LOT row shown in the BOM UI is the primary LOT mapping for the PN row.
|
||||||
|
- Additional LOT rows are added via the `+` action.
|
||||||
|
- inline LOT input is strict:
|
||||||
|
- autocomplete source = full local components list (`/api/components?per_page=5000`)
|
||||||
|
- free text that does not match an existing LOT is rejected
|
||||||
|
|
||||||
|
`LOT в 1 PN` behavior:
|
||||||
|
- quantity multiplier for each visible LOT row in BOM (`quantity_per_pn` in persisted `lot_mappings[]`)
|
||||||
|
- default = `1`
|
||||||
|
- editable inline
|
||||||
|
|
||||||
|
### Bundle mode (`1 PN -> multiple LOTs`)
|
||||||
|
|
||||||
|
The `+` action in BOM rows adds an extra LOT mapping row for the same vendor PN row.
|
||||||
|
|
||||||
|
- All visible LOT rows (first + added rows) are persisted uniformly in `lot_mappings[]`
|
||||||
|
- Each mapping row has:
|
||||||
|
- LOT
|
||||||
|
- qty (`LOT in 1 PN` = `quantity_per_pn`)
|
||||||
|
|
||||||
|
### BOM restore on config open
|
||||||
|
|
||||||
|
On config open, QuoteForge loads `vendor_spec` from server and reconstructs the editable BOM table in normalized form:
|
||||||
|
|
||||||
|
- columns restored as: `Qty | P/N | Description | Price`
|
||||||
|
- column mapping restored as:
|
||||||
|
- `qty`, `pn`, `description`, `price`
|
||||||
|
- LOT / `LOT в 1 PN` rows are restored from `vendor_spec.lot_mappings[]`
|
||||||
|
|
||||||
|
This restores the BOM editing state, but not the original raw Excel layout (extra columns, ignored rows, original headers).
|
||||||
|
|
||||||
### Pricing Tab: column order
|
### Pricing Tab: column order
|
||||||
|
|
||||||
@@ -171,12 +265,17 @@ LOT | PN вендора | Описание | Кол-во | Estimate | Цена
|
|||||||
|
|
||||||
**Description source priority:** BOM row description → LOT description from `local_components`.
|
**Description source priority:** BOM row description → LOT description from `local_components`.
|
||||||
|
|
||||||
### Inline LOT input for unresolved rows
|
### Pricing Tab: BOM + Estimate merge behavior
|
||||||
|
|
||||||
Unresolved rows show a red background with an `<input list="lot-autocomplete-list">` (HTML5 datalist). The datalist is rebuilt from `allComponents` on every `renderBOMTable()` call.
|
When BOM exists, the pricing tab renders:
|
||||||
|
|
||||||
- `oninput` — updates `bomRows[idx].manual_lot` only; no table re-render (prevents focus loss).
|
- BOM-based rows (including rows resolved via manual LOT and bundle mappings)
|
||||||
- `onchange` — validates via `_bomLotValid(v)` against `allComponents`; rejects free-text not matching a known LOT (field reset). If valid, calls `resolveBOM()`.
|
- plus **Estimate-only LOTs** (rows currently in cart but not covered by BOM mappings)
|
||||||
|
|
||||||
|
Estimate-only rows are shown as separate rows with:
|
||||||
|
- `PN вендора = "—"`
|
||||||
|
- vendor price = `—`
|
||||||
|
- description from local components
|
||||||
|
|
||||||
### Pricing Tab: "Своя цена" input
|
### Pricing Tab: "Своя цена" input
|
||||||
|
|
||||||
@@ -202,15 +301,19 @@ Unresolved rows show a red background with an `<input list="lot-autocomplete-lis
|
|||||||
|
|
||||||
## Unresolved PN Tracking (`qt_vendor_partnumber_seen`)
|
## Unresolved PN Tracking (`qt_vendor_partnumber_seen`)
|
||||||
|
|
||||||
After each `resolveBOM()` call, all unresolved PNs (rows where `resolution_source === 'unresolved'`) are pushed to the server via `POST /api/sync/partnumber-seen` (fire-and-forget from JS — errors silently ignored).
|
After each `resolveBOM()` call, QuoteForge pushes PN rows to `POST /api/sync/partnumber-seen` (fire-and-forget from JS — errors silently ignored):
|
||||||
|
|
||||||
|
- 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 upserts into `qt_vendor_partnumber_seen`:
|
||||||
|
|
||||||
```sql
|
```sql
|
||||||
INSERT INTO qt_vendor_partnumber_seen (source_type, vendor, partnumber, description, last_seen_at)
|
INSERT INTO qt_vendor_partnumber_seen (source_type, vendor, partnumber, description, is_ignored, last_seen_at)
|
||||||
VALUES ('manual', '', ?, ?, NOW())
|
VALUES ('manual', '', ?, ?, ?, NOW())
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
last_seen_at = VALUES(last_seen_at),
|
last_seen_at = VALUES(last_seen_at),
|
||||||
|
is_ignored = VALUES(is_ignored),
|
||||||
description = COALESCE(NULLIF(VALUES(description), ''), description)
|
description = COALESCE(NULLIF(VALUES(description), ''), description)
|
||||||
```
|
```
|
||||||
|
|
||||||
@@ -219,13 +322,40 @@ Uniqueness key: `partnumber` only (after PriceForge migration 025). PriceForge u
|
|||||||
## BOM Persistence
|
## BOM Persistence
|
||||||
|
|
||||||
- `vendor_spec` is saved to server via `PUT /api/configs/:uuid/vendor-spec`.
|
- `vendor_spec` is saved to server via `PUT /api/configs/:uuid/vendor-spec`.
|
||||||
|
- `GET` / `PUT` `vendor_spec` must preserve row-level mapping fields used by the UI:
|
||||||
|
- `lot_mappings[]`
|
||||||
|
- each item: `lot_name`, `quantity_per_pn`
|
||||||
|
- `description` is persisted in each BOM row and is used by the Pricing tab when available.
|
||||||
|
- Ignored raw rows are **not** persisted into `vendor_spec`.
|
||||||
- The PUT handler explicitly marshals `VendorSpec` to JSON string before passing to GORM `Update` (GORM does not reliably call `driver.Valuer` for custom types in `Update(column, value)`).
|
- The PUT handler explicitly marshals `VendorSpec` to JSON string before passing to GORM `Update` (GORM does not reliably call `driver.Valuer` for custom types in `Update(column, value)`).
|
||||||
- BOM is saved automatically after every `resolveBOM()` (which fires on paste and on manual LOT selection).
|
- BOM is autosaved (debounced) after BOM-changing actions, including:
|
||||||
|
- `resolveBOM()`
|
||||||
|
- LOT row qty (`LOT в 1 PN`) changes
|
||||||
|
- LOT row add/remove (`+` / delete in bundle context)
|
||||||
- "Сохранить BOM" button triggers explicit save.
|
- "Сохранить BOM" button triggers explicit save.
|
||||||
|
|
||||||
## Pricing Tab: Estimate Price Source
|
## Pricing Tab: Estimate Price Source
|
||||||
|
|
||||||
`renderPricingTab()` is async. It calls `POST /api/quote/price-levels` with all resolved LOTs from BOM rows (regardless of whether those LOTs are in the cart). This ensures Estimate prices appear even for manually-resolved LOTs that have not yet been applied to the cart via "Пересчитать эстимейт".
|
`renderPricingTab()` is async. It calls `POST /api/quote/price-levels` with LOTs collected from:
|
||||||
|
|
||||||
|
- `lot_mappings[]` from BOM rows
|
||||||
|
- current Estimate/cart LOTs not covered by BOM mappings (to show estimate-only rows)
|
||||||
|
|
||||||
|
This ensures Estimate prices appear for:
|
||||||
|
|
||||||
|
- manually matched LOTs in the BOM tab
|
||||||
|
- bundle LOTs
|
||||||
|
- LOTs already present in Estimate but not mapped from BOM
|
||||||
|
|
||||||
|
### Apply to Estimate (`Пересчитать эстимейт`)
|
||||||
|
|
||||||
|
When applying BOM to Estimate, QuoteForge builds cart rows from explicit UI mappings stored in `lot_mappings[]`.
|
||||||
|
|
||||||
|
For a BOM row with PN qty = `Q`:
|
||||||
|
|
||||||
|
- each mapped LOT contributes `Q * quantity_per_pn`
|
||||||
|
|
||||||
|
Rows without any valid LOT mapping are skipped.
|
||||||
|
|
||||||
## Web Route
|
## Web Route
|
||||||
|
|
||||||
|
|||||||
@@ -678,6 +678,7 @@ func (h *SyncHandler) ReportPartnumberSeen(c *gin.Context) {
|
|||||||
Items []struct {
|
Items []struct {
|
||||||
Partnumber string `json:"partnumber"`
|
Partnumber string `json:"partnumber"`
|
||||||
Description string `json:"description"`
|
Description string `json:"description"`
|
||||||
|
Ignored bool `json:"ignored"`
|
||||||
} `json:"items"`
|
} `json:"items"`
|
||||||
}
|
}
|
||||||
if err := c.ShouldBindJSON(&body); err != nil {
|
if err := c.ShouldBindJSON(&body); err != nil {
|
||||||
@@ -691,6 +692,7 @@ func (h *SyncHandler) ReportPartnumberSeen(c *gin.Context) {
|
|||||||
items = append(items, sync.SeenPartnumber{
|
items = append(items, sync.SeenPartnumber{
|
||||||
Partnumber: it.Partnumber,
|
Partnumber: it.Partnumber,
|
||||||
Description: it.Description,
|
Description: it.Description,
|
||||||
|
Ignored: it.Ignored,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -283,6 +283,21 @@ type VendorSpecItem struct {
|
|||||||
ResolvedLotName string `json:"resolved_lot_name,omitempty"`
|
ResolvedLotName string `json:"resolved_lot_name,omitempty"`
|
||||||
ResolutionSource string `json:"resolution_source,omitempty"` // "book", "manual", "unresolved"
|
ResolutionSource string `json:"resolution_source,omitempty"` // "book", "manual", "unresolved"
|
||||||
ManualLotSuggestion string `json:"manual_lot_suggestion,omitempty"`
|
ManualLotSuggestion string `json:"manual_lot_suggestion,omitempty"`
|
||||||
|
LotQtyPerPN int `json:"lot_qty_per_pn,omitempty"`
|
||||||
|
LotAllocations []VendorSpecLotAllocation `json:"lot_allocations,omitempty"`
|
||||||
|
LotMappings []VendorSpecLotMapping `json:"lot_mappings,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VendorSpecLotAllocation struct {
|
||||||
|
LotName string `json:"lot_name"`
|
||||||
|
Quantity int `json:"quantity"` // quantity of LOT per 1 vendor PN
|
||||||
|
}
|
||||||
|
|
||||||
|
// VendorSpecLotMapping is the canonical persisted LOT mapping for a vendor PN row.
|
||||||
|
// It stores all mapped LOTs (base + bundle) uniformly.
|
||||||
|
type VendorSpecLotMapping struct {
|
||||||
|
LotName string `json:"lot_name"`
|
||||||
|
QuantityPerPN int `json:"quantity_per_pn"`
|
||||||
}
|
}
|
||||||
|
|
||||||
// VendorSpec is a JSON-encodable slice of VendorSpecItem
|
// VendorSpec is a JSON-encodable slice of VendorSpecItem
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ import (
|
|||||||
type SeenPartnumber struct {
|
type SeenPartnumber struct {
|
||||||
Partnumber string
|
Partnumber string
|
||||||
Description string
|
Description string
|
||||||
|
Ignored bool
|
||||||
}
|
}
|
||||||
|
|
||||||
// PushPartnumberSeen inserts unresolved vendor partnumbers into qt_vendor_partnumber_seen on MariaDB.
|
// PushPartnumberSeen inserts unresolved vendor partnumbers into qt_vendor_partnumber_seen on MariaDB.
|
||||||
@@ -31,13 +32,14 @@ func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
|
|||||||
}
|
}
|
||||||
err := mariaDB.Exec(`
|
err := mariaDB.Exec(`
|
||||||
INSERT INTO qt_vendor_partnumber_seen
|
INSERT INTO qt_vendor_partnumber_seen
|
||||||
(source_type, vendor, partnumber, description, last_seen_at)
|
(source_type, vendor, partnumber, description, is_ignored, last_seen_at)
|
||||||
VALUES
|
VALUES
|
||||||
('manual', '', ?, ?, ?)
|
('manual', '', ?, ?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
last_seen_at = VALUES(last_seen_at),
|
last_seen_at = VALUES(last_seen_at),
|
||||||
|
is_ignored = VALUES(is_ignored),
|
||||||
description = COALESCE(NULLIF(VALUES(description), ''), description)
|
description = COALESCE(NULLIF(VALUES(description), ''), description)
|
||||||
`, item.Partnumber, item.Description, now).Error
|
`, item.Partnumber, item.Description, item.Ignored, now).Error
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("failed to upsert partnumber_seen", "partnumber", item.Partnumber, "error", err)
|
slog.Error("failed to upsert partnumber_seen", "partnumber", item.Partnumber, "error", err)
|
||||||
// Continue with remaining items
|
// Continue with remaining items
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user