Add vendor workspace import and pricing export workflow
This commit is contained in:
33
acc_lot_log_import.sql
Normal file
33
acc_lot_log_import.sql
Normal file
@@ -0,0 +1,33 @@
|
|||||||
|
-- Generated from /Users/mchusavitin/Downloads/acc.csv
|
||||||
|
-- Unambiguous rows only. Rows from headers without a date were skipped.
|
||||||
|
INSERT INTO lot_log (`lot`, `supplier`, `date`, `price`, `quality`, `comments`) VALUES
|
||||||
|
('ACC_RMK_L_Type', '', '2024-04-01', 19, NULL, 'header supplier missing in source (45383)'),
|
||||||
|
('ACC_RMK_SLIDE', '', '2024-04-01', 31, NULL, 'header supplier missing in source (45383)'),
|
||||||
|
('NVLINK_2S_Bridge', '', '2023-01-01', 431, NULL, 'header supplier missing in source (44927)'),
|
||||||
|
('NVLINK_2S_Bridge', 'Jevy Yang', '2025-01-15', 139, NULL, NULL),
|
||||||
|
('NVLINK_2S_Bridge', 'Wendy', '2025-01-15', 143, NULL, NULL),
|
||||||
|
('NVLINK_2S_Bridge', 'HONCH (Darian)', '2025-05-06', 155, NULL, NULL),
|
||||||
|
('NVLINK_2S_Bridge', 'HONCH (Sunny)', '2025-06-17', 155, NULL, NULL),
|
||||||
|
('NVLINK_2S_Bridge', 'Wendy', '2025-07-02', 145, NULL, NULL),
|
||||||
|
('NVLINK_2S_Bridge', 'Honch (Sunny)', '2025-07-10', 155, NULL, NULL),
|
||||||
|
('NVLINK_2S_Bridge', 'Honch (Yan)', '2025-08-07', 155, NULL, NULL),
|
||||||
|
('NVLINK_2S_Bridge', 'Jevy', '2025-09-09', 155, NULL, NULL),
|
||||||
|
('NVLINK_2S_Bridge', 'Honch (Darian)', '2025-11-17', 102, NULL, NULL),
|
||||||
|
('NVLINK_2W_Bridge(H200)', '', '2023-01-01', 405, NULL, 'header supplier missing in source (44927)'),
|
||||||
|
('NVLINK_2W_Bridge(H200)', 'network logic / Stephen', '2025-02-10', 305, NULL, NULL),
|
||||||
|
('NVLINK_2W_Bridge(H200)', 'JEVY', '2025-02-18', 411, NULL, NULL),
|
||||||
|
('NVLINK_4W_Bridge(H200)', '', '2023-01-01', 820, NULL, 'header supplier missing in source (44927)'),
|
||||||
|
('NVLINK_4W_Bridge(H200)', 'network logic / Stephen', '2025-02-10', 610, NULL, NULL),
|
||||||
|
('NVLINK_4W_Bridge(H200)', 'JEVY', '2025-02-18', 754, NULL, NULL),
|
||||||
|
('25G_SFP28_MMA2P00-AS', 'HONCH (Doris)', '2025-02-19', 65, NULL, NULL),
|
||||||
|
('ACC_SuperCap', '', '2024-04-01', 59, NULL, 'header supplier missing in source (45383)'),
|
||||||
|
('ACC_SuperCap', 'Chiphome', '2025-02-28', 48, NULL, NULL);
|
||||||
|
|
||||||
|
-- Skipped source values due to missing date in header:
|
||||||
|
-- lot=ACC_RMK_L_Type; header=FOB; price=19; reason=header has supplier but no date
|
||||||
|
-- lot=ACC_RMK_SLIDE; header=FOB; price=31; reason=header has supplier but no date
|
||||||
|
-- lot=NVLINK_2S_Bridge; header=FOB; price=155; reason=header has supplier but no date
|
||||||
|
-- lot=NVLINK_2W_Bridge(H200); header=FOB; price=405; reason=header has supplier but no date
|
||||||
|
-- lot=NVLINK_4W_Bridge(H200); header=FOB; price=754; reason=header has supplier but no date
|
||||||
|
-- lot=25G_SFP28_MMA2P00-AS; header=FOB; price=65; reason=header has supplier but no date
|
||||||
|
-- lot=ACC_SuperCap; header=FOB; price=48; reason=header has supplier but no date
|
||||||
2
bible
2
bible
Submodule bible updated: 34b457d654...72e10622ba
@@ -115,5 +115,10 @@ QuoteForge integrates with the existing `RFQ_LOG` database:
|
|||||||
|
|
||||||
**Sync service tables:**
|
**Sync service tables:**
|
||||||
- `qt_client_local_migrations` — migration catalog (SELECT only)
|
- `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
|
- `qt_pricelist_sync_status` — pricelist sync status
|
||||||
|
|||||||
@@ -107,11 +107,33 @@ Database: `RFQ_LOG`
|
|||||||
| `stock_log` | Latest stock qty by partnumber (pricelist enrichment) | SELECT |
|
| `stock_log` | Latest stock qty by partnumber (pricelist enrichment) | SELECT |
|
||||||
| `qt_projects` | Projects | SELECT, INSERT, UPDATE |
|
| `qt_projects` | Projects | SELECT, INSERT, UPDATE |
|
||||||
| `qt_client_local_migrations` | Migration catalog | SELECT only |
|
| `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_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 |
|
| `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
|
### Grant Permissions to Existing User
|
||||||
|
|
||||||
|
|||||||
@@ -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) |
|
| 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 |
|
| GET | `/api/projects/:uuid/configs` | Project configurations |
|
||||||
| PATCH | `/api/projects/:uuid/configs/reorder` | Reorder active project configurations (`ordered_uuids`) and persist `line_no` |
|
| 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:
|
`GET /api/projects/:uuid/configs` ordering:
|
||||||
`line ASC`, then `created_at DESC`, then `id DESC`.
|
`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
|
### Sync
|
||||||
|
|
||||||
| Method | Endpoint | Purpose | Flow |
|
| Method | Endpoint | Purpose | Flow |
|
||||||
@@ -115,7 +120,7 @@ Notes:
|
|||||||
| Method | Endpoint | Purpose |
|
| Method | Endpoint | Purpose |
|
||||||
|--------|----------|---------|
|
|--------|----------|---------|
|
||||||
| GET | `/api/partnumber-books` | List local book snapshots |
|
| 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 |
|
| 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.
|
||||||
@@ -126,6 +131,8 @@ See [09-vendor-spec.md](09-vendor-spec.md) for `vendor_spec` JSON schema and BOM
|
|||||||
| Method | Endpoint | Purpose |
|
| Method | Endpoint | Purpose |
|
||||||
|--------|----------|---------|
|
|--------|----------|---------|
|
||||||
| POST | `/api/export/csv` | Export configuration to CSV |
|
| 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`
|
**Export filename format:** `YYYY-MM-DD (ProjectCode) ConfigName Article.csv`
|
||||||
(uses `project.Code`, not `project.Name`)
|
(uses `project.Code`, not `project.Name`)
|
||||||
|
|||||||
@@ -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` |
|
| `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 |
|
| 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
|
### `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
|
## Qty Aggregation Logic
|
||||||
|
|
||||||
After resolution, qty per LOT is computed as:
|
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) |
|
| 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/resolve` | Resolve PNs → LOTs (no cart mutation) |
|
||||||
| POST | `/api/configs/:uuid/vendor-spec/apply` | Apply resolved LOTs to cart |
|
| 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` | 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 |
|
||||||
| POST | `/api/sync/partnumber-books` | Pull book snapshots from MariaDB |
|
| 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`)
|
- 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
|
- 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
|
```sql
|
||||||
INSERT INTO qt_vendor_partnumber_seen (source_type, vendor, partnumber, description, is_ignored, 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),
|
partnumber = partnumber
|
||||||
is_ignored = VALUES(is_ignored),
|
|
||||||
description = COALESCE(NULLIF(VALUES(description), ''), description)
|
|
||||||
```
|
```
|
||||||
|
|
||||||
Uniqueness key: `partnumber` only (after PriceForge migration 025). PriceForge uses this table for populating the partnumber book.
|
Uniqueness key: `partnumber` only (after PriceForge migration 025). PriceForge uses this table for populating the partnumber book.
|
||||||
|
|||||||
@@ -6,6 +6,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"flag"
|
"flag"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"io"
|
||||||
"io/fs"
|
"io/fs"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
"math"
|
"math"
|
||||||
@@ -1730,7 +1731,46 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
c.JSON(http.StatusCreated, config)
|
c.JSON(http.StatusCreated, config)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
projects.POST("/:uuid/vendor-import", func(c *gin.Context) {
|
||||||
|
fileHeader, err := c.FormFile("file")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "file is required"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
file, err := fileHeader.Open()
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to open uploaded file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
defer file.Close()
|
||||||
|
|
||||||
|
data, err := io.ReadAll(file)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "failed to read uploaded file"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if !services.IsCFXMLWorkspace(data) {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "unsupported vendor export format"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := configService.ImportVendorWorkspaceToProject(c.Param("uuid"), fileHeader.Filename, data, dbUsername)
|
||||||
|
if err != nil {
|
||||||
|
switch {
|
||||||
|
case errors.Is(err, services.ErrProjectNotFound):
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
default:
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
}
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
c.JSON(http.StatusCreated, result)
|
||||||
|
})
|
||||||
|
|
||||||
projects.GET("/:uuid/export", exportHandler.ExportProjectCSV)
|
projects.GET("/:uuid/export", exportHandler.ExportProjectCSV)
|
||||||
|
projects.POST("/:uuid/export", exportHandler.ExportProjectPricingCSV)
|
||||||
|
|
||||||
projects.POST("/:uuid/configs/:config_uuid/clone", func(c *gin.Context) {
|
projects.POST("/:uuid/configs/:config_uuid/clone", func(c *gin.Context) {
|
||||||
var req struct {
|
var req struct {
|
||||||
|
|||||||
@@ -45,6 +45,14 @@ type ExportRequest struct {
|
|||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProjectExportOptionsRequest struct {
|
||||||
|
IncludeLOT bool `json:"include_lot"`
|
||||||
|
IncludeBOM bool `json:"include_bom"`
|
||||||
|
IncludeEstimate bool `json:"include_estimate"`
|
||||||
|
IncludeStock bool `json:"include_stock"`
|
||||||
|
IncludeCompetitor bool `json:"include_competitor"`
|
||||||
|
}
|
||||||
|
|
||||||
func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
||||||
var req ExportRequest
|
var req ExportRequest
|
||||||
if err := c.ShouldBindJSON(&req); err != nil {
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
@@ -213,3 +221,53 @@ func (h *ExportHandler) ExportProjectCSV(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) {
|
||||||
|
username := middleware.GetUsername(c)
|
||||||
|
projectUUID := c.Param("uuid")
|
||||||
|
|
||||||
|
var req ProjectExportOptionsRequest
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
project, err := h.projectService.GetByUUID(projectUUID, username)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusNotFound, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
result, err := h.projectService.ListConfigurations(projectUUID, username, "active")
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if len(result.Configs) == 0 {
|
||||||
|
c.JSON(http.StatusBadRequest, gin.H{"error": "no configurations to export"})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
opts := services.ProjectPricingExportOptions{
|
||||||
|
IncludeLOT: req.IncludeLOT,
|
||||||
|
IncludeBOM: req.IncludeBOM,
|
||||||
|
IncludeEstimate: req.IncludeEstimate,
|
||||||
|
IncludeStock: req.IncludeStock,
|
||||||
|
IncludeCompetitor: req.IncludeCompetitor,
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := h.exportService.ProjectToPricingExportData(result.Configs, opts)
|
||||||
|
if err != nil {
|
||||||
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
filename := fmt.Sprintf("%s (%s) pricing.csv", time.Now().Format("2006-01-02"), project.Code)
|
||||||
|
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||||
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||||
|
|
||||||
|
if err := h.exportService.ToPricingCSV(c.Writer, data, opts); err != nil {
|
||||||
|
c.Error(err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ package handlers
|
|||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
"strconv"
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
@@ -66,6 +67,15 @@ func (h *PartnumberBooksHandler) GetItems(c *gin.Context) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB())
|
bookRepo := repository.NewPartnumberBookRepository(h.localDB.DB())
|
||||||
|
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||||
|
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "100"))
|
||||||
|
search := strings.TrimSpace(c.Query("search"))
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if perPage < 1 || perPage > 500 {
|
||||||
|
perPage = 100
|
||||||
|
}
|
||||||
|
|
||||||
// Find local book by server_id
|
// Find local book by server_id
|
||||||
var book localdb.LocalPartnumberBook
|
var book localdb.LocalPartnumberBook
|
||||||
@@ -74,17 +84,23 @@ func (h *PartnumberBooksHandler) GetItems(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
items, err := bookRepo.GetBookItems(book.ID)
|
items, total, err := bookRepo.GetBookItemsPage(book.ID, search, page, perPage)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
c.JSON(http.StatusOK, gin.H{
|
c.JSON(http.StatusOK, gin.H{
|
||||||
"book_id": book.ServerID,
|
"book_id": book.ServerID,
|
||||||
"version": book.Version,
|
"version": book.Version,
|
||||||
"is_active": book.IsActive,
|
"is_active": book.IsActive,
|
||||||
"items": items,
|
"items": items,
|
||||||
"total": len(items),
|
"total": total,
|
||||||
|
"page": page,
|
||||||
|
"per_page": perPage,
|
||||||
|
"search": search,
|
||||||
|
"book_total": bookRepo.CountBookItems(book.ID),
|
||||||
|
"lot_count": bookRepo.CountDistinctLots(book.ID),
|
||||||
|
"primary_count": bookRepo.CountPrimaryItems(book.ID),
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|||||||
97
internal/localdb/configuration_business_fields_test.go
Normal file
97
internal/localdb/configuration_business_fields_test.go
Normal file
@@ -0,0 +1,97 @@
|
|||||||
|
package localdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestConfigurationConvertersPreserveBusinessFields(t *testing.T) {
|
||||||
|
estimateID := uint(11)
|
||||||
|
warehouseID := uint(22)
|
||||||
|
competitorID := uint(33)
|
||||||
|
|
||||||
|
cfg := &models.Configuration{
|
||||||
|
UUID: "cfg-1",
|
||||||
|
OwnerUsername: "tester",
|
||||||
|
Name: "Config",
|
||||||
|
PricelistID: &estimateID,
|
||||||
|
WarehousePricelistID: &warehouseID,
|
||||||
|
CompetitorPricelistID: &competitorID,
|
||||||
|
DisablePriceRefresh: true,
|
||||||
|
OnlyInStock: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
local := ConfigurationToLocal(cfg)
|
||||||
|
if local.WarehousePricelistID == nil || *local.WarehousePricelistID != warehouseID {
|
||||||
|
t.Fatalf("warehouse pricelist lost in ConfigurationToLocal: %+v", local.WarehousePricelistID)
|
||||||
|
}
|
||||||
|
if local.CompetitorPricelistID == nil || *local.CompetitorPricelistID != competitorID {
|
||||||
|
t.Fatalf("competitor pricelist lost in ConfigurationToLocal: %+v", local.CompetitorPricelistID)
|
||||||
|
}
|
||||||
|
if !local.DisablePriceRefresh {
|
||||||
|
t.Fatalf("disable_price_refresh lost in ConfigurationToLocal")
|
||||||
|
}
|
||||||
|
|
||||||
|
back := LocalToConfiguration(local)
|
||||||
|
if back.WarehousePricelistID == nil || *back.WarehousePricelistID != warehouseID {
|
||||||
|
t.Fatalf("warehouse pricelist lost in LocalToConfiguration: %+v", back.WarehousePricelistID)
|
||||||
|
}
|
||||||
|
if back.CompetitorPricelistID == nil || *back.CompetitorPricelistID != competitorID {
|
||||||
|
t.Fatalf("competitor pricelist lost in LocalToConfiguration: %+v", back.CompetitorPricelistID)
|
||||||
|
}
|
||||||
|
if !back.DisablePriceRefresh {
|
||||||
|
t.Fatalf("disable_price_refresh lost in LocalToConfiguration")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestConfigurationSnapshotPreservesBusinessFields(t *testing.T) {
|
||||||
|
estimateID := uint(11)
|
||||||
|
warehouseID := uint(22)
|
||||||
|
competitorID := uint(33)
|
||||||
|
|
||||||
|
cfg := &LocalConfiguration{
|
||||||
|
UUID: "cfg-1",
|
||||||
|
Name: "Config",
|
||||||
|
PricelistID: &estimateID,
|
||||||
|
WarehousePricelistID: &warehouseID,
|
||||||
|
CompetitorPricelistID: &competitorID,
|
||||||
|
DisablePriceRefresh: true,
|
||||||
|
OnlyInStock: true,
|
||||||
|
VendorSpec: VendorSpec{
|
||||||
|
{
|
||||||
|
SortOrder: 10,
|
||||||
|
VendorPartnumber: "PN-1",
|
||||||
|
Quantity: 1,
|
||||||
|
LotMappings: []VendorSpecLotMapping{
|
||||||
|
{LotName: "LOT_A", QuantityPerPN: 2},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := BuildConfigurationSnapshot(cfg)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("BuildConfigurationSnapshot: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
decoded, err := DecodeConfigurationSnapshot(raw)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("DecodeConfigurationSnapshot: %v", err)
|
||||||
|
}
|
||||||
|
if decoded.WarehousePricelistID == nil || *decoded.WarehousePricelistID != warehouseID {
|
||||||
|
t.Fatalf("warehouse pricelist lost in snapshot: %+v", decoded.WarehousePricelistID)
|
||||||
|
}
|
||||||
|
if decoded.CompetitorPricelistID == nil || *decoded.CompetitorPricelistID != competitorID {
|
||||||
|
t.Fatalf("competitor pricelist lost in snapshot: %+v", decoded.CompetitorPricelistID)
|
||||||
|
}
|
||||||
|
if !decoded.DisablePriceRefresh {
|
||||||
|
t.Fatalf("disable_price_refresh lost in snapshot")
|
||||||
|
}
|
||||||
|
if len(decoded.VendorSpec) != 1 || decoded.VendorSpec[0].VendorPartnumber != "PN-1" {
|
||||||
|
t.Fatalf("vendor_spec lost in snapshot: %+v", decoded.VendorSpec)
|
||||||
|
}
|
||||||
|
if len(decoded.VendorSpec[0].LotMappings) != 1 || decoded.VendorSpec[0].LotMappings[0].LotName != "LOT_A" {
|
||||||
|
t.Fatalf("lot mappings lost in snapshot: %+v", decoded.VendorSpec)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -18,28 +18,32 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
local := &LocalConfiguration{
|
local := &LocalConfiguration{
|
||||||
UUID: cfg.UUID,
|
UUID: cfg.UUID,
|
||||||
ProjectUUID: cfg.ProjectUUID,
|
ProjectUUID: cfg.ProjectUUID,
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
Name: cfg.Name,
|
Name: cfg.Name,
|
||||||
Items: items,
|
Items: items,
|
||||||
TotalPrice: cfg.TotalPrice,
|
TotalPrice: cfg.TotalPrice,
|
||||||
CustomPrice: cfg.CustomPrice,
|
CustomPrice: cfg.CustomPrice,
|
||||||
Notes: cfg.Notes,
|
Notes: cfg.Notes,
|
||||||
IsTemplate: cfg.IsTemplate,
|
IsTemplate: cfg.IsTemplate,
|
||||||
ServerCount: cfg.ServerCount,
|
ServerCount: cfg.ServerCount,
|
||||||
ServerModel: cfg.ServerModel,
|
ServerModel: cfg.ServerModel,
|
||||||
SupportCode: cfg.SupportCode,
|
SupportCode: cfg.SupportCode,
|
||||||
Article: cfg.Article,
|
Article: cfg.Article,
|
||||||
PricelistID: cfg.PricelistID,
|
PricelistID: cfg.PricelistID,
|
||||||
OnlyInStock: cfg.OnlyInStock,
|
WarehousePricelistID: cfg.WarehousePricelistID,
|
||||||
Line: cfg.Line,
|
CompetitorPricelistID: cfg.CompetitorPricelistID,
|
||||||
PriceUpdatedAt: cfg.PriceUpdatedAt,
|
VendorSpec: modelVendorSpecToLocal(cfg.VendorSpec),
|
||||||
CreatedAt: cfg.CreatedAt,
|
DisablePriceRefresh: cfg.DisablePriceRefresh,
|
||||||
UpdatedAt: time.Now(),
|
OnlyInStock: cfg.OnlyInStock,
|
||||||
SyncStatus: "pending",
|
Line: cfg.Line,
|
||||||
OriginalUserID: derefUint(cfg.UserID),
|
PriceUpdatedAt: cfg.PriceUpdatedAt,
|
||||||
OriginalUsername: cfg.OwnerUsername,
|
CreatedAt: cfg.CreatedAt,
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
SyncStatus: "pending",
|
||||||
|
OriginalUserID: derefUint(cfg.UserID),
|
||||||
|
OriginalUsername: cfg.OwnerUsername,
|
||||||
}
|
}
|
||||||
|
|
||||||
if local.OriginalUsername == "" && cfg.User != nil {
|
if local.OriginalUsername == "" && cfg.User != nil {
|
||||||
@@ -66,24 +70,28 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
|||||||
}
|
}
|
||||||
|
|
||||||
cfg := &models.Configuration{
|
cfg := &models.Configuration{
|
||||||
UUID: local.UUID,
|
UUID: local.UUID,
|
||||||
OwnerUsername: local.OriginalUsername,
|
OwnerUsername: local.OriginalUsername,
|
||||||
ProjectUUID: local.ProjectUUID,
|
ProjectUUID: local.ProjectUUID,
|
||||||
Name: local.Name,
|
Name: local.Name,
|
||||||
Items: items,
|
Items: items,
|
||||||
TotalPrice: local.TotalPrice,
|
TotalPrice: local.TotalPrice,
|
||||||
CustomPrice: local.CustomPrice,
|
CustomPrice: local.CustomPrice,
|
||||||
Notes: local.Notes,
|
Notes: local.Notes,
|
||||||
IsTemplate: local.IsTemplate,
|
IsTemplate: local.IsTemplate,
|
||||||
ServerCount: local.ServerCount,
|
ServerCount: local.ServerCount,
|
||||||
ServerModel: local.ServerModel,
|
ServerModel: local.ServerModel,
|
||||||
SupportCode: local.SupportCode,
|
SupportCode: local.SupportCode,
|
||||||
Article: local.Article,
|
Article: local.Article,
|
||||||
PricelistID: local.PricelistID,
|
PricelistID: local.PricelistID,
|
||||||
OnlyInStock: local.OnlyInStock,
|
WarehousePricelistID: local.WarehousePricelistID,
|
||||||
Line: local.Line,
|
CompetitorPricelistID: local.CompetitorPricelistID,
|
||||||
PriceUpdatedAt: local.PriceUpdatedAt,
|
VendorSpec: localVendorSpecToModel(local.VendorSpec),
|
||||||
CreatedAt: local.CreatedAt,
|
DisablePriceRefresh: local.DisablePriceRefresh,
|
||||||
|
OnlyInStock: local.OnlyInStock,
|
||||||
|
Line: local.Line,
|
||||||
|
PriceUpdatedAt: local.PriceUpdatedAt,
|
||||||
|
CreatedAt: local.CreatedAt,
|
||||||
}
|
}
|
||||||
|
|
||||||
if local.ServerID != nil {
|
if local.ServerID != nil {
|
||||||
@@ -107,6 +115,88 @@ func derefUint(v *uint) uint {
|
|||||||
return *v
|
return *v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func modelVendorSpecToLocal(spec models.VendorSpec) VendorSpec {
|
||||||
|
if len(spec) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(VendorSpec, 0, len(spec))
|
||||||
|
for _, item := range spec {
|
||||||
|
row := VendorSpecItem{
|
||||||
|
SortOrder: item.SortOrder,
|
||||||
|
VendorPartnumber: item.VendorPartnumber,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
Description: item.Description,
|
||||||
|
UnitPrice: item.UnitPrice,
|
||||||
|
TotalPrice: item.TotalPrice,
|
||||||
|
ResolvedLotName: item.ResolvedLotName,
|
||||||
|
ResolutionSource: item.ResolutionSource,
|
||||||
|
ManualLotSuggestion: item.ManualLotSuggestion,
|
||||||
|
LotQtyPerPN: item.LotQtyPerPN,
|
||||||
|
}
|
||||||
|
if len(item.LotAllocations) > 0 {
|
||||||
|
row.LotAllocations = make([]VendorSpecLotAllocation, 0, len(item.LotAllocations))
|
||||||
|
for _, alloc := range item.LotAllocations {
|
||||||
|
row.LotAllocations = append(row.LotAllocations, VendorSpecLotAllocation{
|
||||||
|
LotName: alloc.LotName,
|
||||||
|
Quantity: alloc.Quantity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(item.LotMappings) > 0 {
|
||||||
|
row.LotMappings = make([]VendorSpecLotMapping, 0, len(item.LotMappings))
|
||||||
|
for _, mapping := range item.LotMappings {
|
||||||
|
row.LotMappings = append(row.LotMappings, VendorSpecLotMapping{
|
||||||
|
LotName: mapping.LotName,
|
||||||
|
QuantityPerPN: mapping.QuantityPerPN,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func localVendorSpecToModel(spec VendorSpec) models.VendorSpec {
|
||||||
|
if len(spec) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make(models.VendorSpec, 0, len(spec))
|
||||||
|
for _, item := range spec {
|
||||||
|
row := models.VendorSpecItem{
|
||||||
|
SortOrder: item.SortOrder,
|
||||||
|
VendorPartnumber: item.VendorPartnumber,
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
Description: item.Description,
|
||||||
|
UnitPrice: item.UnitPrice,
|
||||||
|
TotalPrice: item.TotalPrice,
|
||||||
|
ResolvedLotName: item.ResolvedLotName,
|
||||||
|
ResolutionSource: item.ResolutionSource,
|
||||||
|
ManualLotSuggestion: item.ManualLotSuggestion,
|
||||||
|
LotQtyPerPN: item.LotQtyPerPN,
|
||||||
|
}
|
||||||
|
if len(item.LotAllocations) > 0 {
|
||||||
|
row.LotAllocations = make([]models.VendorSpecLotAllocation, 0, len(item.LotAllocations))
|
||||||
|
for _, alloc := range item.LotAllocations {
|
||||||
|
row.LotAllocations = append(row.LotAllocations, models.VendorSpecLotAllocation{
|
||||||
|
LotName: alloc.LotName,
|
||||||
|
Quantity: alloc.Quantity,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if len(item.LotMappings) > 0 {
|
||||||
|
row.LotMappings = make([]models.VendorSpecLotMapping, 0, len(item.LotMappings))
|
||||||
|
for _, mapping := range item.LotMappings {
|
||||||
|
row.LotMappings = append(row.LotMappings, models.VendorSpecLotMapping{
|
||||||
|
LotName: mapping.LotName,
|
||||||
|
QuantityPerPN: mapping.QuantityPerPN,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
out = append(out, row)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
func ProjectToLocal(project *models.Project) *LocalProject {
|
func ProjectToLocal(project *models.Project) *LocalProject {
|
||||||
local := &LocalProject{
|
local := &LocalProject{
|
||||||
UUID: project.UUID,
|
UUID: project.UUID,
|
||||||
|
|||||||
@@ -102,8 +102,9 @@ type LocalConfiguration struct {
|
|||||||
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
|
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
|
||||||
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
||||||
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
||||||
|
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
|
||||||
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
||||||
VendorSpec VendorSpec `gorm:"type:text" json:"vendor_spec,omitempty"`
|
VendorSpec VendorSpec `gorm:"type:text" json:"vendor_spec,omitempty"`
|
||||||
Line int `gorm:"column:line_no;index" json:"line"`
|
Line int `gorm:"column:line_no;index" json:"line"`
|
||||||
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
@@ -274,18 +275,18 @@ func (LocalPartnumberBookItem) TableName() string {
|
|||||||
|
|
||||||
// VendorSpecItem represents a single row in a vendor BOM specification
|
// VendorSpecItem represents a single row in a vendor BOM specification
|
||||||
type VendorSpecItem struct {
|
type VendorSpecItem struct {
|
||||||
SortOrder int `json:"sort_order"`
|
SortOrder int `json:"sort_order"`
|
||||||
VendorPartnumber string `json:"vendor_partnumber"`
|
VendorPartnumber string `json:"vendor_partnumber"`
|
||||||
Quantity int `json:"quantity"`
|
Quantity int `json:"quantity"`
|
||||||
Description string `json:"description,omitempty"`
|
Description string `json:"description,omitempty"`
|
||||||
UnitPrice *float64 `json:"unit_price,omitempty"`
|
UnitPrice *float64 `json:"unit_price,omitempty"`
|
||||||
TotalPrice *float64 `json:"total_price,omitempty"`
|
TotalPrice *float64 `json:"total_price,omitempty"`
|
||||||
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"`
|
LotQtyPerPN int `json:"lot_qty_per_pn,omitempty"`
|
||||||
LotAllocations []VendorSpecLotAllocation `json:"lot_allocations,omitempty"`
|
LotAllocations []VendorSpecLotAllocation `json:"lot_allocations,omitempty"`
|
||||||
LotMappings []VendorSpecLotMapping `json:"lot_mappings,omitempty"`
|
LotMappings []VendorSpecLotMapping `json:"lot_mappings,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type VendorSpecLotAllocation struct {
|
type VendorSpecLotAllocation struct {
|
||||||
|
|||||||
@@ -10,32 +10,36 @@ import (
|
|||||||
// BuildConfigurationSnapshot serializes the full local configuration state.
|
// BuildConfigurationSnapshot serializes the full local configuration state.
|
||||||
func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
|
func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
|
||||||
snapshot := map[string]interface{}{
|
snapshot := map[string]interface{}{
|
||||||
"id": localCfg.ID,
|
"id": localCfg.ID,
|
||||||
"uuid": localCfg.UUID,
|
"uuid": localCfg.UUID,
|
||||||
"server_id": localCfg.ServerID,
|
"server_id": localCfg.ServerID,
|
||||||
"project_uuid": localCfg.ProjectUUID,
|
"project_uuid": localCfg.ProjectUUID,
|
||||||
"current_version_id": localCfg.CurrentVersionID,
|
"current_version_id": localCfg.CurrentVersionID,
|
||||||
"is_active": localCfg.IsActive,
|
"is_active": localCfg.IsActive,
|
||||||
"name": localCfg.Name,
|
"name": localCfg.Name,
|
||||||
"items": localCfg.Items,
|
"items": localCfg.Items,
|
||||||
"total_price": localCfg.TotalPrice,
|
"total_price": localCfg.TotalPrice,
|
||||||
"custom_price": localCfg.CustomPrice,
|
"custom_price": localCfg.CustomPrice,
|
||||||
"notes": localCfg.Notes,
|
"notes": localCfg.Notes,
|
||||||
"is_template": localCfg.IsTemplate,
|
"is_template": localCfg.IsTemplate,
|
||||||
"server_count": localCfg.ServerCount,
|
"server_count": localCfg.ServerCount,
|
||||||
"server_model": localCfg.ServerModel,
|
"server_model": localCfg.ServerModel,
|
||||||
"support_code": localCfg.SupportCode,
|
"support_code": localCfg.SupportCode,
|
||||||
"article": localCfg.Article,
|
"article": localCfg.Article,
|
||||||
"pricelist_id": localCfg.PricelistID,
|
"pricelist_id": localCfg.PricelistID,
|
||||||
"only_in_stock": localCfg.OnlyInStock,
|
"warehouse_pricelist_id": localCfg.WarehousePricelistID,
|
||||||
"line": localCfg.Line,
|
"competitor_pricelist_id": localCfg.CompetitorPricelistID,
|
||||||
"price_updated_at": localCfg.PriceUpdatedAt,
|
"disable_price_refresh": localCfg.DisablePriceRefresh,
|
||||||
"created_at": localCfg.CreatedAt,
|
"only_in_stock": localCfg.OnlyInStock,
|
||||||
"updated_at": localCfg.UpdatedAt,
|
"vendor_spec": localCfg.VendorSpec,
|
||||||
"synced_at": localCfg.SyncedAt,
|
"line": localCfg.Line,
|
||||||
"sync_status": localCfg.SyncStatus,
|
"price_updated_at": localCfg.PriceUpdatedAt,
|
||||||
"original_user_id": localCfg.OriginalUserID,
|
"created_at": localCfg.CreatedAt,
|
||||||
"original_username": localCfg.OriginalUsername,
|
"updated_at": localCfg.UpdatedAt,
|
||||||
|
"synced_at": localCfg.SyncedAt,
|
||||||
|
"sync_status": localCfg.SyncStatus,
|
||||||
|
"original_user_id": localCfg.OriginalUserID,
|
||||||
|
"original_username": localCfg.OriginalUsername,
|
||||||
}
|
}
|
||||||
|
|
||||||
data, err := json.Marshal(snapshot)
|
data, err := json.Marshal(snapshot)
|
||||||
@@ -48,24 +52,28 @@ func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
|
|||||||
// DecodeConfigurationSnapshot returns editable fields from one saved snapshot.
|
// DecodeConfigurationSnapshot returns editable fields from one saved snapshot.
|
||||||
func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
||||||
var snapshot struct {
|
var snapshot struct {
|
||||||
ProjectUUID *string `json:"project_uuid"`
|
ProjectUUID *string `json:"project_uuid"`
|
||||||
IsActive *bool `json:"is_active"`
|
IsActive *bool `json:"is_active"`
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Items LocalConfigItems `json:"items"`
|
Items LocalConfigItems `json:"items"`
|
||||||
TotalPrice *float64 `json:"total_price"`
|
TotalPrice *float64 `json:"total_price"`
|
||||||
CustomPrice *float64 `json:"custom_price"`
|
CustomPrice *float64 `json:"custom_price"`
|
||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
IsTemplate bool `json:"is_template"`
|
IsTemplate bool `json:"is_template"`
|
||||||
ServerCount int `json:"server_count"`
|
ServerCount int `json:"server_count"`
|
||||||
ServerModel string `json:"server_model"`
|
ServerModel string `json:"server_model"`
|
||||||
SupportCode string `json:"support_code"`
|
SupportCode string `json:"support_code"`
|
||||||
Article string `json:"article"`
|
Article string `json:"article"`
|
||||||
PricelistID *uint `json:"pricelist_id"`
|
PricelistID *uint `json:"pricelist_id"`
|
||||||
OnlyInStock bool `json:"only_in_stock"`
|
WarehousePricelistID *uint `json:"warehouse_pricelist_id"`
|
||||||
Line int `json:"line"`
|
CompetitorPricelistID *uint `json:"competitor_pricelist_id"`
|
||||||
PriceUpdatedAt *time.Time `json:"price_updated_at"`
|
DisablePriceRefresh bool `json:"disable_price_refresh"`
|
||||||
OriginalUserID uint `json:"original_user_id"`
|
OnlyInStock bool `json:"only_in_stock"`
|
||||||
OriginalUsername string `json:"original_username"`
|
VendorSpec VendorSpec `json:"vendor_spec"`
|
||||||
|
Line int `json:"line"`
|
||||||
|
PriceUpdatedAt *time.Time `json:"price_updated_at"`
|
||||||
|
OriginalUserID uint `json:"original_user_id"`
|
||||||
|
OriginalUsername string `json:"original_username"`
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := json.Unmarshal([]byte(data), &snapshot); err != nil {
|
if err := json.Unmarshal([]byte(data), &snapshot); err != nil {
|
||||||
@@ -78,24 +86,28 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &LocalConfiguration{
|
return &LocalConfiguration{
|
||||||
IsActive: isActive,
|
IsActive: isActive,
|
||||||
ProjectUUID: snapshot.ProjectUUID,
|
ProjectUUID: snapshot.ProjectUUID,
|
||||||
Name: snapshot.Name,
|
Name: snapshot.Name,
|
||||||
Items: snapshot.Items,
|
Items: snapshot.Items,
|
||||||
TotalPrice: snapshot.TotalPrice,
|
TotalPrice: snapshot.TotalPrice,
|
||||||
CustomPrice: snapshot.CustomPrice,
|
CustomPrice: snapshot.CustomPrice,
|
||||||
Notes: snapshot.Notes,
|
Notes: snapshot.Notes,
|
||||||
IsTemplate: snapshot.IsTemplate,
|
IsTemplate: snapshot.IsTemplate,
|
||||||
ServerCount: snapshot.ServerCount,
|
ServerCount: snapshot.ServerCount,
|
||||||
ServerModel: snapshot.ServerModel,
|
ServerModel: snapshot.ServerModel,
|
||||||
SupportCode: snapshot.SupportCode,
|
SupportCode: snapshot.SupportCode,
|
||||||
Article: snapshot.Article,
|
Article: snapshot.Article,
|
||||||
PricelistID: snapshot.PricelistID,
|
PricelistID: snapshot.PricelistID,
|
||||||
OnlyInStock: snapshot.OnlyInStock,
|
WarehousePricelistID: snapshot.WarehousePricelistID,
|
||||||
Line: snapshot.Line,
|
CompetitorPricelistID: snapshot.CompetitorPricelistID,
|
||||||
PriceUpdatedAt: snapshot.PriceUpdatedAt,
|
DisablePriceRefresh: snapshot.DisablePriceRefresh,
|
||||||
OriginalUserID: snapshot.OriginalUserID,
|
OnlyInStock: snapshot.OnlyInStock,
|
||||||
OriginalUsername: snapshot.OriginalUsername,
|
VendorSpec: snapshot.VendorSpec,
|
||||||
|
Line: snapshot.Line,
|
||||||
|
PriceUpdatedAt: snapshot.PriceUpdatedAt,
|
||||||
|
OriginalUserID: snapshot.OriginalUserID,
|
||||||
|
OriginalUsername: snapshot.OriginalUsername,
|
||||||
}, nil
|
}, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,57 @@ func (c ConfigItems) Total() float64 {
|
|||||||
return total
|
return total
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type VendorSpecLotAllocation struct {
|
||||||
|
LotName string `json:"lot_name"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VendorSpecLotMapping struct {
|
||||||
|
LotName string `json:"lot_name"`
|
||||||
|
QuantityPerPN int `json:"quantity_per_pn"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VendorSpecItem struct {
|
||||||
|
SortOrder int `json:"sort_order"`
|
||||||
|
VendorPartnumber string `json:"vendor_partnumber"`
|
||||||
|
Quantity int `json:"quantity"`
|
||||||
|
Description string `json:"description,omitempty"`
|
||||||
|
UnitPrice *float64 `json:"unit_price,omitempty"`
|
||||||
|
TotalPrice *float64 `json:"total_price,omitempty"`
|
||||||
|
ResolvedLotName string `json:"resolved_lot_name,omitempty"`
|
||||||
|
ResolutionSource string `json:"resolution_source,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 VendorSpec []VendorSpecItem
|
||||||
|
|
||||||
|
func (v VendorSpec) Value() (driver.Value, error) {
|
||||||
|
if v == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
return json.Marshal(v)
|
||||||
|
}
|
||||||
|
|
||||||
|
func (v *VendorSpec) Scan(value interface{}) error {
|
||||||
|
if value == nil {
|
||||||
|
*v = nil
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
var bytes []byte
|
||||||
|
switch val := value.(type) {
|
||||||
|
case []byte:
|
||||||
|
bytes = val
|
||||||
|
case string:
|
||||||
|
bytes = []byte(val)
|
||||||
|
default:
|
||||||
|
return errors.New("type assertion failed for VendorSpec")
|
||||||
|
}
|
||||||
|
return json.Unmarshal(bytes, v)
|
||||||
|
}
|
||||||
|
|
||||||
type Configuration struct {
|
type Configuration struct {
|
||||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||||
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
|
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
|
||||||
@@ -59,6 +110,7 @@ type Configuration struct {
|
|||||||
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
|
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
|
||||||
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
||||||
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
||||||
|
VendorSpec VendorSpec `gorm:"type:json" json:"vendor_spec,omitempty"`
|
||||||
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
|
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
|
||||||
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
||||||
Line int `gorm:"column:line_no;index" json:"line"`
|
Line int `gorm:"column:line_no;index" json:"line"`
|
||||||
|
|||||||
@@ -31,6 +31,35 @@ func (r *PartnumberBookRepository) GetBookItems(bookID uint) ([]localdb.LocalPar
|
|||||||
return items, err
|
return items, err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetBookItemsPage returns items for the given local book ID with optional search and pagination.
|
||||||
|
func (r *PartnumberBookRepository) GetBookItemsPage(bookID uint, search string, page, perPage int) ([]localdb.LocalPartnumberBookItem, int64, error) {
|
||||||
|
if page < 1 {
|
||||||
|
page = 1
|
||||||
|
}
|
||||||
|
if perPage < 1 {
|
||||||
|
perPage = 100
|
||||||
|
}
|
||||||
|
|
||||||
|
query := r.db.Model(&localdb.LocalPartnumberBookItem{}).Where("book_id = ?", bookID)
|
||||||
|
trimmedSearch := "%" + search + "%"
|
||||||
|
if search != "" {
|
||||||
|
query = query.Where("partnumber LIKE ? OR lot_name LIKE ?", trimmedSearch, trimmedSearch)
|
||||||
|
}
|
||||||
|
|
||||||
|
var total int64
|
||||||
|
if err := query.Count(&total).Error; err != nil {
|
||||||
|
return nil, 0, err
|
||||||
|
}
|
||||||
|
|
||||||
|
var items []localdb.LocalPartnumberBookItem
|
||||||
|
err := query.
|
||||||
|
Order("partnumber ASC, lot_name ASC, id ASC").
|
||||||
|
Offset((page - 1) * perPage).
|
||||||
|
Limit(perPage).
|
||||||
|
Find(&items).Error
|
||||||
|
return items, total, err
|
||||||
|
}
|
||||||
|
|
||||||
// FindLotByPartnumber looks up a partnumber in the active book and returns the matching items.
|
// FindLotByPartnumber looks up a partnumber in the active book and returns the matching items.
|
||||||
func (r *PartnumberBookRepository) FindLotByPartnumber(bookID uint, partnumber string) ([]localdb.LocalPartnumberBookItem, error) {
|
func (r *PartnumberBookRepository) FindLotByPartnumber(bookID uint, partnumber string) ([]localdb.LocalPartnumberBookItem, error) {
|
||||||
var items []localdb.LocalPartnumberBookItem
|
var items []localdb.LocalPartnumberBookItem
|
||||||
@@ -64,3 +93,20 @@ func (r *PartnumberBookRepository) CountBookItems(bookID uint) int64 {
|
|||||||
r.db.Model(&localdb.LocalPartnumberBookItem{}).Where("book_id = ?", bookID).Count(&count)
|
r.db.Model(&localdb.LocalPartnumberBookItem{}).Where("book_id = ?", bookID).Count(&count)
|
||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (r *PartnumberBookRepository) CountDistinctLots(bookID uint) int64 {
|
||||||
|
var count int64
|
||||||
|
r.db.Model(&localdb.LocalPartnumberBookItem{}).
|
||||||
|
Where("book_id = ?", bookID).
|
||||||
|
Distinct("lot_name").
|
||||||
|
Count(&count)
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|
||||||
|
func (r *PartnumberBookRepository) CountPrimaryItems(bookID uint) int64 {
|
||||||
|
var count int64
|
||||||
|
r.db.Model(&localdb.LocalPartnumberBookItem{}).
|
||||||
|
Where("book_id = ? AND is_primary_pn = ?", bookID, true).
|
||||||
|
Count(&count)
|
||||||
|
return count
|
||||||
|
}
|
||||||
|
|||||||
@@ -45,18 +45,21 @@ func NewConfigurationService(
|
|||||||
}
|
}
|
||||||
|
|
||||||
type CreateConfigRequest struct {
|
type CreateConfigRequest struct {
|
||||||
Name string `json:"name"`
|
Name string `json:"name"`
|
||||||
Items models.ConfigItems `json:"items"`
|
Items models.ConfigItems `json:"items"`
|
||||||
ProjectUUID *string `json:"project_uuid,omitempty"`
|
ProjectUUID *string `json:"project_uuid,omitempty"`
|
||||||
CustomPrice *float64 `json:"custom_price"`
|
CustomPrice *float64 `json:"custom_price"`
|
||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
IsTemplate bool `json:"is_template"`
|
IsTemplate bool `json:"is_template"`
|
||||||
ServerCount int `json:"server_count"`
|
ServerCount int `json:"server_count"`
|
||||||
ServerModel string `json:"server_model,omitempty"`
|
ServerModel string `json:"server_model,omitempty"`
|
||||||
SupportCode string `json:"support_code,omitempty"`
|
SupportCode string `json:"support_code,omitempty"`
|
||||||
Article string `json:"article,omitempty"`
|
Article string `json:"article,omitempty"`
|
||||||
PricelistID *uint `json:"pricelist_id,omitempty"`
|
PricelistID *uint `json:"pricelist_id,omitempty"`
|
||||||
OnlyInStock bool `json:"only_in_stock"`
|
WarehousePricelistID *uint `json:"warehouse_pricelist_id,omitempty"`
|
||||||
|
CompetitorPricelistID *uint `json:"competitor_pricelist_id,omitempty"`
|
||||||
|
DisablePriceRefresh bool `json:"disable_price_refresh"`
|
||||||
|
OnlyInStock bool `json:"only_in_stock"`
|
||||||
}
|
}
|
||||||
|
|
||||||
type ArticlePreviewRequest struct {
|
type ArticlePreviewRequest struct {
|
||||||
@@ -84,21 +87,24 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
|
|||||||
}
|
}
|
||||||
|
|
||||||
config := &models.Configuration{
|
config := &models.Configuration{
|
||||||
UUID: uuid.New().String(),
|
UUID: uuid.New().String(),
|
||||||
OwnerUsername: ownerUsername,
|
OwnerUsername: ownerUsername,
|
||||||
ProjectUUID: projectUUID,
|
ProjectUUID: projectUUID,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Items: req.Items,
|
Items: req.Items,
|
||||||
TotalPrice: &total,
|
TotalPrice: &total,
|
||||||
CustomPrice: req.CustomPrice,
|
CustomPrice: req.CustomPrice,
|
||||||
Notes: req.Notes,
|
Notes: req.Notes,
|
||||||
IsTemplate: req.IsTemplate,
|
IsTemplate: req.IsTemplate,
|
||||||
ServerCount: req.ServerCount,
|
ServerCount: req.ServerCount,
|
||||||
ServerModel: req.ServerModel,
|
ServerModel: req.ServerModel,
|
||||||
SupportCode: req.SupportCode,
|
SupportCode: req.SupportCode,
|
||||||
Article: req.Article,
|
Article: req.Article,
|
||||||
PricelistID: pricelistID,
|
PricelistID: pricelistID,
|
||||||
OnlyInStock: req.OnlyInStock,
|
WarehousePricelistID: req.WarehousePricelistID,
|
||||||
|
CompetitorPricelistID: req.CompetitorPricelistID,
|
||||||
|
DisablePriceRefresh: req.DisablePriceRefresh,
|
||||||
|
OnlyInStock: req.OnlyInStock,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.configRepo.Create(config); err != nil {
|
if err := s.configRepo.Create(config); err != nil {
|
||||||
@@ -163,6 +169,9 @@ func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *Cr
|
|||||||
config.SupportCode = req.SupportCode
|
config.SupportCode = req.SupportCode
|
||||||
config.Article = req.Article
|
config.Article = req.Article
|
||||||
config.PricelistID = pricelistID
|
config.PricelistID = pricelistID
|
||||||
|
config.WarehousePricelistID = req.WarehousePricelistID
|
||||||
|
config.CompetitorPricelistID = req.CompetitorPricelistID
|
||||||
|
config.DisablePriceRefresh = req.DisablePriceRefresh
|
||||||
config.OnlyInStock = req.OnlyInStock
|
config.OnlyInStock = req.OnlyInStock
|
||||||
|
|
||||||
if err := s.configRepo.Update(config); err != nil {
|
if err := s.configRepo.Update(config); err != nil {
|
||||||
@@ -230,18 +239,24 @@ func (s *ConfigurationService) CloneToProject(configUUID string, ownerUsername s
|
|||||||
}
|
}
|
||||||
|
|
||||||
clone := &models.Configuration{
|
clone := &models.Configuration{
|
||||||
UUID: uuid.New().String(),
|
UUID: uuid.New().String(),
|
||||||
OwnerUsername: ownerUsername,
|
OwnerUsername: ownerUsername,
|
||||||
ProjectUUID: resolvedProjectUUID,
|
ProjectUUID: resolvedProjectUUID,
|
||||||
Name: newName,
|
Name: newName,
|
||||||
Items: original.Items,
|
Items: original.Items,
|
||||||
TotalPrice: &total,
|
TotalPrice: &total,
|
||||||
CustomPrice: original.CustomPrice,
|
CustomPrice: original.CustomPrice,
|
||||||
Notes: original.Notes,
|
Notes: original.Notes,
|
||||||
IsTemplate: false, // Clone is never a template
|
IsTemplate: false, // Clone is never a template
|
||||||
ServerCount: original.ServerCount,
|
ServerCount: original.ServerCount,
|
||||||
PricelistID: original.PricelistID,
|
ServerModel: original.ServerModel,
|
||||||
OnlyInStock: original.OnlyInStock,
|
SupportCode: original.SupportCode,
|
||||||
|
Article: original.Article,
|
||||||
|
PricelistID: original.PricelistID,
|
||||||
|
WarehousePricelistID: original.WarehousePricelistID,
|
||||||
|
CompetitorPricelistID: original.CompetitorPricelistID,
|
||||||
|
DisablePriceRefresh: original.DisablePriceRefresh,
|
||||||
|
OnlyInStock: original.OnlyInStock,
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := s.configRepo.Create(clone); err != nil {
|
if err := s.configRepo.Create(clone); err != nil {
|
||||||
@@ -314,7 +329,13 @@ func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigReques
|
|||||||
config.Notes = req.Notes
|
config.Notes = req.Notes
|
||||||
config.IsTemplate = req.IsTemplate
|
config.IsTemplate = req.IsTemplate
|
||||||
config.ServerCount = req.ServerCount
|
config.ServerCount = req.ServerCount
|
||||||
|
config.ServerModel = req.ServerModel
|
||||||
|
config.SupportCode = req.SupportCode
|
||||||
|
config.Article = req.Article
|
||||||
config.PricelistID = pricelistID
|
config.PricelistID = pricelistID
|
||||||
|
config.WarehousePricelistID = req.WarehousePricelistID
|
||||||
|
config.CompetitorPricelistID = req.CompetitorPricelistID
|
||||||
|
config.DisablePriceRefresh = req.DisablePriceRefresh
|
||||||
config.OnlyInStock = req.OnlyInStock
|
config.OnlyInStock = req.OnlyInStock
|
||||||
|
|
||||||
if err := s.configRepo.Update(config); err != nil {
|
if err := s.configRepo.Update(config); err != nil {
|
||||||
|
|||||||
@@ -55,6 +55,38 @@ type ProjectExportData struct {
|
|||||||
CreatedAt time.Time
|
CreatedAt time.Time
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type ProjectPricingExportOptions struct {
|
||||||
|
IncludeLOT bool `json:"include_lot"`
|
||||||
|
IncludeBOM bool `json:"include_bom"`
|
||||||
|
IncludeEstimate bool `json:"include_estimate"`
|
||||||
|
IncludeStock bool `json:"include_stock"`
|
||||||
|
IncludeCompetitor bool `json:"include_competitor"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectPricingExportData struct {
|
||||||
|
Configs []ProjectPricingExportConfig
|
||||||
|
CreatedAt time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectPricingExportConfig struct {
|
||||||
|
Name string
|
||||||
|
Article string
|
||||||
|
Line int
|
||||||
|
ServerCount int
|
||||||
|
Rows []ProjectPricingExportRow
|
||||||
|
}
|
||||||
|
|
||||||
|
type ProjectPricingExportRow struct {
|
||||||
|
LotDisplay string
|
||||||
|
VendorPN string
|
||||||
|
Description string
|
||||||
|
Quantity int
|
||||||
|
BOMTotal *float64
|
||||||
|
Estimate *float64
|
||||||
|
Stock *float64
|
||||||
|
Competitor *float64
|
||||||
|
}
|
||||||
|
|
||||||
// ToCSV writes project export data in the new structured CSV format.
|
// ToCSV writes project export data in the new structured CSV format.
|
||||||
//
|
//
|
||||||
// Format:
|
// Format:
|
||||||
@@ -168,6 +200,80 @@ func (s *ExportService) ToCSVBytes(data *ProjectExportData) ([]byte, error) {
|
|||||||
return buf.Bytes(), nil
|
return buf.Bytes(), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ExportService) ProjectToPricingExportData(configs []models.Configuration, opts ProjectPricingExportOptions) (*ProjectPricingExportData, error) {
|
||||||
|
sortedConfigs := make([]models.Configuration, len(configs))
|
||||||
|
copy(sortedConfigs, configs)
|
||||||
|
sort.Slice(sortedConfigs, func(i, j int) bool {
|
||||||
|
leftLine := sortedConfigs[i].Line
|
||||||
|
rightLine := sortedConfigs[j].Line
|
||||||
|
|
||||||
|
if leftLine <= 0 {
|
||||||
|
leftLine = int(^uint(0) >> 1)
|
||||||
|
}
|
||||||
|
if rightLine <= 0 {
|
||||||
|
rightLine = int(^uint(0) >> 1)
|
||||||
|
}
|
||||||
|
if leftLine != rightLine {
|
||||||
|
return leftLine < rightLine
|
||||||
|
}
|
||||||
|
if !sortedConfigs[i].CreatedAt.Equal(sortedConfigs[j].CreatedAt) {
|
||||||
|
return sortedConfigs[i].CreatedAt.After(sortedConfigs[j].CreatedAt)
|
||||||
|
}
|
||||||
|
return sortedConfigs[i].UUID > sortedConfigs[j].UUID
|
||||||
|
})
|
||||||
|
|
||||||
|
blocks := make([]ProjectPricingExportConfig, 0, len(sortedConfigs))
|
||||||
|
for i := range sortedConfigs {
|
||||||
|
block, err := s.buildPricingExportBlock(&sortedConfigs[i], opts)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
blocks = append(blocks, block)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &ProjectPricingExportData{
|
||||||
|
Configs: blocks,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExportService) ToPricingCSV(w io.Writer, data *ProjectPricingExportData, opts ProjectPricingExportOptions) error {
|
||||||
|
if _, err := w.Write([]byte{0xEF, 0xBB, 0xBF}); err != nil {
|
||||||
|
return fmt.Errorf("failed to write BOM: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
csvWriter := csv.NewWriter(w)
|
||||||
|
csvWriter.Comma = ';'
|
||||||
|
defer csvWriter.Flush()
|
||||||
|
|
||||||
|
headers := pricingCSVHeaders(opts)
|
||||||
|
if err := csvWriter.Write(headers); err != nil {
|
||||||
|
return fmt.Errorf("failed to write pricing header: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for idx, cfg := range data.Configs {
|
||||||
|
if err := csvWriter.Write(pricingConfigSummaryRow(cfg, opts)); err != nil {
|
||||||
|
return fmt.Errorf("failed to write config summary row: %w", err)
|
||||||
|
}
|
||||||
|
for _, row := range cfg.Rows {
|
||||||
|
if err := csvWriter.Write(pricingCSVRow(row, opts)); err != nil {
|
||||||
|
return fmt.Errorf("failed to write pricing row: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if idx < len(data.Configs)-1 {
|
||||||
|
if err := csvWriter.Write([]string{}); err != nil {
|
||||||
|
return fmt.Errorf("failed to write separator row: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
csvWriter.Flush()
|
||||||
|
if err := csvWriter.Error(); err != nil {
|
||||||
|
return fmt.Errorf("csv writer error: %w", err)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
// ConfigToExportData converts a single configuration into ProjectExportData.
|
// ConfigToExportData converts a single configuration into ProjectExportData.
|
||||||
func (s *ExportService) ConfigToExportData(cfg *models.Configuration) *ProjectExportData {
|
func (s *ExportService) ConfigToExportData(cfg *models.Configuration) *ProjectExportData {
|
||||||
block := s.buildExportBlock(cfg)
|
block := s.buildExportBlock(cfg)
|
||||||
@@ -247,6 +353,99 @@ func (s *ExportService) buildExportBlock(cfg *models.Configuration) ConfigExport
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts ProjectPricingExportOptions) (ProjectPricingExportConfig, error) {
|
||||||
|
block := ProjectPricingExportConfig{
|
||||||
|
Name: cfg.Name,
|
||||||
|
Article: cfg.Article,
|
||||||
|
Line: cfg.Line,
|
||||||
|
ServerCount: exportPositiveInt(cfg.ServerCount, 1),
|
||||||
|
Rows: make([]ProjectPricingExportRow, 0),
|
||||||
|
}
|
||||||
|
if s.localDB == nil {
|
||||||
|
for _, item := range cfg.Items {
|
||||||
|
block.Rows = append(block.Rows, ProjectPricingExportRow{
|
||||||
|
LotDisplay: item.LotName,
|
||||||
|
VendorPN: "—",
|
||||||
|
Quantity: item.Quantity,
|
||||||
|
Estimate: floatPtr(item.UnitPrice * float64(item.Quantity)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return block, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
localCfg, err := s.localDB.GetConfigurationByUUID(cfg.UUID)
|
||||||
|
if err != nil {
|
||||||
|
localCfg = nil
|
||||||
|
}
|
||||||
|
|
||||||
|
priceMap := s.resolvePricingTotals(cfg, localCfg, opts)
|
||||||
|
componentDescriptions := s.resolveLotDescriptions(cfg, localCfg)
|
||||||
|
if opts.IncludeBOM && localCfg != nil && len(localCfg.VendorSpec) > 0 {
|
||||||
|
coveredLots := make(map[string]struct{})
|
||||||
|
for _, row := range localCfg.VendorSpec {
|
||||||
|
rowMappings := normalizeLotMappings(row.LotMappings)
|
||||||
|
for _, mapping := range rowMappings {
|
||||||
|
coveredLots[mapping.LotName] = struct{}{}
|
||||||
|
}
|
||||||
|
|
||||||
|
description := strings.TrimSpace(row.Description)
|
||||||
|
if description == "" && len(rowMappings) > 0 {
|
||||||
|
description = componentDescriptions[rowMappings[0].LotName]
|
||||||
|
}
|
||||||
|
|
||||||
|
pricingRow := ProjectPricingExportRow{
|
||||||
|
LotDisplay: formatLotDisplay(rowMappings),
|
||||||
|
VendorPN: row.VendorPartnumber,
|
||||||
|
Description: description,
|
||||||
|
Quantity: exportPositiveInt(row.Quantity, 1),
|
||||||
|
BOMTotal: vendorRowTotal(row),
|
||||||
|
Estimate: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Estimate }),
|
||||||
|
Stock: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Stock }),
|
||||||
|
Competitor: computeMappingTotal(priceMap, rowMappings, row.Quantity, func(p pricingLevels) *float64 { return p.Competitor }),
|
||||||
|
}
|
||||||
|
block.Rows = append(block.Rows, pricingRow)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range cfg.Items {
|
||||||
|
if item.LotName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := coveredLots[item.LotName]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
estimate := estimateOnlyTotal(priceMap[item.LotName].Estimate, item.UnitPrice, item.Quantity)
|
||||||
|
block.Rows = append(block.Rows, ProjectPricingExportRow{
|
||||||
|
LotDisplay: item.LotName,
|
||||||
|
VendorPN: "—",
|
||||||
|
Description: componentDescriptions[item.LotName],
|
||||||
|
Quantity: exportPositiveInt(item.Quantity, 1),
|
||||||
|
Estimate: estimate,
|
||||||
|
Stock: totalForUnitPrice(priceMap[item.LotName].Stock, item.Quantity),
|
||||||
|
Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return block, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, item := range cfg.Items {
|
||||||
|
if item.LotName == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
estimate := estimateOnlyTotal(priceMap[item.LotName].Estimate, item.UnitPrice, item.Quantity)
|
||||||
|
block.Rows = append(block.Rows, ProjectPricingExportRow{
|
||||||
|
LotDisplay: item.LotName,
|
||||||
|
VendorPN: "—",
|
||||||
|
Description: componentDescriptions[item.LotName],
|
||||||
|
Quantity: exportPositiveInt(item.Quantity, 1),
|
||||||
|
Estimate: estimate,
|
||||||
|
Stock: totalForUnitPrice(priceMap[item.LotName].Stock, item.Quantity),
|
||||||
|
Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
return block, nil
|
||||||
|
}
|
||||||
|
|
||||||
// resolveCategories returns lot_name → category map.
|
// resolveCategories returns lot_name → category map.
|
||||||
// Primary source: pricelist items (lot_category). Fallback: local_components table.
|
// Primary source: pricelist items (lot_category). Fallback: local_components table.
|
||||||
func (s *ExportService) resolveCategories(pricelistID *uint, lotNames []string) map[string]string {
|
func (s *ExportService) resolveCategories(pricelistID *uint, lotNames []string) map[string]string {
|
||||||
@@ -303,6 +502,324 @@ func sortItemsByCategory(items []ExportItem, categoryOrder map[string]int) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type pricingLevels struct {
|
||||||
|
Estimate *float64
|
||||||
|
Stock *float64
|
||||||
|
Competitor *float64
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *ExportService) resolvePricingTotals(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, opts ProjectPricingExportOptions) map[string]pricingLevels {
|
||||||
|
result := map[string]pricingLevels{}
|
||||||
|
lots := collectPricingLots(cfg, localCfg, opts.IncludeBOM)
|
||||||
|
if len(lots) == 0 || s.localDB == nil {
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
estimateID := cfg.PricelistID
|
||||||
|
if estimateID == nil || *estimateID == 0 {
|
||||||
|
if latest, err := s.localDB.GetLatestLocalPricelistBySource("estimate"); err == nil && latest != nil {
|
||||||
|
estimateID = &latest.ServerID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
var warehouseID *uint
|
||||||
|
var competitorID *uint
|
||||||
|
if localCfg != nil {
|
||||||
|
warehouseID = localCfg.WarehousePricelistID
|
||||||
|
competitorID = localCfg.CompetitorPricelistID
|
||||||
|
}
|
||||||
|
if warehouseID == nil || *warehouseID == 0 {
|
||||||
|
if latest, err := s.localDB.GetLatestLocalPricelistBySource("warehouse"); err == nil && latest != nil {
|
||||||
|
warehouseID = &latest.ServerID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if competitorID == nil || *competitorID == 0 {
|
||||||
|
if latest, err := s.localDB.GetLatestLocalPricelistBySource("competitor"); err == nil && latest != nil {
|
||||||
|
competitorID = &latest.ServerID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, lot := range lots {
|
||||||
|
level := pricingLevels{}
|
||||||
|
level.Estimate = s.lookupPricePointer(estimateID, lot)
|
||||||
|
level.Stock = s.lookupPricePointer(warehouseID, lot)
|
||||||
|
level.Competitor = s.lookupPricePointer(competitorID, lot)
|
||||||
|
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) == "" {
|
||||||
|
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 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return floatPtr(price)
|
||||||
|
}
|
||||||
|
|
||||||
|
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
|
||||||
|
}
|
||||||
|
for _, lot := range lots {
|
||||||
|
component, err := s.localDB.GetLocalComponent(lot)
|
||||||
|
if err != nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
result[lot] = component.LotDescription
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, includeBOM bool) []string {
|
||||||
|
seen := map[string]struct{}{}
|
||||||
|
out := make([]string, 0)
|
||||||
|
if includeBOM && localCfg != nil {
|
||||||
|
for _, row := range localCfg.VendorSpec {
|
||||||
|
for _, mapping := range normalizeLotMappings(row.LotMappings) {
|
||||||
|
if _, ok := seen[mapping.LotName]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[mapping.LotName] = struct{}{}
|
||||||
|
out = append(out, mapping.LotName)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, item := range cfg.Items {
|
||||||
|
lot := strings.TrimSpace(item.LotName)
|
||||||
|
if lot == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if _, ok := seen[lot]; ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
seen[lot] = struct{}{}
|
||||||
|
out = append(out, lot)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeLotMappings(mappings []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
|
||||||
|
if len(mappings) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]localdb.VendorSpecLotMapping, 0, len(mappings))
|
||||||
|
for _, mapping := range mappings {
|
||||||
|
lot := strings.TrimSpace(mapping.LotName)
|
||||||
|
if lot == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
qty := mapping.QuantityPerPN
|
||||||
|
if qty < 1 {
|
||||||
|
qty = 1
|
||||||
|
}
|
||||||
|
out = append(out, localdb.VendorSpecLotMapping{
|
||||||
|
LotName: lot,
|
||||||
|
QuantityPerPN: qty,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func vendorRowTotal(row localdb.VendorSpecItem) *float64 {
|
||||||
|
if row.TotalPrice != nil {
|
||||||
|
return floatPtr(*row.TotalPrice)
|
||||||
|
}
|
||||||
|
if row.UnitPrice == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return floatPtr(*row.UnitPrice * float64(exportPositiveInt(row.Quantity, 1)))
|
||||||
|
}
|
||||||
|
|
||||||
|
func computeMappingTotal(priceMap map[string]pricingLevels, mappings []localdb.VendorSpecLotMapping, pnQty int, selector func(pricingLevels) *float64) *float64 {
|
||||||
|
if len(mappings) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
total := 0.0
|
||||||
|
hasValue := false
|
||||||
|
qty := exportPositiveInt(pnQty, 1)
|
||||||
|
for _, mapping := range mappings {
|
||||||
|
price := selector(priceMap[mapping.LotName])
|
||||||
|
if price == nil || *price <= 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total += *price * float64(qty*mapping.QuantityPerPN)
|
||||||
|
hasValue = true
|
||||||
|
}
|
||||||
|
if !hasValue {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return floatPtr(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
func totalForUnitPrice(unitPrice *float64, quantity int) *float64 {
|
||||||
|
if unitPrice == nil || *unitPrice <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
total := *unitPrice * float64(exportPositiveInt(quantity, 1))
|
||||||
|
return &total
|
||||||
|
}
|
||||||
|
|
||||||
|
func estimateOnlyTotal(estimatePrice *float64, fallbackUnitPrice float64, quantity int) *float64 {
|
||||||
|
if estimatePrice != nil && *estimatePrice > 0 {
|
||||||
|
return totalForUnitPrice(estimatePrice, quantity)
|
||||||
|
}
|
||||||
|
if fallbackUnitPrice <= 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
total := fallbackUnitPrice * float64(maxInt(quantity, 1))
|
||||||
|
return &total
|
||||||
|
}
|
||||||
|
|
||||||
|
func pricingCSVHeaders(opts ProjectPricingExportOptions) []string {
|
||||||
|
headers := make([]string, 0, 8)
|
||||||
|
headers = append(headers, "Line Item")
|
||||||
|
if opts.IncludeLOT {
|
||||||
|
headers = append(headers, "LOT")
|
||||||
|
}
|
||||||
|
headers = append(headers, "PN вендора", "Описание", "Кол-во")
|
||||||
|
if opts.IncludeBOM {
|
||||||
|
headers = append(headers, "BOM")
|
||||||
|
}
|
||||||
|
if opts.IncludeEstimate {
|
||||||
|
headers = append(headers, "Estimate")
|
||||||
|
}
|
||||||
|
if opts.IncludeStock {
|
||||||
|
headers = append(headers, "Stock")
|
||||||
|
}
|
||||||
|
if opts.IncludeCompetitor {
|
||||||
|
headers = append(headers, "Конкуренты")
|
||||||
|
}
|
||||||
|
return headers
|
||||||
|
}
|
||||||
|
|
||||||
|
func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions) []string {
|
||||||
|
record := make([]string, 0, 8)
|
||||||
|
record = append(record, "")
|
||||||
|
if opts.IncludeLOT {
|
||||||
|
record = append(record, emptyDash(row.LotDisplay))
|
||||||
|
}
|
||||||
|
record = append(record,
|
||||||
|
emptyDash(row.VendorPN),
|
||||||
|
emptyDash(row.Description),
|
||||||
|
fmt.Sprintf("%d", exportPositiveInt(row.Quantity, 1)),
|
||||||
|
)
|
||||||
|
if opts.IncludeBOM {
|
||||||
|
record = append(record, formatMoneyValue(row.BOMTotal))
|
||||||
|
}
|
||||||
|
if opts.IncludeEstimate {
|
||||||
|
record = append(record, formatMoneyValue(row.Estimate))
|
||||||
|
}
|
||||||
|
if opts.IncludeStock {
|
||||||
|
record = append(record, formatMoneyValue(row.Stock))
|
||||||
|
}
|
||||||
|
if opts.IncludeCompetitor {
|
||||||
|
record = append(record, formatMoneyValue(row.Competitor))
|
||||||
|
}
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricingExportOptions) []string {
|
||||||
|
record := make([]string, 0, 8)
|
||||||
|
record = append(record, fmt.Sprintf("%d", cfg.Line))
|
||||||
|
if opts.IncludeLOT {
|
||||||
|
record = append(record, "")
|
||||||
|
}
|
||||||
|
record = append(record,
|
||||||
|
"",
|
||||||
|
emptyDash(cfg.Name),
|
||||||
|
fmt.Sprintf("%d", exportPositiveInt(cfg.ServerCount, 1)),
|
||||||
|
)
|
||||||
|
if opts.IncludeBOM {
|
||||||
|
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.BOMTotal })))
|
||||||
|
}
|
||||||
|
if opts.IncludeEstimate {
|
||||||
|
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Estimate })))
|
||||||
|
}
|
||||||
|
if opts.IncludeStock {
|
||||||
|
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Stock })))
|
||||||
|
}
|
||||||
|
if opts.IncludeCompetitor {
|
||||||
|
record = append(record, formatMoneyValue(sumPricingColumn(cfg.Rows, func(row ProjectPricingExportRow) *float64 { return row.Competitor })))
|
||||||
|
}
|
||||||
|
return record
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatLotDisplay(mappings []localdb.VendorSpecLotMapping) string {
|
||||||
|
switch len(mappings) {
|
||||||
|
case 0:
|
||||||
|
return "н/д"
|
||||||
|
case 1:
|
||||||
|
return mappings[0].LotName
|
||||||
|
default:
|
||||||
|
return fmt.Sprintf("%s +%d", mappings[0].LotName, len(mappings)-1)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func formatMoneyValue(value *float64) string {
|
||||||
|
if value == nil {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
n := math.Round(*value*100) / 100
|
||||||
|
sign := ""
|
||||||
|
if n < 0 {
|
||||||
|
sign = "-"
|
||||||
|
n = -n
|
||||||
|
}
|
||||||
|
whole := int64(n)
|
||||||
|
fraction := int(math.Round((n - float64(whole)) * 100))
|
||||||
|
if fraction == 100 {
|
||||||
|
whole++
|
||||||
|
fraction = 0
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("%s%s,%02d", sign, formatIntWithSpace(whole), fraction)
|
||||||
|
}
|
||||||
|
|
||||||
|
func emptyDash(value string) string {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return "—"
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func sumPricingColumn(rows []ProjectPricingExportRow, selector func(ProjectPricingExportRow) *float64) *float64 {
|
||||||
|
total := 0.0
|
||||||
|
hasValue := false
|
||||||
|
for _, row := range rows {
|
||||||
|
value := selector(row)
|
||||||
|
if value == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total += *value
|
||||||
|
hasValue = true
|
||||||
|
}
|
||||||
|
if !hasValue {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return floatPtr(total)
|
||||||
|
}
|
||||||
|
|
||||||
|
func floatPtr(value float64) *float64 {
|
||||||
|
v := value
|
||||||
|
return &v
|
||||||
|
}
|
||||||
|
|
||||||
|
func exportPositiveInt(value, fallback int) int {
|
||||||
|
if value < 1 {
|
||||||
|
return fallback
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
// formatPriceComma formats a price with comma as decimal separator (e.g., "2074,5").
|
// formatPriceComma formats a price with comma as decimal separator (e.g., "2074,5").
|
||||||
// Trailing zeros after the comma are trimmed, and if the value is an integer, no comma is shown.
|
// Trailing zeros after the comma are trimmed, and if the value is an integer, no comma is shown.
|
||||||
func formatPriceComma(value float64) string {
|
func formatPriceComma(value float64) string {
|
||||||
|
|||||||
@@ -444,6 +444,117 @@ func TestFormatPriceComma(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestToPricingCSV_UsesSelectedColumns(t *testing.T) {
|
||||||
|
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||||
|
data := &ProjectPricingExportData{
|
||||||
|
Configs: []ProjectPricingExportConfig{
|
||||||
|
{
|
||||||
|
Name: "Config A",
|
||||||
|
Article: "ART-1",
|
||||||
|
Line: 10,
|
||||||
|
ServerCount: 2,
|
||||||
|
Rows: []ProjectPricingExportRow{
|
||||||
|
{
|
||||||
|
LotDisplay: "LOT_A +1",
|
||||||
|
VendorPN: "PN-001",
|
||||||
|
Description: "Bundle row",
|
||||||
|
Quantity: 2,
|
||||||
|
BOMTotal: floatPtr(2400.5),
|
||||||
|
Estimate: floatPtr(2000),
|
||||||
|
Stock: floatPtr(1800.25),
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
}
|
||||||
|
opts := ProjectPricingExportOptions{
|
||||||
|
IncludeLOT: true,
|
||||||
|
IncludeBOM: true,
|
||||||
|
IncludeEstimate: true,
|
||||||
|
IncludeStock: true,
|
||||||
|
}
|
||||||
|
|
||||||
|
var buf bytes.Buffer
|
||||||
|
if err := svc.ToPricingCSV(&buf, data, opts); err != nil {
|
||||||
|
t.Fatalf("ToPricingCSV failed: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
reader := csv.NewReader(bytes.NewReader(buf.Bytes()[3:]))
|
||||||
|
reader.Comma = ';'
|
||||||
|
reader.FieldsPerRecord = -1
|
||||||
|
|
||||||
|
header, err := reader.Read()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read header row: %v", err)
|
||||||
|
}
|
||||||
|
expectedHeader := []string{"Line Item", "LOT", "PN вендора", "Описание", "Кол-во", "BOM", "Estimate", "Stock"}
|
||||||
|
for i, want := range expectedHeader {
|
||||||
|
if header[i] != want {
|
||||||
|
t.Fatalf("header[%d]: expected %q, got %q", i, want, header[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
summary, err := reader.Read()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read summary row: %v", err)
|
||||||
|
}
|
||||||
|
expectedSummary := []string{"10", "", "", "Config A", "2", "2 400,50", "2 000,00", "1 800,25"}
|
||||||
|
for i, want := range expectedSummary {
|
||||||
|
if summary[i] != want {
|
||||||
|
t.Fatalf("summary[%d]: expected %q, got %q", i, want, summary[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
row, err := reader.Read()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("read data row: %v", err)
|
||||||
|
}
|
||||||
|
expectedRow := []string{"", "LOT_A +1", "PN-001", "Bundle row", "2", "2 400,50", "2 000,00", "1 800,25"}
|
||||||
|
for i, want := range expectedRow {
|
||||||
|
if row[i] != want {
|
||||||
|
t.Fatalf("row[%d]: expected %q, got %q", i, want, row[i])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestProjectToPricingExportData_UsesCartRowsWithoutBOM(t *testing.T) {
|
||||||
|
svc := NewExportService(config.ExportConfig{}, nil, nil)
|
||||||
|
configs := []models.Configuration{
|
||||||
|
{
|
||||||
|
UUID: "cfg-1",
|
||||||
|
Name: "Config A",
|
||||||
|
Article: "ART-1",
|
||||||
|
ServerCount: 1,
|
||||||
|
Items: models.ConfigItems{
|
||||||
|
{LotName: "LOT_A", Quantity: 2, UnitPrice: 300},
|
||||||
|
},
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
data, err := svc.ProjectToPricingExportData(configs, ProjectPricingExportOptions{
|
||||||
|
IncludeLOT: true,
|
||||||
|
IncludeEstimate: true,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ProjectToPricingExportData failed: %v", err)
|
||||||
|
}
|
||||||
|
if len(data.Configs) != 1 || len(data.Configs[0].Rows) != 1 {
|
||||||
|
t.Fatalf("unexpected rows count: %+v", data.Configs)
|
||||||
|
}
|
||||||
|
row := data.Configs[0].Rows[0]
|
||||||
|
if row.LotDisplay != "LOT_A" {
|
||||||
|
t.Fatalf("expected LOT_A, got %q", row.LotDisplay)
|
||||||
|
}
|
||||||
|
if row.VendorPN != "—" {
|
||||||
|
t.Fatalf("expected vendor dash, got %q", row.VendorPN)
|
||||||
|
}
|
||||||
|
if row.Estimate == nil || *row.Estimate != 600 {
|
||||||
|
t.Fatalf("expected estimate total 600, got %+v", row.Estimate)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// failingWriter always returns an error
|
// failingWriter always returns an error
|
||||||
type failingWriter struct{}
|
type failingWriter struct{}
|
||||||
|
|
||||||
|
|||||||
@@ -83,22 +83,25 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
|||||||
}
|
}
|
||||||
|
|
||||||
cfg := &models.Configuration{
|
cfg := &models.Configuration{
|
||||||
UUID: uuid.New().String(),
|
UUID: uuid.New().String(),
|
||||||
OwnerUsername: ownerUsername,
|
OwnerUsername: ownerUsername,
|
||||||
ProjectUUID: projectUUID,
|
ProjectUUID: projectUUID,
|
||||||
Name: req.Name,
|
Name: req.Name,
|
||||||
Items: req.Items,
|
Items: req.Items,
|
||||||
TotalPrice: &total,
|
TotalPrice: &total,
|
||||||
CustomPrice: req.CustomPrice,
|
CustomPrice: req.CustomPrice,
|
||||||
Notes: req.Notes,
|
Notes: req.Notes,
|
||||||
IsTemplate: req.IsTemplate,
|
IsTemplate: req.IsTemplate,
|
||||||
ServerCount: req.ServerCount,
|
ServerCount: req.ServerCount,
|
||||||
ServerModel: req.ServerModel,
|
ServerModel: req.ServerModel,
|
||||||
SupportCode: req.SupportCode,
|
SupportCode: req.SupportCode,
|
||||||
Article: req.Article,
|
Article: req.Article,
|
||||||
PricelistID: pricelistID,
|
PricelistID: pricelistID,
|
||||||
OnlyInStock: req.OnlyInStock,
|
WarehousePricelistID: req.WarehousePricelistID,
|
||||||
CreatedAt: time.Now(),
|
CompetitorPricelistID: req.CompetitorPricelistID,
|
||||||
|
DisablePriceRefresh: req.DisablePriceRefresh,
|
||||||
|
OnlyInStock: req.OnlyInStock,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Convert to local model
|
// Convert to local model
|
||||||
@@ -196,6 +199,9 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
|
|||||||
localCfg.SupportCode = req.SupportCode
|
localCfg.SupportCode = req.SupportCode
|
||||||
localCfg.Article = req.Article
|
localCfg.Article = req.Article
|
||||||
localCfg.PricelistID = pricelistID
|
localCfg.PricelistID = pricelistID
|
||||||
|
localCfg.WarehousePricelistID = req.WarehousePricelistID
|
||||||
|
localCfg.CompetitorPricelistID = req.CompetitorPricelistID
|
||||||
|
localCfg.DisablePriceRefresh = req.DisablePriceRefresh
|
||||||
localCfg.OnlyInStock = req.OnlyInStock
|
localCfg.OnlyInStock = req.OnlyInStock
|
||||||
localCfg.UpdatedAt = time.Now()
|
localCfg.UpdatedAt = time.Now()
|
||||||
localCfg.SyncStatus = "pending"
|
localCfg.SyncStatus = "pending"
|
||||||
@@ -304,22 +310,25 @@ func (s *LocalConfigurationService) CloneToProject(configUUID string, ownerUsern
|
|||||||
}
|
}
|
||||||
|
|
||||||
clone := &models.Configuration{
|
clone := &models.Configuration{
|
||||||
UUID: uuid.New().String(),
|
UUID: uuid.New().String(),
|
||||||
OwnerUsername: ownerUsername,
|
OwnerUsername: ownerUsername,
|
||||||
ProjectUUID: resolvedProjectUUID,
|
ProjectUUID: resolvedProjectUUID,
|
||||||
Name: newName,
|
Name: newName,
|
||||||
Items: original.Items,
|
Items: original.Items,
|
||||||
TotalPrice: &total,
|
TotalPrice: &total,
|
||||||
CustomPrice: original.CustomPrice,
|
CustomPrice: original.CustomPrice,
|
||||||
Notes: original.Notes,
|
Notes: original.Notes,
|
||||||
IsTemplate: false,
|
IsTemplate: false,
|
||||||
ServerCount: original.ServerCount,
|
ServerCount: original.ServerCount,
|
||||||
ServerModel: original.ServerModel,
|
ServerModel: original.ServerModel,
|
||||||
SupportCode: original.SupportCode,
|
SupportCode: original.SupportCode,
|
||||||
Article: original.Article,
|
Article: original.Article,
|
||||||
PricelistID: original.PricelistID,
|
PricelistID: original.PricelistID,
|
||||||
OnlyInStock: original.OnlyInStock,
|
WarehousePricelistID: original.WarehousePricelistID,
|
||||||
CreatedAt: time.Now(),
|
CompetitorPricelistID: original.CompetitorPricelistID,
|
||||||
|
DisablePriceRefresh: original.DisablePriceRefresh,
|
||||||
|
OnlyInStock: original.OnlyInStock,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
localCfg := localdb.ConfigurationToLocal(clone)
|
localCfg := localdb.ConfigurationToLocal(clone)
|
||||||
@@ -521,6 +530,9 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
|
|||||||
localCfg.SupportCode = req.SupportCode
|
localCfg.SupportCode = req.SupportCode
|
||||||
localCfg.Article = req.Article
|
localCfg.Article = req.Article
|
||||||
localCfg.PricelistID = pricelistID
|
localCfg.PricelistID = pricelistID
|
||||||
|
localCfg.WarehousePricelistID = req.WarehousePricelistID
|
||||||
|
localCfg.CompetitorPricelistID = req.CompetitorPricelistID
|
||||||
|
localCfg.DisablePriceRefresh = req.DisablePriceRefresh
|
||||||
localCfg.OnlyInStock = req.OnlyInStock
|
localCfg.OnlyInStock = req.OnlyInStock
|
||||||
localCfg.UpdatedAt = time.Now()
|
localCfg.UpdatedAt = time.Now()
|
||||||
localCfg.SyncStatus = "pending"
|
localCfg.SyncStatus = "pending"
|
||||||
@@ -623,19 +635,25 @@ func (s *LocalConfigurationService) CloneNoAuthToProjectFromVersion(configUUID s
|
|||||||
}
|
}
|
||||||
|
|
||||||
clone := &models.Configuration{
|
clone := &models.Configuration{
|
||||||
UUID: uuid.New().String(),
|
UUID: uuid.New().String(),
|
||||||
OwnerUsername: ownerUsername,
|
OwnerUsername: ownerUsername,
|
||||||
ProjectUUID: resolvedProjectUUID,
|
ProjectUUID: resolvedProjectUUID,
|
||||||
Name: newName,
|
Name: newName,
|
||||||
Items: original.Items,
|
Items: original.Items,
|
||||||
TotalPrice: &total,
|
TotalPrice: &total,
|
||||||
CustomPrice: original.CustomPrice,
|
CustomPrice: original.CustomPrice,
|
||||||
Notes: original.Notes,
|
Notes: original.Notes,
|
||||||
IsTemplate: false,
|
IsTemplate: false,
|
||||||
ServerCount: original.ServerCount,
|
ServerCount: original.ServerCount,
|
||||||
PricelistID: original.PricelistID,
|
ServerModel: original.ServerModel,
|
||||||
OnlyInStock: original.OnlyInStock,
|
SupportCode: original.SupportCode,
|
||||||
CreatedAt: time.Now(),
|
Article: original.Article,
|
||||||
|
PricelistID: original.PricelistID,
|
||||||
|
WarehousePricelistID: original.WarehousePricelistID,
|
||||||
|
CompetitorPricelistID: original.CompetitorPricelistID,
|
||||||
|
DisablePriceRefresh: original.DisablePriceRefresh,
|
||||||
|
OnlyInStock: original.OnlyInStock,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
}
|
}
|
||||||
|
|
||||||
localCfg := localdb.ConfigurationToLocal(clone)
|
localCfg := localdb.ConfigurationToLocal(clone)
|
||||||
@@ -1053,39 +1071,43 @@ func (s *LocalConfigurationService) isOwner(cfg *localdb.LocalConfiguration, own
|
|||||||
|
|
||||||
func (s *LocalConfigurationService) createWithVersion(localCfg *localdb.LocalConfiguration, createdBy string) error {
|
func (s *LocalConfigurationService) createWithVersion(localCfg *localdb.LocalConfiguration, createdBy string) error {
|
||||||
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
return s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||||
if localCfg.IsActive {
|
return s.createWithVersionTx(tx, localCfg, createdBy)
|
||||||
if err := s.ensureConfigurationLineTx(tx, localCfg); err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if err := tx.Create(localCfg).Error; err != nil {
|
|
||||||
return fmt.Errorf("create local configuration: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
version, err := s.appendVersionTx(tx, localCfg, "create", createdBy)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("append create version: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := tx.Model(&localdb.LocalConfiguration{}).
|
|
||||||
Where("uuid = ?", localCfg.UUID).
|
|
||||||
Update("current_version_id", version.ID).Error; err != nil {
|
|
||||||
return fmt.Errorf("set current version id: %w", err)
|
|
||||||
}
|
|
||||||
localCfg.CurrentVersionID = &version.ID
|
|
||||||
localCfg.CurrentVersion = version
|
|
||||||
|
|
||||||
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "create", version, createdBy); err != nil {
|
|
||||||
return fmt.Errorf("enqueue create pending change: %w", err)
|
|
||||||
}
|
|
||||||
if err := s.recalculateLocalPricelistUsageTx(tx); err != nil {
|
|
||||||
return fmt.Errorf("recalculate local pricelist usage: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
return nil
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func (s *LocalConfigurationService) createWithVersionTx(tx *gorm.DB, localCfg *localdb.LocalConfiguration, createdBy string) error {
|
||||||
|
if localCfg.IsActive {
|
||||||
|
if err := s.ensureConfigurationLineTx(tx, localCfg); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if err := tx.Create(localCfg).Error; err != nil {
|
||||||
|
return fmt.Errorf("create local configuration: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
version, err := s.appendVersionTx(tx, localCfg, "create", createdBy)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("append create version: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Model(&localdb.LocalConfiguration{}).
|
||||||
|
Where("uuid = ?", localCfg.UUID).
|
||||||
|
Update("current_version_id", version.ID).Error; err != nil {
|
||||||
|
return fmt.Errorf("set current version id: %w", err)
|
||||||
|
}
|
||||||
|
localCfg.CurrentVersionID = &version.ID
|
||||||
|
localCfg.CurrentVersion = version
|
||||||
|
|
||||||
|
if err := s.enqueueConfigurationPendingChangeTx(tx, localCfg, "create", version, createdBy); err != nil {
|
||||||
|
return fmt.Errorf("enqueue create pending change: %w", err)
|
||||||
|
}
|
||||||
|
if err := s.recalculateLocalPricelistUsageTx(tx); err != nil {
|
||||||
|
return fmt.Errorf("recalculate local pricelist usage: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.LocalConfiguration, operation string, createdBy string) (*models.Configuration, error) {
|
func (s *LocalConfigurationService) saveWithVersionAndPending(localCfg *localdb.LocalConfiguration, operation string, createdBy string) (*models.Configuration, error) {
|
||||||
var cfg *models.Configuration
|
var cfg *models.Configuration
|
||||||
|
|
||||||
@@ -1183,6 +1205,7 @@ func hasNonRevisionConfigurationChanges(current *localdb.LocalConfiguration, nex
|
|||||||
current.ServerModel != next.ServerModel ||
|
current.ServerModel != next.ServerModel ||
|
||||||
current.SupportCode != next.SupportCode ||
|
current.SupportCode != next.SupportCode ||
|
||||||
current.Article != next.Article ||
|
current.Article != next.Article ||
|
||||||
|
current.DisablePriceRefresh != next.DisablePriceRefresh ||
|
||||||
current.OnlyInStock != next.OnlyInStock ||
|
current.OnlyInStock != next.OnlyInStock ||
|
||||||
current.IsActive != next.IsActive ||
|
current.IsActive != next.IsActive ||
|
||||||
current.Line != next.Line {
|
current.Line != next.Line {
|
||||||
@@ -1419,8 +1442,15 @@ func (s *LocalConfigurationService) rollbackToVersion(configurationUUID string,
|
|||||||
current.Notes = rollbackData.Notes
|
current.Notes = rollbackData.Notes
|
||||||
current.IsTemplate = rollbackData.IsTemplate
|
current.IsTemplate = rollbackData.IsTemplate
|
||||||
current.ServerCount = rollbackData.ServerCount
|
current.ServerCount = rollbackData.ServerCount
|
||||||
|
current.ServerModel = rollbackData.ServerModel
|
||||||
|
current.SupportCode = rollbackData.SupportCode
|
||||||
|
current.Article = rollbackData.Article
|
||||||
current.PricelistID = rollbackData.PricelistID
|
current.PricelistID = rollbackData.PricelistID
|
||||||
|
current.WarehousePricelistID = rollbackData.WarehousePricelistID
|
||||||
|
current.CompetitorPricelistID = rollbackData.CompetitorPricelistID
|
||||||
|
current.DisablePriceRefresh = rollbackData.DisablePriceRefresh
|
||||||
current.OnlyInStock = rollbackData.OnlyInStock
|
current.OnlyInStock = rollbackData.OnlyInStock
|
||||||
|
current.VendorSpec = rollbackData.VendorSpec
|
||||||
if rollbackData.Line > 0 {
|
if rollbackData.Line > 0 {
|
||||||
current.Line = rollbackData.Line
|
current.Line = rollbackData.Line
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -14,7 +14,7 @@ type SeenPartnumber struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// PushPartnumberSeen inserts unresolved vendor partnumbers into qt_vendor_partnumber_seen on MariaDB.
|
// PushPartnumberSeen inserts unresolved vendor partnumbers into qt_vendor_partnumber_seen on MariaDB.
|
||||||
// Uses INSERT ... ON DUPLICATE KEY UPDATE so existing rows are updated (last_seen_at) without error.
|
// Existing rows are left untouched: no updates to last_seen_at, is_ignored, or description.
|
||||||
func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
|
func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
|
||||||
if len(items) == 0 {
|
if len(items) == 0 {
|
||||||
return nil
|
return nil
|
||||||
@@ -36,12 +36,10 @@ func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
|
|||||||
VALUES
|
VALUES
|
||||||
('manual', '', ?, ?, ?, ?)
|
('manual', '', ?, ?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
last_seen_at = VALUES(last_seen_at),
|
partnumber = partnumber
|
||||||
is_ignored = VALUES(is_ignored),
|
|
||||||
description = COALESCE(NULLIF(VALUES(description), ''), description)
|
|
||||||
`, item.Partnumber, item.Description, item.Ignored, 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 insert partnumber_seen", "partnumber", item.Partnumber, "error", err)
|
||||||
// Continue with remaining items
|
// Continue with remaining items
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import (
|
|||||||
"errors"
|
"errors"
|
||||||
"fmt"
|
"fmt"
|
||||||
"log/slog"
|
"log/slog"
|
||||||
|
"os"
|
||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
@@ -213,17 +214,64 @@ func ensureClientMigrationRegistryTable(db *gorm.DB) error {
|
|||||||
if err := db.Exec(`
|
if err := db.Exec(`
|
||||||
CREATE TABLE IF NOT EXISTS qt_client_schema_state (
|
CREATE TABLE IF NOT EXISTS qt_client_schema_state (
|
||||||
username VARCHAR(100) NOT NULL,
|
username VARCHAR(100) NOT NULL,
|
||||||
|
hostname VARCHAR(255) NOT NULL DEFAULT '',
|
||||||
last_applied_migration_id VARCHAR(128) NULL,
|
last_applied_migration_id VARCHAR(128) NULL,
|
||||||
app_version VARCHAR(64) NULL,
|
app_version VARCHAR(64) NULL,
|
||||||
|
last_sync_at DATETIME NULL,
|
||||||
|
last_sync_status VARCHAR(32) NULL,
|
||||||
|
pending_changes_count INT NOT NULL DEFAULT 0,
|
||||||
|
pending_errors_count INT NOT NULL DEFAULT 0,
|
||||||
|
configurations_count INT NOT NULL DEFAULT 0,
|
||||||
|
projects_count INT NOT NULL DEFAULT 0,
|
||||||
|
estimate_pricelist_version VARCHAR(128) NULL,
|
||||||
|
warehouse_pricelist_version VARCHAR(128) NULL,
|
||||||
|
competitor_pricelist_version VARCHAR(128) NULL,
|
||||||
|
last_sync_error_code VARCHAR(128) NULL,
|
||||||
|
last_sync_error_text TEXT NULL,
|
||||||
last_checked_at DATETIME NOT NULL,
|
last_checked_at DATETIME NOT NULL,
|
||||||
updated_at DATETIME NOT NULL,
|
updated_at DATETIME NOT NULL,
|
||||||
PRIMARY KEY (username),
|
PRIMARY KEY (username, hostname),
|
||||||
INDEX idx_qt_client_schema_state_checked (last_checked_at)
|
INDEX idx_qt_client_schema_state_checked (last_checked_at)
|
||||||
)
|
)
|
||||||
`).Error; err != nil {
|
`).Error; err != nil {
|
||||||
return fmt.Errorf("create qt_client_schema_state table: %w", err)
|
return fmt.Errorf("create qt_client_schema_state table: %w", err)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if tableExists(db, "qt_client_schema_state") {
|
||||||
|
if err := db.Exec(`
|
||||||
|
ALTER TABLE qt_client_schema_state
|
||||||
|
ADD COLUMN IF NOT EXISTS hostname VARCHAR(255) NOT NULL DEFAULT '' AFTER username
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("add qt_client_schema_state.hostname: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := db.Exec(`
|
||||||
|
ALTER TABLE qt_client_schema_state
|
||||||
|
DROP PRIMARY KEY,
|
||||||
|
ADD PRIMARY KEY (username, hostname)
|
||||||
|
`).Error; err != nil && !isDuplicatePrimaryKeyDefinition(err) {
|
||||||
|
return fmt.Errorf("set qt_client_schema_state primary key: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, stmt := range []string{
|
||||||
|
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_at DATETIME NULL AFTER app_version",
|
||||||
|
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_status VARCHAR(32) NULL AFTER last_sync_at",
|
||||||
|
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS pending_changes_count INT NOT NULL DEFAULT 0 AFTER last_sync_status",
|
||||||
|
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS pending_errors_count INT NOT NULL DEFAULT 0 AFTER pending_changes_count",
|
||||||
|
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS configurations_count INT NOT NULL DEFAULT 0 AFTER pending_errors_count",
|
||||||
|
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS projects_count INT NOT NULL DEFAULT 0 AFTER configurations_count",
|
||||||
|
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS estimate_pricelist_version VARCHAR(128) NULL AFTER projects_count",
|
||||||
|
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS warehouse_pricelist_version VARCHAR(128) NULL AFTER estimate_pricelist_version",
|
||||||
|
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS competitor_pricelist_version VARCHAR(128) NULL AFTER warehouse_pricelist_version",
|
||||||
|
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_code VARCHAR(128) NULL AFTER competitor_pricelist_version",
|
||||||
|
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_text TEXT NULL AFTER last_sync_error_code",
|
||||||
|
} {
|
||||||
|
if err := db.Exec(stmt).Error; err != nil {
|
||||||
|
return fmt.Errorf("expand qt_client_schema_state: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -351,19 +399,108 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
|
|||||||
if username == "" {
|
if username == "" {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
hostname, err := os.Hostname()
|
||||||
|
if err != nil {
|
||||||
|
hostname = ""
|
||||||
|
}
|
||||||
|
hostname = strings.TrimSpace(hostname)
|
||||||
lastMigrationID := ""
|
lastMigrationID := ""
|
||||||
if id, err := s.localDB.GetLatestAppliedRemoteMigrationID(); err == nil {
|
if id, err := s.localDB.GetLatestAppliedRemoteMigrationID(); err == nil {
|
||||||
lastMigrationID = id
|
lastMigrationID = id
|
||||||
}
|
}
|
||||||
|
lastSyncAt := s.localDB.GetLastSyncTime()
|
||||||
|
lastSyncStatus := ReadinessReady
|
||||||
|
pendingChangesCount := s.localDB.CountPendingChanges()
|
||||||
|
pendingErrorsCount := s.localDB.CountErroredChanges()
|
||||||
|
configurationsCount := s.localDB.CountConfigurations()
|
||||||
|
projectsCount := s.localDB.CountProjects()
|
||||||
|
estimateVersion := latestPricelistVersion(s.localDB, "estimate")
|
||||||
|
warehouseVersion := latestPricelistVersion(s.localDB, "warehouse")
|
||||||
|
competitorVersion := latestPricelistVersion(s.localDB, "competitor")
|
||||||
|
lastSyncErrorCode, lastSyncErrorText := latestSyncErrorState(s.localDB)
|
||||||
return mariaDB.Exec(`
|
return mariaDB.Exec(`
|
||||||
INSERT INTO qt_client_schema_state (username, last_applied_migration_id, app_version, last_checked_at, updated_at)
|
INSERT INTO qt_client_schema_state (
|
||||||
VALUES (?, ?, ?, ?, ?)
|
username, hostname, 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
|
||||||
|
)
|
||||||
|
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
ON DUPLICATE KEY UPDATE
|
ON DUPLICATE KEY UPDATE
|
||||||
last_applied_migration_id = VALUES(last_applied_migration_id),
|
last_applied_migration_id = VALUES(last_applied_migration_id),
|
||||||
app_version = VALUES(app_version),
|
app_version = VALUES(app_version),
|
||||||
|
last_sync_at = VALUES(last_sync_at),
|
||||||
|
last_sync_status = VALUES(last_sync_status),
|
||||||
|
pending_changes_count = VALUES(pending_changes_count),
|
||||||
|
pending_errors_count = VALUES(pending_errors_count),
|
||||||
|
configurations_count = VALUES(configurations_count),
|
||||||
|
projects_count = VALUES(projects_count),
|
||||||
|
estimate_pricelist_version = VALUES(estimate_pricelist_version),
|
||||||
|
warehouse_pricelist_version = VALUES(warehouse_pricelist_version),
|
||||||
|
competitor_pricelist_version = VALUES(competitor_pricelist_version),
|
||||||
|
last_sync_error_code = VALUES(last_sync_error_code),
|
||||||
|
last_sync_error_text = VALUES(last_sync_error_text),
|
||||||
last_checked_at = VALUES(last_checked_at),
|
last_checked_at = VALUES(last_checked_at),
|
||||||
updated_at = VALUES(updated_at)
|
updated_at = VALUES(updated_at)
|
||||||
`, username, lastMigrationID, appmeta.Version(), checkedAt, checkedAt).Error
|
`, username, hostname, lastMigrationID, appmeta.Version(),
|
||||||
|
lastSyncAt, lastSyncStatus, pendingChangesCount, pendingErrorsCount,
|
||||||
|
configurationsCount, projectsCount,
|
||||||
|
estimateVersion, warehouseVersion, competitorVersion,
|
||||||
|
lastSyncErrorCode, lastSyncErrorText,
|
||||||
|
checkedAt, checkedAt).Error
|
||||||
|
}
|
||||||
|
|
||||||
|
func isDuplicatePrimaryKeyDefinition(err error) bool {
|
||||||
|
if err == nil {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
msg := strings.ToLower(err.Error())
|
||||||
|
return strings.Contains(msg, "multiple primary key defined") ||
|
||||||
|
strings.Contains(msg, "duplicate key name 'primary'") ||
|
||||||
|
strings.Contains(msg, "duplicate entry")
|
||||||
|
}
|
||||||
|
|
||||||
|
func latestPricelistVersion(local *localdb.LocalDB, source string) *string {
|
||||||
|
if local == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
pl, err := local.GetLatestLocalPricelistBySource(source)
|
||||||
|
if err != nil || pl == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
version := strings.TrimSpace(pl.Version)
|
||||||
|
if version == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &version
|
||||||
|
}
|
||||||
|
|
||||||
|
func latestSyncErrorState(local *localdb.LocalDB) (*string, *string) {
|
||||||
|
if local == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
if guard, err := local.GetSyncGuardState(); err == nil && guard != nil && strings.EqualFold(guard.Status, ReadinessBlocked) {
|
||||||
|
return optionalString(strings.TrimSpace(guard.ReasonCode)), optionalString(strings.TrimSpace(guard.ReasonText))
|
||||||
|
}
|
||||||
|
|
||||||
|
var pending localdb.PendingChange
|
||||||
|
if err := local.DB().
|
||||||
|
Where("TRIM(COALESCE(last_error, '')) <> ''").
|
||||||
|
Order("id DESC").
|
||||||
|
First(&pending).Error; err == nil {
|
||||||
|
return optionalString("PENDING_CHANGE_ERROR"), optionalString(strings.TrimSpace(pending.LastError))
|
||||||
|
}
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func optionalString(value string) *string {
|
||||||
|
if strings.TrimSpace(value) == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
v := strings.TrimSpace(value)
|
||||||
|
return &v
|
||||||
}
|
}
|
||||||
|
|
||||||
func normalizeVersionParts(v string) []int {
|
func normalizeVersionParts(v string) []int {
|
||||||
|
|||||||
@@ -148,9 +148,6 @@ func (s *Service) ImportConfigurationsToLocal() (*ConfigImportResult, error) {
|
|||||||
if localCfg.Line <= 0 && existing.Line > 0 {
|
if localCfg.Line <= 0 && existing.Line > 0 {
|
||||||
localCfg.Line = existing.Line
|
localCfg.Line = existing.Line
|
||||||
}
|
}
|
||||||
// vendor_spec is local-only for BOM tab and is not stored on server.
|
|
||||||
// Preserve it during server pull updates.
|
|
||||||
localCfg.VendorSpec = existing.VendorSpec
|
|
||||||
result.Updated++
|
result.Updated++
|
||||||
} else {
|
} else {
|
||||||
result.Imported++
|
result.Imported++
|
||||||
|
|||||||
@@ -315,10 +315,21 @@ func TestImportConfigurationsToLocalPullsLine(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImportConfigurationsToLocalPreservesLocalVendorSpec(t *testing.T) {
|
func TestImportConfigurationsToLocalLoadsVendorSpecFromServer(t *testing.T) {
|
||||||
local := newLocalDBForSyncTest(t)
|
local := newLocalDBForSyncTest(t)
|
||||||
serverDB := newServerDBForSyncTest(t)
|
serverDB := newServerDBForSyncTest(t)
|
||||||
|
|
||||||
|
serverSpec := models.VendorSpec{
|
||||||
|
{
|
||||||
|
SortOrder: 10,
|
||||||
|
VendorPartnumber: "GPU-NVHGX-H200-8141",
|
||||||
|
Quantity: 1,
|
||||||
|
Description: "NVIDIA HGX Delta-Next GPU Baseboard",
|
||||||
|
LotMappings: []models.VendorSpecLotMapping{
|
||||||
|
{LotName: "GPU_NV_H200_141GB_SXM_(HGX)", QuantityPerPN: 8},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
cfg := models.Configuration{
|
cfg := models.Configuration{
|
||||||
UUID: "server-vendorspec-config",
|
UUID: "server-vendorspec-config",
|
||||||
OwnerUsername: "tester",
|
OwnerUsername: "tester",
|
||||||
@@ -326,6 +337,7 @@ func TestImportConfigurationsToLocalPreservesLocalVendorSpec(t *testing.T) {
|
|||||||
Items: models.ConfigItems{{LotName: "CPU_PULL", Quantity: 1, UnitPrice: 900}},
|
Items: models.ConfigItems{{LotName: "CPU_PULL", Quantity: 1, UnitPrice: 900}},
|
||||||
ServerCount: 1,
|
ServerCount: 1,
|
||||||
Line: 50,
|
Line: 50,
|
||||||
|
VendorSpec: serverSpec,
|
||||||
}
|
}
|
||||||
total := cfg.Items.Total()
|
total := cfg.Items.Total()
|
||||||
cfg.TotalPrice = &total
|
cfg.TotalPrice = &total
|
||||||
@@ -333,32 +345,6 @@ func TestImportConfigurationsToLocalPreservesLocalVendorSpec(t *testing.T) {
|
|||||||
t.Fatalf("seed server config: %v", err)
|
t.Fatalf("seed server config: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
localSpec := localdb.VendorSpec{
|
|
||||||
{
|
|
||||||
SortOrder: 10,
|
|
||||||
VendorPartnumber: "GPU-NVHGX-H200-8141",
|
|
||||||
Quantity: 1,
|
|
||||||
Description: "NVIDIA HGX Delta-Next GPU Baseboard",
|
|
||||||
LotMappings: []localdb.VendorSpecLotMapping{
|
|
||||||
{LotName: "GPU_NV_H200_141GB_SXM_(HGX)", QuantityPerPN: 8},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
}
|
|
||||||
if err := local.SaveConfiguration(&localdb.LocalConfiguration{
|
|
||||||
UUID: cfg.UUID,
|
|
||||||
OriginalUsername: "tester",
|
|
||||||
Name: "Local cfg",
|
|
||||||
Items: localdb.LocalConfigItems{{LotName: "CPU_PULL", Quantity: 1, UnitPrice: 900}},
|
|
||||||
IsActive: true,
|
|
||||||
SyncStatus: "synced",
|
|
||||||
Line: 50,
|
|
||||||
VendorSpec: localSpec,
|
|
||||||
CreatedAt: time.Now().Add(-30 * time.Minute),
|
|
||||||
UpdatedAt: time.Now().Add(-30 * time.Minute),
|
|
||||||
}); err != nil {
|
|
||||||
t.Fatalf("seed local configuration: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
svc := syncsvc.NewServiceWithDB(serverDB, local)
|
svc := syncsvc.NewServiceWithDB(serverDB, local)
|
||||||
if _, err := svc.ImportConfigurationsToLocal(); err != nil {
|
if _, err := svc.ImportConfigurationsToLocal(); err != nil {
|
||||||
t.Fatalf("import configurations to local: %v", err)
|
t.Fatalf("import configurations to local: %v", err)
|
||||||
@@ -369,7 +355,7 @@ func TestImportConfigurationsToLocalPreservesLocalVendorSpec(t *testing.T) {
|
|||||||
t.Fatalf("load local config: %v", err)
|
t.Fatalf("load local config: %v", err)
|
||||||
}
|
}
|
||||||
if len(localCfg.VendorSpec) != 1 {
|
if len(localCfg.VendorSpec) != 1 {
|
||||||
t.Fatalf("expected local vendor_spec preserved, got %d rows", len(localCfg.VendorSpec))
|
t.Fatalf("expected server vendor_spec imported, got %d rows", len(localCfg.VendorSpec))
|
||||||
}
|
}
|
||||||
if localCfg.VendorSpec[0].VendorPartnumber != "GPU-NVHGX-H200-8141" {
|
if localCfg.VendorSpec[0].VendorPartnumber != "GPU-NVHGX-H200-8141" {
|
||||||
t.Fatalf("unexpected vendor_partnumber after import: %q", localCfg.VendorSpec[0].VendorPartnumber)
|
t.Fatalf("unexpected vendor_partnumber after import: %q", localCfg.VendorSpec[0].VendorPartnumber)
|
||||||
@@ -492,6 +478,7 @@ CREATE TABLE qt_configurations (
|
|||||||
only_in_stock INTEGER NOT NULL DEFAULT 0,
|
only_in_stock INTEGER NOT NULL DEFAULT 0,
|
||||||
line_no INTEGER NULL,
|
line_no INTEGER NULL,
|
||||||
price_updated_at DATETIME NULL,
|
price_updated_at DATETIME NULL,
|
||||||
|
vendor_spec TEXT NULL,
|
||||||
created_at DATETIME
|
created_at DATETIME
|
||||||
);`).Error; err != nil {
|
);`).Error; err != nil {
|
||||||
t.Fatalf("create qt_configurations: %v", err)
|
t.Fatalf("create qt_configurations: %v", err)
|
||||||
|
|||||||
560
internal/services/vendor_workspace_import.go
Normal file
560
internal/services/vendor_workspace_import.go
Normal file
@@ -0,0 +1,560 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"bytes"
|
||||||
|
"encoding/xml"
|
||||||
|
"fmt"
|
||||||
|
"path/filepath"
|
||||||
|
"sort"
|
||||||
|
"strconv"
|
||||||
|
"strings"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||||
|
"github.com/google/uuid"
|
||||||
|
"gorm.io/gorm"
|
||||||
|
)
|
||||||
|
|
||||||
|
type VendorWorkspaceImportResult struct {
|
||||||
|
Imported int `json:"imported"`
|
||||||
|
Project *models.Project `json:"project,omitempty"`
|
||||||
|
Configs []VendorWorkspaceImportedConfig `json:"configs"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type VendorWorkspaceImportedConfig struct {
|
||||||
|
UUID string `json:"uuid"`
|
||||||
|
Name string `json:"name"`
|
||||||
|
ServerCount int `json:"server_count"`
|
||||||
|
ServerModel string `json:"server_model,omitempty"`
|
||||||
|
Rows int `json:"rows"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type importedWorkspace struct {
|
||||||
|
SourceFormat string
|
||||||
|
SourceDocID string
|
||||||
|
SourceFileName string
|
||||||
|
CurrencyCode string
|
||||||
|
Configurations []importedConfiguration
|
||||||
|
}
|
||||||
|
|
||||||
|
type importedConfiguration struct {
|
||||||
|
GroupID string
|
||||||
|
Name string
|
||||||
|
Line int
|
||||||
|
ServerCount int
|
||||||
|
ServerModel string
|
||||||
|
Article string
|
||||||
|
CurrencyCode string
|
||||||
|
Rows []localdb.VendorSpecItem
|
||||||
|
TotalPrice *float64
|
||||||
|
}
|
||||||
|
|
||||||
|
type groupedItem struct {
|
||||||
|
order int
|
||||||
|
row cfxmlProductLineItem
|
||||||
|
}
|
||||||
|
|
||||||
|
type cfxmlDocument struct {
|
||||||
|
XMLName xml.Name `xml:"CFXML"`
|
||||||
|
ThisDocumentIdentifier cfxmlDocumentIdentifier `xml:"thisDocumentIdentifier"`
|
||||||
|
CFData cfxmlData `xml:"CFData"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cfxmlDocumentIdentifier struct {
|
||||||
|
ProprietaryDocumentIdentifier string `xml:"ProprietaryDocumentIdentifier"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cfxmlData struct {
|
||||||
|
ProprietaryInformation []cfxmlProprietaryInformation `xml:"ProprietaryInformation"`
|
||||||
|
ProductLineItems []cfxmlProductLineItem `xml:"ProductLineItem"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cfxmlProprietaryInformation struct {
|
||||||
|
Name string `xml:"Name"`
|
||||||
|
Value string `xml:"Value"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cfxmlProductLineItem struct {
|
||||||
|
ProductLineNumber string `xml:"ProductLineNumber"`
|
||||||
|
ItemNo string `xml:"ItemNo"`
|
||||||
|
TransactionType string `xml:"TransactionType"`
|
||||||
|
ProprietaryGroupIdentifier string `xml:"ProprietaryGroupIdentifier"`
|
||||||
|
ConfigurationGroupLineNumberReference string `xml:"ConfigurationGroupLineNumberReference"`
|
||||||
|
Quantity string `xml:"Quantity"`
|
||||||
|
ProductIdentification cfxmlProductIdentification `xml:"ProductIdentification"`
|
||||||
|
UnitListPrice cfxmlUnitListPrice `xml:"UnitListPrice"`
|
||||||
|
ProductSubLineItems []cfxmlProductSubLineItem `xml:"ProductSubLineItem"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cfxmlProductSubLineItem struct {
|
||||||
|
LineNumber string `xml:"LineNumber"`
|
||||||
|
TransactionType string `xml:"TransactionType"`
|
||||||
|
Quantity string `xml:"Quantity"`
|
||||||
|
ProductIdentification cfxmlProductIdentification `xml:"ProductIdentification"`
|
||||||
|
UnitListPrice cfxmlUnitListPrice `xml:"UnitListPrice"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cfxmlProductIdentification struct {
|
||||||
|
PartnerProductIdentification cfxmlPartnerProductIdentification `xml:"PartnerProductIdentification"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cfxmlPartnerProductIdentification struct {
|
||||||
|
ProprietaryProductIdentifier string `xml:"ProprietaryProductIdentifier"`
|
||||||
|
ProprietaryProductChar string `xml:"ProprietaryProductChar"`
|
||||||
|
ProductCharacter string `xml:"ProductCharacter"`
|
||||||
|
ProductDescription string `xml:"ProductDescription"`
|
||||||
|
ProductName string `xml:"ProductName"`
|
||||||
|
ProductTypeCode string `xml:"ProductTypeCode"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cfxmlUnitListPrice struct {
|
||||||
|
FinancialAmount cfxmlFinancialAmount `xml:"FinancialAmount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
type cfxmlFinancialAmount struct {
|
||||||
|
GlobalCurrencyCode string `xml:"GlobalCurrencyCode"`
|
||||||
|
MonetaryAmount string `xml:"MonetaryAmount"`
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID string, sourceFileName string, data []byte, ownerUsername string) (*VendorWorkspaceImportResult, error) {
|
||||||
|
project, err := s.localDB.GetProjectByUUID(projectUUID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, ErrProjectNotFound
|
||||||
|
}
|
||||||
|
|
||||||
|
workspace, err := parseCFXMLWorkspace(data, filepath.Base(sourceFileName))
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
result := &VendorWorkspaceImportResult{
|
||||||
|
Imported: 0,
|
||||||
|
Project: localdb.LocalToProject(project),
|
||||||
|
Configs: make([]VendorWorkspaceImportedConfig, 0, len(workspace.Configurations)),
|
||||||
|
}
|
||||||
|
|
||||||
|
err = s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||||
|
bookRepo := repository.NewPartnumberBookRepository(tx)
|
||||||
|
for _, imported := range workspace.Configurations {
|
||||||
|
now := time.Now()
|
||||||
|
cfgUUID := uuid.NewString()
|
||||||
|
groupRows, items, totalPrice, estimatePricelistID, err := s.prepareImportedConfiguration(imported.Rows, imported.ServerCount, bookRepo)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Errorf("prepare imported configuration group %s: %w", imported.GroupID, err)
|
||||||
|
}
|
||||||
|
localCfg := &localdb.LocalConfiguration{
|
||||||
|
UUID: cfgUUID,
|
||||||
|
ProjectUUID: &projectUUID,
|
||||||
|
IsActive: true,
|
||||||
|
Name: imported.Name,
|
||||||
|
Items: items,
|
||||||
|
TotalPrice: totalPrice,
|
||||||
|
ServerCount: imported.ServerCount,
|
||||||
|
ServerModel: imported.ServerModel,
|
||||||
|
Article: imported.Article,
|
||||||
|
PricelistID: estimatePricelistID,
|
||||||
|
VendorSpec: groupRows,
|
||||||
|
CreatedAt: now,
|
||||||
|
UpdatedAt: now,
|
||||||
|
SyncStatus: "pending",
|
||||||
|
OriginalUsername: ownerUsername,
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := s.createWithVersionTx(tx, localCfg, ownerUsername); err != nil {
|
||||||
|
return fmt.Errorf("import configuration group %s: %w", imported.GroupID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
result.Imported++
|
||||||
|
result.Configs = append(result.Configs, VendorWorkspaceImportedConfig{
|
||||||
|
UUID: localCfg.UUID,
|
||||||
|
Name: localCfg.Name,
|
||||||
|
ServerCount: localCfg.ServerCount,
|
||||||
|
ServerModel: localCfg.ServerModel,
|
||||||
|
Rows: len(localCfg.VendorSpec),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func (s *LocalConfigurationService) prepareImportedConfiguration(rows []localdb.VendorSpecItem, serverCount int, bookRepo *repository.PartnumberBookRepository) (localdb.VendorSpec, localdb.LocalConfigItems, *float64, *uint, error) {
|
||||||
|
resolver := NewVendorSpecResolver(bookRepo)
|
||||||
|
resolved, err := resolver.Resolve(append([]localdb.VendorSpecItem(nil), rows...))
|
||||||
|
if err != nil {
|
||||||
|
return nil, nil, nil, nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
canonical := make(localdb.VendorSpec, 0, len(resolved))
|
||||||
|
for _, row := range resolved {
|
||||||
|
if len(row.LotMappings) == 0 && strings.TrimSpace(row.ResolvedLotName) != "" {
|
||||||
|
row.LotMappings = []localdb.VendorSpecLotMapping{
|
||||||
|
{LotName: strings.TrimSpace(row.ResolvedLotName), QuantityPerPN: 1},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
row.LotMappings = normalizeImportedLotMappings(row.LotMappings)
|
||||||
|
row.ResolvedLotName = ""
|
||||||
|
row.ResolutionSource = ""
|
||||||
|
row.ManualLotSuggestion = ""
|
||||||
|
row.LotQtyPerPN = 0
|
||||||
|
row.LotAllocations = nil
|
||||||
|
canonical = append(canonical, row)
|
||||||
|
}
|
||||||
|
|
||||||
|
estimatePricelist, _ := s.localDB.GetLatestLocalPricelistBySource("estimate")
|
||||||
|
var serverPricelistID *uint
|
||||||
|
if estimatePricelist != nil {
|
||||||
|
serverPricelistID = &estimatePricelist.ServerID
|
||||||
|
}
|
||||||
|
|
||||||
|
items := aggregateVendorSpecToItems(canonical, estimatePricelist, s.localDB)
|
||||||
|
totalValue := items.Total()
|
||||||
|
if serverCount > 1 {
|
||||||
|
totalValue *= float64(serverCount)
|
||||||
|
}
|
||||||
|
totalPrice := &totalValue
|
||||||
|
return canonical, items, totalPrice, serverPricelistID, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func aggregateVendorSpecToItems(spec localdb.VendorSpec, estimatePricelist *localdb.LocalPricelist, local *localdb.LocalDB) localdb.LocalConfigItems {
|
||||||
|
if len(spec) == 0 {
|
||||||
|
return localdb.LocalConfigItems{}
|
||||||
|
}
|
||||||
|
|
||||||
|
lotMap := make(map[string]int)
|
||||||
|
order := make([]string, 0)
|
||||||
|
for _, row := range spec {
|
||||||
|
for _, mapping := range normalizeImportedLotMappings(row.LotMappings) {
|
||||||
|
if _, exists := lotMap[mapping.LotName]; !exists {
|
||||||
|
order = append(order, mapping.LotName)
|
||||||
|
}
|
||||||
|
lotMap[mapping.LotName] += row.Quantity * mapping.QuantityPerPN
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
sort.Strings(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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
items = append(items, localdb.LocalConfigItem{
|
||||||
|
LotName: lotName,
|
||||||
|
Quantity: lotMap[lotName],
|
||||||
|
UnitPrice: unitPrice,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
return items
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeImportedLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
|
||||||
|
if len(in) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
merged := make(map[string]int, len(in))
|
||||||
|
order := make([]string, 0, len(in))
|
||||||
|
for _, mapping := range in {
|
||||||
|
lot := strings.TrimSpace(mapping.LotName)
|
||||||
|
if lot == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
qty := mapping.QuantityPerPN
|
||||||
|
if qty < 1 {
|
||||||
|
qty = 1
|
||||||
|
}
|
||||||
|
if _, exists := merged[lot]; !exists {
|
||||||
|
order = append(order, lot)
|
||||||
|
}
|
||||||
|
merged[lot] += qty
|
||||||
|
}
|
||||||
|
out := make([]localdb.VendorSpecLotMapping, 0, len(order))
|
||||||
|
for _, lot := range order {
|
||||||
|
out = append(out, localdb.VendorSpecLotMapping{
|
||||||
|
LotName: lot,
|
||||||
|
QuantityPerPN: merged[lot],
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if len(out) == 0 {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseCFXMLWorkspace(data []byte, sourceFileName string) (*importedWorkspace, error) {
|
||||||
|
var doc cfxmlDocument
|
||||||
|
if err := xml.Unmarshal(data, &doc); err != nil {
|
||||||
|
return nil, fmt.Errorf("parse CFXML workspace: %w", err)
|
||||||
|
}
|
||||||
|
if doc.XMLName.Local != "CFXML" {
|
||||||
|
return nil, fmt.Errorf("unsupported workspace root: %s", doc.XMLName.Local)
|
||||||
|
}
|
||||||
|
if len(doc.CFData.ProductLineItems) == 0 {
|
||||||
|
return nil, fmt.Errorf("CFXML workspace has no ProductLineItem rows")
|
||||||
|
}
|
||||||
|
|
||||||
|
workspace := &importedWorkspace{
|
||||||
|
SourceFormat: "CFXML",
|
||||||
|
SourceDocID: strings.TrimSpace(doc.ThisDocumentIdentifier.ProprietaryDocumentIdentifier),
|
||||||
|
SourceFileName: sourceFileName,
|
||||||
|
CurrencyCode: detectWorkspaceCurrency(doc.CFData.ProprietaryInformation, doc.CFData.ProductLineItems),
|
||||||
|
}
|
||||||
|
|
||||||
|
type groupBucket struct {
|
||||||
|
order int
|
||||||
|
items []groupedItem
|
||||||
|
}
|
||||||
|
|
||||||
|
groupOrder := make([]string, 0)
|
||||||
|
groups := make(map[string]*groupBucket)
|
||||||
|
for idx, item := range doc.CFData.ProductLineItems {
|
||||||
|
groupID := strings.TrimSpace(item.ProprietaryGroupIdentifier)
|
||||||
|
if groupID == "" {
|
||||||
|
groupID = firstNonEmpty(strings.TrimSpace(item.ProductLineNumber), strings.TrimSpace(item.ItemNo), fmt.Sprintf("group-%d", idx+1))
|
||||||
|
}
|
||||||
|
bucket := groups[groupID]
|
||||||
|
if bucket == nil {
|
||||||
|
bucket = &groupBucket{order: idx}
|
||||||
|
groups[groupID] = bucket
|
||||||
|
groupOrder = append(groupOrder, groupID)
|
||||||
|
}
|
||||||
|
bucket.items = append(bucket.items, groupedItem{order: idx, row: item})
|
||||||
|
}
|
||||||
|
|
||||||
|
for lineIdx, groupID := range groupOrder {
|
||||||
|
bucket := groups[groupID]
|
||||||
|
if bucket == nil || len(bucket.items) == 0 {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
primary := pickPrimaryTopLevelRow(bucket.items)
|
||||||
|
serverCount := maxInt(parseInt(primary.row.Quantity), 1)
|
||||||
|
rows := make([]localdb.VendorSpecItem, 0, len(bucket.items)*4)
|
||||||
|
sortOrder := 10
|
||||||
|
|
||||||
|
for _, item := range bucket.items {
|
||||||
|
topRow := vendorSpecItemFromTopLevel(item.row, serverCount, sortOrder)
|
||||||
|
if topRow != nil {
|
||||||
|
rows = append(rows, *topRow)
|
||||||
|
sortOrder += 10
|
||||||
|
}
|
||||||
|
|
||||||
|
for _, sub := range item.row.ProductSubLineItems {
|
||||||
|
subRow := vendorSpecItemFromSubLine(sub, sortOrder)
|
||||||
|
if subRow == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
rows = append(rows, *subRow)
|
||||||
|
sortOrder += 10
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
total := sumVendorSpecRows(rows, serverCount)
|
||||||
|
name := strings.TrimSpace(primary.row.ProductIdentification.PartnerProductIdentification.ProductName)
|
||||||
|
if name == "" {
|
||||||
|
name = strings.TrimSpace(primary.row.ProductIdentification.PartnerProductIdentification.ProductDescription)
|
||||||
|
}
|
||||||
|
if name == "" {
|
||||||
|
name = fmt.Sprintf("Imported config %d", lineIdx+1)
|
||||||
|
}
|
||||||
|
|
||||||
|
workspace.Configurations = append(workspace.Configurations, importedConfiguration{
|
||||||
|
GroupID: groupID,
|
||||||
|
Name: name,
|
||||||
|
Line: (lineIdx + 1) * 10,
|
||||||
|
ServerCount: serverCount,
|
||||||
|
ServerModel: strings.TrimSpace(primary.row.ProductIdentification.PartnerProductIdentification.ProductDescription),
|
||||||
|
Article: strings.TrimSpace(primary.row.ProductIdentification.PartnerProductIdentification.ProprietaryProductIdentifier),
|
||||||
|
CurrencyCode: workspace.CurrencyCode,
|
||||||
|
Rows: rows,
|
||||||
|
TotalPrice: total,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(workspace.Configurations) == 0 {
|
||||||
|
return nil, fmt.Errorf("CFXML workspace has no importable configuration groups")
|
||||||
|
}
|
||||||
|
|
||||||
|
return workspace, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func detectWorkspaceCurrency(meta []cfxmlProprietaryInformation, rows []cfxmlProductLineItem) string {
|
||||||
|
for _, item := range meta {
|
||||||
|
if strings.EqualFold(strings.TrimSpace(item.Name), "Currencies") {
|
||||||
|
value := strings.TrimSpace(item.Value)
|
||||||
|
if value != "" {
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
for _, row := range rows {
|
||||||
|
code := strings.TrimSpace(row.UnitListPrice.FinancialAmount.GlobalCurrencyCode)
|
||||||
|
if code != "" {
|
||||||
|
return code
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func pickPrimaryTopLevelRow(items []groupedItem) groupedItem {
|
||||||
|
best := items[0]
|
||||||
|
bestScore := primaryScore(best.row)
|
||||||
|
for _, item := range items[1:] {
|
||||||
|
score := primaryScore(item.row)
|
||||||
|
if score > bestScore {
|
||||||
|
best = item
|
||||||
|
bestScore = score
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if score == bestScore && compareLineNumbers(item.row.ProductLineNumber, best.row.ProductLineNumber) < 0 {
|
||||||
|
best = item
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return best
|
||||||
|
}
|
||||||
|
|
||||||
|
func primaryScore(row cfxmlProductLineItem) int {
|
||||||
|
score := len(row.ProductSubLineItems)
|
||||||
|
if strings.EqualFold(strings.TrimSpace(row.ProductIdentification.PartnerProductIdentification.ProductTypeCode), "Hardware") {
|
||||||
|
score += 100000
|
||||||
|
}
|
||||||
|
return score
|
||||||
|
}
|
||||||
|
|
||||||
|
func compareLineNumbers(left, right string) int {
|
||||||
|
li := parseInt(left)
|
||||||
|
ri := parseInt(right)
|
||||||
|
switch {
|
||||||
|
case li < ri:
|
||||||
|
return -1
|
||||||
|
case li > ri:
|
||||||
|
return 1
|
||||||
|
default:
|
||||||
|
return strings.Compare(left, right)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func vendorSpecItemFromTopLevel(item cfxmlProductLineItem, serverCount int, sortOrder int) *localdb.VendorSpecItem {
|
||||||
|
code := strings.TrimSpace(item.ProductIdentification.PartnerProductIdentification.ProprietaryProductIdentifier)
|
||||||
|
desc := strings.TrimSpace(item.ProductIdentification.PartnerProductIdentification.ProductDescription)
|
||||||
|
if code == "" && desc == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
qty := normalizeTopLevelQuantity(item.Quantity, serverCount)
|
||||||
|
unitPrice := parseOptionalFloat(item.UnitListPrice.FinancialAmount.MonetaryAmount)
|
||||||
|
return &localdb.VendorSpecItem{
|
||||||
|
SortOrder: sortOrder,
|
||||||
|
VendorPartnumber: code,
|
||||||
|
Quantity: qty,
|
||||||
|
Description: desc,
|
||||||
|
UnitPrice: unitPrice,
|
||||||
|
TotalPrice: totalPrice(unitPrice, qty),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func vendorSpecItemFromSubLine(item cfxmlProductSubLineItem, sortOrder int) *localdb.VendorSpecItem {
|
||||||
|
code := strings.TrimSpace(item.ProductIdentification.PartnerProductIdentification.ProprietaryProductIdentifier)
|
||||||
|
desc := strings.TrimSpace(item.ProductIdentification.PartnerProductIdentification.ProductDescription)
|
||||||
|
if code == "" && desc == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
qty := maxInt(parseInt(item.Quantity), 1)
|
||||||
|
unitPrice := parseOptionalFloat(item.UnitListPrice.FinancialAmount.MonetaryAmount)
|
||||||
|
return &localdb.VendorSpecItem{
|
||||||
|
SortOrder: sortOrder,
|
||||||
|
VendorPartnumber: code,
|
||||||
|
Quantity: qty,
|
||||||
|
Description: desc,
|
||||||
|
UnitPrice: unitPrice,
|
||||||
|
TotalPrice: totalPrice(unitPrice, qty),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func sumVendorSpecRows(rows []localdb.VendorSpecItem, serverCount int) *float64 {
|
||||||
|
total := 0.0
|
||||||
|
hasTotal := false
|
||||||
|
for _, row := range rows {
|
||||||
|
if row.TotalPrice == nil {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
total += *row.TotalPrice
|
||||||
|
hasTotal = true
|
||||||
|
}
|
||||||
|
if !hasTotal {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if serverCount > 1 {
|
||||||
|
total *= float64(serverCount)
|
||||||
|
}
|
||||||
|
return &total
|
||||||
|
}
|
||||||
|
|
||||||
|
func totalPrice(unitPrice *float64, qty int) *float64 {
|
||||||
|
if unitPrice == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
total := *unitPrice * float64(qty)
|
||||||
|
return &total
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseOptionalFloat(raw string) *float64 {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if trimmed == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
value, err := strconv.ParseFloat(trimmed, 64)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &value
|
||||||
|
}
|
||||||
|
|
||||||
|
func parseInt(raw string) int {
|
||||||
|
trimmed := strings.TrimSpace(raw)
|
||||||
|
if trimmed == "" {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
value, err := strconv.Atoi(trimmed)
|
||||||
|
if err != nil {
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func firstNonEmpty(values ...string) string {
|
||||||
|
for _, value := range values {
|
||||||
|
if strings.TrimSpace(value) != "" {
|
||||||
|
return strings.TrimSpace(value)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
|
||||||
|
func maxInt(value, floor int) int {
|
||||||
|
if value < floor {
|
||||||
|
return floor
|
||||||
|
}
|
||||||
|
return value
|
||||||
|
}
|
||||||
|
|
||||||
|
func normalizeTopLevelQuantity(raw string, serverCount int) int {
|
||||||
|
qty := maxInt(parseInt(raw), 1)
|
||||||
|
if serverCount <= 1 {
|
||||||
|
return qty
|
||||||
|
}
|
||||||
|
if qty%serverCount == 0 {
|
||||||
|
return maxInt(qty/serverCount, 1)
|
||||||
|
}
|
||||||
|
return qty
|
||||||
|
}
|
||||||
|
|
||||||
|
func IsCFXMLWorkspace(data []byte) bool {
|
||||||
|
return bytes.Contains(data, []byte("<CFXML>")) || bytes.Contains(data, []byte("<CFXML "))
|
||||||
|
}
|
||||||
363
internal/services/vendor_workspace_import_test.go
Normal file
363
internal/services/vendor_workspace_import_test.go
Normal file
@@ -0,0 +1,363 @@
|
|||||||
|
package services
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||||
|
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestParseCFXMLWorkspaceGroupsSoftwareIntoConfiguration(t *testing.T) {
|
||||||
|
const sample = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<CFXML>
|
||||||
|
<thisDocumentIdentifier>
|
||||||
|
<ProprietaryDocumentIdentifier>CFXML.workspace-test</ProprietaryDocumentIdentifier>
|
||||||
|
</thisDocumentIdentifier>
|
||||||
|
<CFData>
|
||||||
|
<ProprietaryInformation>
|
||||||
|
<Name>Currencies</Name>
|
||||||
|
<Value>USD</Value>
|
||||||
|
</ProprietaryInformation>
|
||||||
|
<ProductLineItem>
|
||||||
|
<ProductLineNumber>1000</ProductLineNumber>
|
||||||
|
<ItemNo>1000</ItemNo>
|
||||||
|
<TransactionType>NEW</TransactionType>
|
||||||
|
<ProprietaryGroupIdentifier>1000</ProprietaryGroupIdentifier>
|
||||||
|
<ConfigurationGroupLineNumberReference>100</ConfigurationGroupLineNumberReference>
|
||||||
|
<Quantity>6</Quantity>
|
||||||
|
<ProductIdentification>
|
||||||
|
<PartnerProductIdentification>
|
||||||
|
<ProprietaryProductIdentifier>7DG9-CTO1WW</ProprietaryProductIdentifier>
|
||||||
|
<ProductDescription>ThinkSystem SR630 V4</ProductDescription>
|
||||||
|
<ProductName>#1</ProductName>
|
||||||
|
<ProductTypeCode>Hardware</ProductTypeCode>
|
||||||
|
</PartnerProductIdentification>
|
||||||
|
</ProductIdentification>
|
||||||
|
<UnitListPrice>
|
||||||
|
<FinancialAmount>
|
||||||
|
<GlobalCurrencyCode>USD</GlobalCurrencyCode>
|
||||||
|
<MonetaryAmount>100.00</MonetaryAmount>
|
||||||
|
</FinancialAmount>
|
||||||
|
</UnitListPrice>
|
||||||
|
<ProductSubLineItem>
|
||||||
|
<LineNumber>1001</LineNumber>
|
||||||
|
<TransactionType>ADD</TransactionType>
|
||||||
|
<Quantity>2</Quantity>
|
||||||
|
<ProductIdentification>
|
||||||
|
<PartnerProductIdentification>
|
||||||
|
<ProprietaryProductIdentifier>CPU-1</ProprietaryProductIdentifier>
|
||||||
|
<ProductDescription>CPU</ProductDescription>
|
||||||
|
<ProductCharacter>PROCESSOR</ProductCharacter>
|
||||||
|
</PartnerProductIdentification>
|
||||||
|
</ProductIdentification>
|
||||||
|
<UnitListPrice>
|
||||||
|
<FinancialAmount>
|
||||||
|
<GlobalCurrencyCode>USD</GlobalCurrencyCode>
|
||||||
|
<MonetaryAmount>0</MonetaryAmount>
|
||||||
|
</FinancialAmount>
|
||||||
|
</UnitListPrice>
|
||||||
|
</ProductSubLineItem>
|
||||||
|
</ProductLineItem>
|
||||||
|
<ProductLineItem>
|
||||||
|
<ProductLineNumber>2000</ProductLineNumber>
|
||||||
|
<ItemNo>2000</ItemNo>
|
||||||
|
<TransactionType>NEW</TransactionType>
|
||||||
|
<ProprietaryGroupIdentifier>1000</ProprietaryGroupIdentifier>
|
||||||
|
<ConfigurationGroupLineNumberReference>100</ConfigurationGroupLineNumberReference>
|
||||||
|
<Quantity>6</Quantity>
|
||||||
|
<ProductIdentification>
|
||||||
|
<PartnerProductIdentification>
|
||||||
|
<ProprietaryProductIdentifier>7S0X-CTO8WW</ProprietaryProductIdentifier>
|
||||||
|
<ProductDescription>XClarity Controller Prem-FOD</ProductDescription>
|
||||||
|
<ProductName>software1</ProductName>
|
||||||
|
<ProductTypeCode>Software</ProductTypeCode>
|
||||||
|
</PartnerProductIdentification>
|
||||||
|
</ProductIdentification>
|
||||||
|
<UnitListPrice>
|
||||||
|
<FinancialAmount>
|
||||||
|
<GlobalCurrencyCode>USD</GlobalCurrencyCode>
|
||||||
|
<MonetaryAmount>25.00</MonetaryAmount>
|
||||||
|
</FinancialAmount>
|
||||||
|
</UnitListPrice>
|
||||||
|
<ProductSubLineItem>
|
||||||
|
<LineNumber>2001</LineNumber>
|
||||||
|
<TransactionType>ADD</TransactionType>
|
||||||
|
<Quantity>1</Quantity>
|
||||||
|
<ProductIdentification>
|
||||||
|
<PartnerProductIdentification>
|
||||||
|
<ProprietaryProductIdentifier>LIC-1</ProprietaryProductIdentifier>
|
||||||
|
<ProductDescription>License</ProductDescription>
|
||||||
|
<ProductCharacter>SOFTWARE</ProductCharacter>
|
||||||
|
</PartnerProductIdentification>
|
||||||
|
</ProductIdentification>
|
||||||
|
<UnitListPrice>
|
||||||
|
<FinancialAmount>
|
||||||
|
<GlobalCurrencyCode>USD</GlobalCurrencyCode>
|
||||||
|
<MonetaryAmount>0</MonetaryAmount>
|
||||||
|
</FinancialAmount>
|
||||||
|
</UnitListPrice>
|
||||||
|
</ProductSubLineItem>
|
||||||
|
</ProductLineItem>
|
||||||
|
<ProductLineItem>
|
||||||
|
<ProductLineNumber>3000</ProductLineNumber>
|
||||||
|
<ItemNo>3000</ItemNo>
|
||||||
|
<TransactionType>NEW</TransactionType>
|
||||||
|
<ProprietaryGroupIdentifier>3000</ProprietaryGroupIdentifier>
|
||||||
|
<ConfigurationGroupLineNumberReference>100</ConfigurationGroupLineNumberReference>
|
||||||
|
<Quantity>2</Quantity>
|
||||||
|
<ProductIdentification>
|
||||||
|
<PartnerProductIdentification>
|
||||||
|
<ProprietaryProductIdentifier>7DG9-CTO1WW</ProprietaryProductIdentifier>
|
||||||
|
<ProductDescription>ThinkSystem SR630 V4</ProductDescription>
|
||||||
|
<ProductName>#2</ProductName>
|
||||||
|
<ProductTypeCode>Hardware</ProductTypeCode>
|
||||||
|
</PartnerProductIdentification>
|
||||||
|
</ProductIdentification>
|
||||||
|
<UnitListPrice>
|
||||||
|
<FinancialAmount>
|
||||||
|
<GlobalCurrencyCode>USD</GlobalCurrencyCode>
|
||||||
|
<MonetaryAmount>90.00</MonetaryAmount>
|
||||||
|
</FinancialAmount>
|
||||||
|
</UnitListPrice>
|
||||||
|
</ProductLineItem>
|
||||||
|
</CFData>
|
||||||
|
</CFXML>`
|
||||||
|
|
||||||
|
workspace, err := parseCFXMLWorkspace([]byte(sample), "sample.xml")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("parseCFXMLWorkspace: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if workspace.SourceFormat != "CFXML" {
|
||||||
|
t.Fatalf("unexpected source format: %q", workspace.SourceFormat)
|
||||||
|
}
|
||||||
|
if len(workspace.Configurations) != 2 {
|
||||||
|
t.Fatalf("expected 2 configurations, got %d", len(workspace.Configurations))
|
||||||
|
}
|
||||||
|
|
||||||
|
first := workspace.Configurations[0]
|
||||||
|
if first.GroupID != "1000" {
|
||||||
|
t.Fatalf("expected first group 1000, got %q", first.GroupID)
|
||||||
|
}
|
||||||
|
if first.Name != "#1" {
|
||||||
|
t.Fatalf("expected first config name #1, got %q", first.Name)
|
||||||
|
}
|
||||||
|
if first.ServerCount != 6 {
|
||||||
|
t.Fatalf("expected first server count 6, got %d", first.ServerCount)
|
||||||
|
}
|
||||||
|
if len(first.Rows) != 4 {
|
||||||
|
t.Fatalf("expected 4 vendor rows in first config, got %d", len(first.Rows))
|
||||||
|
}
|
||||||
|
|
||||||
|
foundSoftwareTopLevel := false
|
||||||
|
foundSoftwareSubRow := false
|
||||||
|
foundPrimaryTopLevelQty := 0
|
||||||
|
for _, row := range first.Rows {
|
||||||
|
if row.VendorPartnumber == "7DG9-CTO1WW" {
|
||||||
|
foundPrimaryTopLevelQty = row.Quantity
|
||||||
|
}
|
||||||
|
if row.VendorPartnumber == "7S0X-CTO8WW" {
|
||||||
|
foundSoftwareTopLevel = true
|
||||||
|
}
|
||||||
|
if row.VendorPartnumber == "LIC-1" {
|
||||||
|
foundSoftwareSubRow = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundSoftwareTopLevel {
|
||||||
|
t.Fatalf("expected software top-level row to stay inside configuration")
|
||||||
|
}
|
||||||
|
if !foundSoftwareSubRow {
|
||||||
|
t.Fatalf("expected software sub-row to stay inside configuration")
|
||||||
|
}
|
||||||
|
if foundPrimaryTopLevelQty != 1 {
|
||||||
|
t.Fatalf("expected primary top-level qty normalized to 1, got %d", foundPrimaryTopLevelQty)
|
||||||
|
}
|
||||||
|
if first.TotalPrice == nil || *first.TotalPrice != 750 {
|
||||||
|
t.Fatalf("expected first total price 750, got %+v", first.TotalPrice)
|
||||||
|
}
|
||||||
|
|
||||||
|
second := workspace.Configurations[1]
|
||||||
|
if second.Name != "#2" {
|
||||||
|
t.Fatalf("expected second config name #2, got %q", second.Name)
|
||||||
|
}
|
||||||
|
if len(second.Rows) != 1 {
|
||||||
|
t.Fatalf("expected second config to contain single top-level row, got %d", len(second.Rows))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestImportVendorWorkspaceToProject_AutoResolvesAndAppliesEstimate(t *testing.T) {
|
||||||
|
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("init local db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = local.Close() })
|
||||||
|
|
||||||
|
projectName := "OPS-2079"
|
||||||
|
if err := local.SaveProject(&localdb.LocalProject{
|
||||||
|
UUID: "project-1",
|
||||||
|
OwnerUsername: "tester",
|
||||||
|
Code: "OPS-2079",
|
||||||
|
Variant: "",
|
||||||
|
Name: &projectName,
|
||||||
|
IsActive: true,
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
UpdatedAt: time.Now(),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("save project: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||||
|
ServerID: 101,
|
||||||
|
Source: "estimate",
|
||||||
|
Version: "E-1",
|
||||||
|
Name: "Estimate",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
SyncedAt: time.Now(),
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("save estimate pricelist: %v", err)
|
||||||
|
}
|
||||||
|
estimatePL, err := local.GetLocalPricelistByServerID(101)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("get estimate pricelist: %v", err)
|
||||||
|
}
|
||||||
|
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
||||||
|
{PricelistID: estimatePL.ID, LotName: "CPU_INTEL_6747P", Price: 1000},
|
||||||
|
{PricelistID: estimatePL.ID, LotName: "LICENSE_XCC", Price: 50},
|
||||||
|
}); err != nil {
|
||||||
|
t.Fatalf("save estimate items: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
bookRepo := local.DB()
|
||||||
|
if err := bookRepo.Create(&localdb.LocalPartnumberBook{
|
||||||
|
ServerID: 501,
|
||||||
|
Version: "B-1",
|
||||||
|
CreatedAt: time.Now(),
|
||||||
|
IsActive: true,
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("save active book: %v", err)
|
||||||
|
}
|
||||||
|
var book localdb.LocalPartnumberBook
|
||||||
|
if err := bookRepo.Where("server_id = ?", 501).First(&book).Error; err != nil {
|
||||||
|
t.Fatalf("load active book: %v", err)
|
||||||
|
}
|
||||||
|
if err := bookRepo.Create([]localdb.LocalPartnumberBookItem{
|
||||||
|
{BookID: book.ID, Partnumber: "CPU-1", LotName: "CPU_INTEL_6747P", IsPrimaryPN: true},
|
||||||
|
{BookID: book.ID, Partnumber: "LIC-1", LotName: "LICENSE_XCC", IsPrimaryPN: true},
|
||||||
|
}).Error; err != nil {
|
||||||
|
t.Fatalf("save book items: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
service := NewLocalConfigurationService(local, syncsvc.NewService(nil, local), &QuoteService{}, func() bool { return false })
|
||||||
|
|
||||||
|
const sample = `<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<CFXML>
|
||||||
|
<thisDocumentIdentifier>
|
||||||
|
<ProprietaryDocumentIdentifier>CFXML.workspace-test</ProprietaryDocumentIdentifier>
|
||||||
|
</thisDocumentIdentifier>
|
||||||
|
<CFData>
|
||||||
|
<ProductLineItem>
|
||||||
|
<ProductLineNumber>1000</ProductLineNumber>
|
||||||
|
<ItemNo>1000</ItemNo>
|
||||||
|
<TransactionType>NEW</TransactionType>
|
||||||
|
<ProprietaryGroupIdentifier>1000</ProprietaryGroupIdentifier>
|
||||||
|
<Quantity>2</Quantity>
|
||||||
|
<ProductIdentification>
|
||||||
|
<PartnerProductIdentification>
|
||||||
|
<ProprietaryProductIdentifier>7DG9-CTO1WW</ProprietaryProductIdentifier>
|
||||||
|
<ProductDescription>ThinkSystem SR630 V4</ProductDescription>
|
||||||
|
<ProductName>#1</ProductName>
|
||||||
|
<ProductTypeCode>Hardware</ProductTypeCode>
|
||||||
|
</PartnerProductIdentification>
|
||||||
|
</ProductIdentification>
|
||||||
|
<ProductSubLineItem>
|
||||||
|
<LineNumber>1001</LineNumber>
|
||||||
|
<Quantity>2</Quantity>
|
||||||
|
<ProductIdentification>
|
||||||
|
<PartnerProductIdentification>
|
||||||
|
<ProprietaryProductIdentifier>CPU-1</ProprietaryProductIdentifier>
|
||||||
|
<ProductDescription>CPU</ProductDescription>
|
||||||
|
</PartnerProductIdentification>
|
||||||
|
</ProductIdentification>
|
||||||
|
</ProductSubLineItem>
|
||||||
|
</ProductLineItem>
|
||||||
|
<ProductLineItem>
|
||||||
|
<ProductLineNumber>2000</ProductLineNumber>
|
||||||
|
<ItemNo>2000</ItemNo>
|
||||||
|
<TransactionType>NEW</TransactionType>
|
||||||
|
<ProprietaryGroupIdentifier>1000</ProprietaryGroupIdentifier>
|
||||||
|
<Quantity>2</Quantity>
|
||||||
|
<ProductIdentification>
|
||||||
|
<PartnerProductIdentification>
|
||||||
|
<ProprietaryProductIdentifier>7S0X-CTO8WW</ProprietaryProductIdentifier>
|
||||||
|
<ProductDescription>XClarity Controller</ProductDescription>
|
||||||
|
<ProductName>software1</ProductName>
|
||||||
|
<ProductTypeCode>Software</ProductTypeCode>
|
||||||
|
</PartnerProductIdentification>
|
||||||
|
</ProductIdentification>
|
||||||
|
<ProductSubLineItem>
|
||||||
|
<LineNumber>2001</LineNumber>
|
||||||
|
<Quantity>1</Quantity>
|
||||||
|
<ProductIdentification>
|
||||||
|
<PartnerProductIdentification>
|
||||||
|
<ProprietaryProductIdentifier>LIC-1</ProprietaryProductIdentifier>
|
||||||
|
<ProductDescription>License</ProductDescription>
|
||||||
|
</PartnerProductIdentification>
|
||||||
|
</ProductIdentification>
|
||||||
|
</ProductSubLineItem>
|
||||||
|
</ProductLineItem>
|
||||||
|
</CFData>
|
||||||
|
</CFXML>`
|
||||||
|
|
||||||
|
result, err := service.ImportVendorWorkspaceToProject("project-1", "sample.xml", []byte(sample), "tester")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ImportVendorWorkspaceToProject: %v", err)
|
||||||
|
}
|
||||||
|
if result.Imported != 1 || len(result.Configs) != 1 {
|
||||||
|
t.Fatalf("unexpected import result: %+v", result)
|
||||||
|
}
|
||||||
|
|
||||||
|
cfg, err := local.GetConfigurationByUUID(result.Configs[0].UUID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load imported config: %v", err)
|
||||||
|
}
|
||||||
|
if cfg.PricelistID == nil || *cfg.PricelistID != 101 {
|
||||||
|
t.Fatalf("expected estimate pricelist id 101, got %+v", cfg.PricelistID)
|
||||||
|
}
|
||||||
|
if len(cfg.VendorSpec) != 4 {
|
||||||
|
t.Fatalf("expected 4 vendor spec rows, got %d", len(cfg.VendorSpec))
|
||||||
|
}
|
||||||
|
if len(cfg.Items) != 2 {
|
||||||
|
t.Fatalf("expected 2 cart items, got %d", len(cfg.Items))
|
||||||
|
}
|
||||||
|
if cfg.Items[0].LotName != "CPU_INTEL_6747P" || cfg.Items[0].Quantity != 2 || cfg.Items[0].UnitPrice != 1000 {
|
||||||
|
t.Fatalf("unexpected first item: %+v", cfg.Items[0])
|
||||||
|
}
|
||||||
|
if cfg.Items[1].LotName != "LICENSE_XCC" || cfg.Items[1].Quantity != 1 || cfg.Items[1].UnitPrice != 50 {
|
||||||
|
t.Fatalf("unexpected second item: %+v", cfg.Items[1])
|
||||||
|
}
|
||||||
|
if cfg.TotalPrice == nil || *cfg.TotalPrice != 4100 {
|
||||||
|
t.Fatalf("expected total price 4100 for 2 servers, got %+v", cfg.TotalPrice)
|
||||||
|
}
|
||||||
|
|
||||||
|
foundCPU := false
|
||||||
|
foundLIC := false
|
||||||
|
for _, row := range cfg.VendorSpec {
|
||||||
|
if row.VendorPartnumber == "CPU-1" {
|
||||||
|
foundCPU = true
|
||||||
|
if len(row.LotMappings) != 1 || row.LotMappings[0].LotName != "CPU_INTEL_6747P" || row.LotMappings[0].QuantityPerPN != 1 {
|
||||||
|
t.Fatalf("unexpected CPU mappings: %+v", row.LotMappings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if row.VendorPartnumber == "LIC-1" {
|
||||||
|
foundLIC = true
|
||||||
|
if len(row.LotMappings) != 1 || row.LotMappings[0].LotName != "LICENSE_XCC" || row.LotMappings[0].QuantityPerPN != 1 {
|
||||||
|
t.Fatalf("unexpected LIC mappings: %+v", row.LotMappings)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if !foundCPU || !foundLIC {
|
||||||
|
t.Fatalf("expected resolved rows for CPU and LIC in vendor spec")
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -20,7 +20,7 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="max-w-md">
|
<div class="max-w-md">
|
||||||
<input id="configs-search" type="text" placeholder="Поиск квоты по названию"
|
<input id="configs-search" type="text" placeholder="Поиск конфигурации по названию"
|
||||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -141,7 +141,7 @@
|
|||||||
|
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="text-sm text-gray-600">
|
<div class="text-sm text-gray-600">
|
||||||
Квота: <span id="move-project-config-name" class="font-medium text-gray-900"></span>
|
Конфигурация: <span id="move-project-config-name" class="font-medium text-gray-900"></span>
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Проект</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Проект</label>
|
||||||
@@ -174,7 +174,7 @@
|
|||||||
<div id="create-project-on-move-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
<div id="create-project-on-move-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||||
<h2 class="text-xl font-semibold mb-3">Проект не найден</h2>
|
<h2 class="text-xl font-semibold mb-3">Проект не найден</h2>
|
||||||
<p class="text-sm text-gray-600 mb-4">Проект с кодом "<span id="create-project-on-move-code" class="font-medium text-gray-900"></span>" не найден. <span id="create-project-on-move-description">Создать и привязать квоту?</span></p>
|
<p class="text-sm text-gray-600 mb-4">Проект с кодом "<span id="create-project-on-move-code" class="font-medium text-gray-900"></span>" не найден. <span id="create-project-on-move-description">Создать и привязать конфигурацию?</span></p>
|
||||||
<div class="mb-4">
|
<div class="mb-4">
|
||||||
<label for="create-project-on-move-name" class="block text-sm font-medium text-gray-700 mb-1">Название проекта</label>
|
<label for="create-project-on-move-name" class="block text-sm font-medium text-gray-700 mb-1">Название проекта</label>
|
||||||
<input id="create-project-on-move-name" type="text" placeholder="Например: Инфраструктура для OPS-123"
|
<input id="create-project-on-move-name" type="text" placeholder="Например: Инфраструктура для OPS-123"
|
||||||
@@ -601,7 +601,7 @@ function openCreateProjectOnMoveModal(projectName) {
|
|||||||
document.getElementById('create-project-on-move-code').textContent = projectName;
|
document.getElementById('create-project-on-move-code').textContent = projectName;
|
||||||
document.getElementById('create-project-on-move-name').value = projectName;
|
document.getElementById('create-project-on-move-name').value = projectName;
|
||||||
document.getElementById('create-project-on-move-variant').value = '';
|
document.getElementById('create-project-on-move-variant').value = '';
|
||||||
document.getElementById('create-project-on-move-description').textContent = 'Создать и привязать квоту?';
|
document.getElementById('create-project-on-move-description').textContent = 'Создать и привязать конфигурацию?';
|
||||||
document.getElementById('create-project-on-move-confirm-btn').textContent = 'Создать и привязать';
|
document.getElementById('create-project-on-move-confirm-btn').textContent = 'Создать и привязать';
|
||||||
document.getElementById('create-project-on-move-modal').classList.remove('hidden');
|
document.getElementById('create-project-on-move-modal').classList.remove('hidden');
|
||||||
document.getElementById('create-project-on-move-modal').classList.add('flex');
|
document.getElementById('create-project-on-move-modal').classList.add('flex');
|
||||||
@@ -719,7 +719,7 @@ async function moveConfigToProject(uuid, projectUUID) {
|
|||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const err = await resp.json();
|
const err = await resp.json();
|
||||||
alert('Не удалось перенести квоту: ' + (err.error || 'ошибка'));
|
alert('Не удалось перенести конфигурацию: ' + (err.error || 'ошибка'));
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
closeMoveProjectModal();
|
closeMoveProjectModal();
|
||||||
@@ -727,7 +727,7 @@ async function moveConfigToProject(uuid, projectUUID) {
|
|||||||
await loadConfigs();
|
await loadConfigs();
|
||||||
return true;
|
return true;
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
alert('Ошибка переноса квоты');
|
alert('Ошибка переноса конфигурации');
|
||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -755,6 +755,9 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
document.getElementById('server-count').value = serverCount;
|
document.getElementById('server-count').value = serverCount;
|
||||||
document.getElementById('total-server-count').textContent = serverCount;
|
document.getElementById('total-server-count').textContent = serverCount;
|
||||||
selectedPricelistIds.estimate = config.pricelist_id || null;
|
selectedPricelistIds.estimate = config.pricelist_id || null;
|
||||||
|
selectedPricelistIds.warehouse = config.warehouse_pricelist_id || null;
|
||||||
|
selectedPricelistIds.competitor = config.competitor_pricelist_id || null;
|
||||||
|
disablePriceRefresh = Boolean(config.disable_price_refresh);
|
||||||
onlyInStock = Boolean(config.only_in_stock);
|
onlyInStock = Boolean(config.only_in_stock);
|
||||||
|
|
||||||
if (config.items && config.items.length > 0) {
|
if (config.items && config.items.length > 0) {
|
||||||
@@ -1983,6 +1986,9 @@ function buildSavePayload() {
|
|||||||
support_code: supportCode,
|
support_code: supportCode,
|
||||||
article: getCurrentArticle(),
|
article: getCurrentArticle(),
|
||||||
pricelist_id: selectedPricelistIds.estimate,
|
pricelist_id: selectedPricelistIds.estimate,
|
||||||
|
warehouse_pricelist_id: selectedPricelistIds.warehouse,
|
||||||
|
competitor_pricelist_id: selectedPricelistIds.competitor,
|
||||||
|
disable_price_refresh: disablePriceRefresh,
|
||||||
only_in_stock: onlyInStock
|
only_in_stock: onlyInStock
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -29,30 +29,6 @@
|
|||||||
Нет активного листа сопоставлений. Нажмите «Синхронизировать» для загрузки с сервера.
|
Нет активного листа сопоставлений. Нажмите «Синхронизировать» для загрузки с сервера.
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Active book items with search -->
|
|
||||||
<div id="active-book-section" class="hidden bg-white rounded-lg shadow overflow-hidden">
|
|
||||||
<div class="px-4 py-3 border-b flex items-center justify-between gap-3">
|
|
||||||
<span class="font-semibold text-gray-800 whitespace-nowrap">Сопоставления активного листа</span>
|
|
||||||
<input type="text" id="pn-search" placeholder="Поиск по PN или LOT..."
|
|
||||||
class="flex-1 max-w-xs px-3 py-1.5 border rounded text-sm focus:ring-2 focus:ring-blue-400 focus:outline-none"
|
|
||||||
oninput="filterItems(this.value)">
|
|
||||||
<span id="pn-count" class="text-xs text-gray-400 whitespace-nowrap"></span>
|
|
||||||
</div>
|
|
||||||
<div class="overflow-x-auto">
|
|
||||||
<table class="w-full text-sm">
|
|
||||||
<thead class="bg-gray-50 text-gray-600 sticky top-0">
|
|
||||||
<tr>
|
|
||||||
<th class="px-4 py-2 text-left">Partnumber</th>
|
|
||||||
<th class="px-4 py-2 text-left">LOT</th>
|
|
||||||
<th class="px-4 py-2 text-center w-24">Primary</th>
|
|
||||||
<th class="px-4 py-2 text-left">Описание</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody id="active-items-body"></tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- All books list (collapsed by default) -->
|
<!-- All books list (collapsed by default) -->
|
||||||
<div class="bg-white rounded-lg shadow overflow-hidden">
|
<div class="bg-white rounded-lg shadow overflow-hidden">
|
||||||
<!-- Header row — always visible -->
|
<!-- Header row — always visible -->
|
||||||
@@ -92,14 +68,51 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Active book items with search -->
|
||||||
|
<div id="active-book-section" class="hidden bg-white rounded-lg shadow overflow-hidden">
|
||||||
|
<div class="px-4 py-3 border-b flex items-center justify-between gap-3">
|
||||||
|
<span class="font-semibold text-gray-800 whitespace-nowrap">Сопоставления активного листа</span>
|
||||||
|
<input type="text" id="pn-search" placeholder="Поиск по PN или LOT..."
|
||||||
|
class="flex-1 max-w-xs px-3 py-1.5 border rounded text-sm focus:ring-2 focus:ring-blue-400 focus:outline-none"
|
||||||
|
oninput="onItemsSearchInput(this.value)">
|
||||||
|
<span id="pn-count" class="text-xs text-gray-400 whitespace-nowrap"></span>
|
||||||
|
</div>
|
||||||
|
<div class="overflow-x-auto">
|
||||||
|
<table class="w-full text-sm">
|
||||||
|
<thead class="bg-gray-50 text-gray-600 sticky top-0">
|
||||||
|
<tr>
|
||||||
|
<th class="px-4 py-2 text-left">Partnumber</th>
|
||||||
|
<th class="px-4 py-2 text-left">LOT</th>
|
||||||
|
<th class="px-4 py-2 text-center w-24">Primary</th>
|
||||||
|
<th class="px-4 py-2 text-left">Описание</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody id="active-items-body"></tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
<div id="items-pagination" class="hidden px-4 py-3 border-t flex items-center justify-between text-sm text-gray-600">
|
||||||
|
<span id="items-page-info"></span>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button id="items-prev" onclick="changeItemsPage(-1)" class="px-3 py-1 rounded border hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed">← Назад</button>
|
||||||
|
<button id="items-next" onclick="changeItemsPage(1)" class="px-3 py-1 rounded border hover:bg-gray-50 disabled:opacity-40 disabled:cursor-not-allowed">Вперёд →</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
<script>
|
<script>
|
||||||
let allItems = [];
|
|
||||||
let allBooks = [];
|
let allBooks = [];
|
||||||
let booksPage = 1;
|
let booksPage = 1;
|
||||||
const BOOKS_PER_PAGE = 10;
|
const BOOKS_PER_PAGE = 10;
|
||||||
|
const ITEMS_PER_PAGE = 100;
|
||||||
|
let activeBookServerID = null;
|
||||||
|
let activeItems = [];
|
||||||
|
let itemsPage = 1;
|
||||||
|
let itemsTotal = 0;
|
||||||
|
let itemsSearch = '';
|
||||||
|
let _itemsSearchTimer = null;
|
||||||
|
|
||||||
function toggleBooksSection() {
|
function toggleBooksSection() {
|
||||||
const body = document.getElementById('books-section-body');
|
const body = document.getElementById('books-section-body');
|
||||||
@@ -179,29 +192,46 @@ function changeBooksPage(delta) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function loadActiveBookItems(book) {
|
async function loadActiveBookItems(book) {
|
||||||
|
activeBookServerID = book.server_id;
|
||||||
|
return loadActiveBookItemsPage(1, itemsSearch, book);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadActiveBookItemsPage(page = 1, search = '', book = null) {
|
||||||
|
const targetBook = book || allBooks.find(b => b.server_id === activeBookServerID);
|
||||||
|
if (!targetBook) return;
|
||||||
|
|
||||||
let resp, data;
|
let resp, data;
|
||||||
try {
|
try {
|
||||||
resp = await fetch(`/api/partnumber-books/${book.server_id}`);
|
const params = new URLSearchParams({
|
||||||
|
page: String(page),
|
||||||
|
per_page: String(ITEMS_PER_PAGE),
|
||||||
|
});
|
||||||
|
if (search.trim()) {
|
||||||
|
params.set('search', search.trim());
|
||||||
|
}
|
||||||
|
resp = await fetch(`/api/partnumber-books/${targetBook.server_id}?` + params.toString());
|
||||||
data = await resp.json();
|
data = await resp.json();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!resp.ok) return;
|
if (!resp.ok) return;
|
||||||
|
|
||||||
allItems = data.items || [];
|
activeItems = data.items || [];
|
||||||
|
itemsPage = data.page || page;
|
||||||
|
itemsTotal = Number(data.total || 0);
|
||||||
|
itemsSearch = data.search || search || '';
|
||||||
|
|
||||||
const lots = new Set(allItems.map(i => i.lot_name));
|
document.getElementById('card-version').textContent = targetBook.version;
|
||||||
const primaryCount = allItems.filter(i => i.is_primary_pn).length;
|
document.getElementById('card-date').textContent = targetBook.created_at;
|
||||||
|
document.getElementById('card-lots').textContent = Number(data.lot_count || 0);
|
||||||
document.getElementById('card-version').textContent = book.version;
|
document.getElementById('card-pn-total').textContent = Number(data.book_total || 0);
|
||||||
document.getElementById('card-date').textContent = book.created_at;
|
document.getElementById('card-pn-primary').textContent = Number(data.primary_count || 0);
|
||||||
document.getElementById('card-lots').textContent = lots.size;
|
|
||||||
document.getElementById('card-pn-total').textContent = allItems.length;
|
|
||||||
document.getElementById('card-pn-primary').textContent = primaryCount;
|
|
||||||
document.getElementById('summary-cards').classList.remove('hidden');
|
document.getElementById('summary-cards').classList.remove('hidden');
|
||||||
document.getElementById('active-book-section').classList.remove('hidden');
|
document.getElementById('active-book-section').classList.remove('hidden');
|
||||||
|
document.getElementById('pn-search').value = itemsSearch;
|
||||||
|
|
||||||
renderItems(allItems);
|
renderItems(activeItems);
|
||||||
|
renderItemsPagination();
|
||||||
}
|
}
|
||||||
|
|
||||||
function renderItems(items) {
|
function renderItems(items) {
|
||||||
@@ -218,15 +248,38 @@ function renderItems(items) {
|
|||||||
`;
|
`;
|
||||||
tbody.appendChild(tr);
|
tbody.appendChild(tr);
|
||||||
});
|
});
|
||||||
document.getElementById('pn-count').textContent = `${items.length} из ${allItems.length}`;
|
const totalLabel = itemsSearch ? `${items.length} из ${itemsTotal} по фильтру` : `${items.length} из ${itemsTotal}`;
|
||||||
|
document.getElementById('pn-count').textContent = totalLabel;
|
||||||
}
|
}
|
||||||
|
|
||||||
function filterItems(query) {
|
function renderItemsPagination() {
|
||||||
const q = query.trim().toLowerCase();
|
const totalPages = Math.max(1, Math.ceil(itemsTotal / ITEMS_PER_PAGE));
|
||||||
if (!q) { renderItems(allItems); return; }
|
const start = itemsTotal === 0 ? 0 : ((itemsPage - 1) * ITEMS_PER_PAGE) + 1;
|
||||||
renderItems(allItems.filter(i =>
|
const end = Math.min(itemsPage * ITEMS_PER_PAGE, itemsTotal);
|
||||||
i.partnumber.toLowerCase().includes(q) || i.lot_name.toLowerCase().includes(q)
|
const box = document.getElementById('items-pagination');
|
||||||
));
|
if (itemsTotal > ITEMS_PER_PAGE) {
|
||||||
|
box.classList.remove('hidden');
|
||||||
|
document.getElementById('items-page-info').textContent = `Сопоставления ${start}–${end} из ${itemsTotal}`;
|
||||||
|
document.getElementById('items-prev').disabled = itemsPage === 1;
|
||||||
|
document.getElementById('items-next').disabled = itemsPage >= totalPages;
|
||||||
|
} else {
|
||||||
|
box.classList.add('hidden');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function changeItemsPage(delta) {
|
||||||
|
const totalPages = Math.max(1, Math.ceil(itemsTotal / ITEMS_PER_PAGE));
|
||||||
|
const nextPage = Math.max(1, Math.min(totalPages, itemsPage + delta));
|
||||||
|
if (nextPage === itemsPage) return;
|
||||||
|
loadActiveBookItemsPage(nextPage, itemsSearch);
|
||||||
|
}
|
||||||
|
|
||||||
|
function onItemsSearchInput(value) {
|
||||||
|
clearTimeout(_itemsSearchTimer);
|
||||||
|
_itemsSearchTimer = setTimeout(() => {
|
||||||
|
itemsSearch = value.trim();
|
||||||
|
loadActiveBookItemsPage(1, itemsSearch);
|
||||||
|
}, 250);
|
||||||
}
|
}
|
||||||
|
|
||||||
async function syncPartnumberBooks() {
|
async function syncPartnumberBooks() {
|
||||||
@@ -253,4 +306,3 @@ document.addEventListener('DOMContentLoaded', loadBooks);
|
|||||||
{{end}}
|
{{end}}
|
||||||
|
|
||||||
{{template "base" .}}
|
{{template "base" .}}
|
||||||
|
|
||||||
|
|||||||
@@ -2,21 +2,21 @@
|
|||||||
|
|
||||||
{{define "content"}}
|
{{define "content"}}
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div class="flex items-center justify-between gap-3">
|
<div class="flex items-start justify-between gap-3">
|
||||||
<div class="flex items-center gap-3">
|
<div class="flex flex-wrap items-center gap-2 sm:gap-3 min-w-0">
|
||||||
<a href="/projects" class="text-gray-500 hover:text-gray-700" title="Все проекты">
|
<a href="/projects" class="shrink-0 text-gray-500 hover:text-gray-700" title="Все проекты">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9.75L12 3l9 6.75v9A2.25 2.25 0 0118.75 21h-13.5A2.25 2.25 0 013 18.75v-9z"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 9.75L12 3l9 6.75v9A2.25 2.25 0 0118.75 21h-13.5A2.25 2.25 0 013 18.75v-9z"></path>
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 21v-6h6v6"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 21v-6h6v6"></path>
|
||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
<div class="text-2xl font-bold flex items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2 text-xl sm:text-2xl font-bold min-w-0">
|
||||||
<a id="project-code-link" href="/projects" class="text-blue-700 hover:underline">
|
<a id="project-code-link" href="/projects" class="min-w-0 truncate text-blue-700 hover:underline">
|
||||||
<span id="project-code">—</span>
|
<span id="project-code">—</span>
|
||||||
</a>
|
</a>
|
||||||
<span class="text-gray-400">-</span>
|
<span class="text-gray-400 shrink-0">-</span>
|
||||||
<div class="relative">
|
<div class="relative shrink-0">
|
||||||
<button id="project-variant-button" type="button" class="inline-flex items-center gap-2 text-base font-medium px-3 py-1.5 rounded-lg bg-gray-100 hover:bg-gray-200 border border-gray-200">
|
<button id="project-variant-button" type="button" class="inline-flex items-center gap-2 text-sm sm:text-base font-medium px-3 py-1.5 rounded-lg bg-gray-100 hover:bg-gray-200 border border-gray-200">
|
||||||
<span id="project-variant-label">main</span>
|
<span id="project-variant-label">main</span>
|
||||||
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-4 h-4 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||||
@@ -26,24 +26,24 @@
|
|||||||
<div id="project-variant-list" class="py-1"></div>
|
<div id="project-variant-list" class="py-1"></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<button onclick="openNewVariantModal()" class="inline-flex w-full sm:w-auto justify-center items-center px-3 py-1.5 text-sm font-medium bg-purple-600 text-white rounded-lg hover:bg-purple-700">
|
||||||
|
+ Вариант
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="action-buttons" class="mt-4 grid grid-cols-1 sm:grid-cols-6 gap-3">
|
<div id="action-buttons" class="mt-4 grid grid-cols-1 sm:grid-cols-6 gap-3">
|
||||||
<button onclick="openNewVariantModal()" class="py-2 bg-purple-600 text-white rounded-lg hover:bg-purple-700 font-medium">
|
|
||||||
+ Новый вариант
|
|
||||||
</button>
|
|
||||||
<button onclick="openCreateModal()" class="py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
<button onclick="openCreateModal()" class="py-2 bg-blue-600 text-white rounded-lg hover:bg-blue-700 font-medium">
|
||||||
+ Создать новую квоту
|
Новая конфигурация
|
||||||
</button>
|
</button>
|
||||||
<button onclick="openImportModal()" class="py-2 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 font-medium">
|
<button onclick="openVendorImportModal()" class="py-2 bg-amber-600 text-white rounded-lg hover:bg-amber-700 font-medium">
|
||||||
Импорт квоты
|
Импорт выгрузки вендора
|
||||||
</button>
|
</button>
|
||||||
<button onclick="openProjectSettingsModal()" class="py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-800 font-medium">
|
<button onclick="openProjectSettingsModal()" class="py-2 bg-gray-700 text-white rounded-lg hover:bg-gray-800 font-medium">
|
||||||
Параметры
|
Параметры
|
||||||
</button>
|
</button>
|
||||||
<button onclick="exportProject()" class="py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium">
|
<button onclick="openExportModal()" class="py-2 bg-green-600 text-white rounded-lg hover:bg-green-700 font-medium">
|
||||||
Экспорт CSV
|
Экспорт CSV
|
||||||
</button>
|
</button>
|
||||||
<button id="delete-variant-btn" onclick="deleteVariant()" class="py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium hidden">
|
<button id="delete-variant-btn" onclick="deleteVariant()" class="py-2 bg-red-600 text-white rounded-lg hover:bg-red-700 font-medium hidden">
|
||||||
@@ -72,10 +72,10 @@
|
|||||||
|
|
||||||
<div id="create-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
<div id="create-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||||
<h2 class="text-xl font-semibold mb-4">Новая квота в проекте</h2>
|
<h2 class="text-xl font-semibold mb-4">Новая конфигурация в проекте</h2>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Название квоты</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Название конфигурации</label>
|
||||||
<input type="text" id="create-name" placeholder="Например: OPP-2026-001"
|
<input type="text" id="create-name" placeholder="Например: OPP-2026-001"
|
||||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||||
</div>
|
</div>
|
||||||
@@ -87,6 +87,65 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div id="vendor-import-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||||
|
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg mx-4 p-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Импорт выгрузки вендора</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="text-sm text-gray-600">
|
||||||
|
Загружает `CFXML`-выгрузку в текущий проект и создаёт несколько конфигураций, если они есть в файле.
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<label for="vendor-import-file" class="block text-sm font-medium text-gray-700 mb-1">Файл выгрузки</label>
|
||||||
|
<input id="vendor-import-file" type="file" accept=".xml,text/xml,application/xml"
|
||||||
|
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-amber-500 focus:border-amber-500">
|
||||||
|
</div>
|
||||||
|
<div id="vendor-import-status" class="hidden text-sm rounded border px-3 py-2"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end space-x-3 mt-6">
|
||||||
|
<button onclick="closeVendorImportModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
||||||
|
<button id="vendor-import-submit" onclick="importVendorWorkspace()" class="px-4 py-2 bg-amber-600 text-white rounded hover:bg-amber-700">Импортировать</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="project-export-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||||
|
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||||
|
<h2 class="text-xl font-semibold mb-4">Экспорт CSV</h2>
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="text-sm text-gray-600">
|
||||||
|
Экспортирует проект в формате вкладки ценообразования. Если включён `BOM`, строки строятся по BOM; иначе по текущему Estimate.
|
||||||
|
</div>
|
||||||
|
<div class="space-y-3">
|
||||||
|
<label class="flex items-center gap-3 text-sm text-gray-700">
|
||||||
|
<input id="export-col-lot" type="checkbox" class="rounded border-gray-300" checked>
|
||||||
|
<span>LOT</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-3 text-sm text-gray-700">
|
||||||
|
<input id="export-col-bom" type="checkbox" class="rounded border-gray-300">
|
||||||
|
<span>BOM</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-3 text-sm text-gray-700">
|
||||||
|
<input id="export-col-estimate" type="checkbox" class="rounded border-gray-300" checked>
|
||||||
|
<span>Estimate</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-3 text-sm text-gray-700">
|
||||||
|
<input id="export-col-stock" type="checkbox" class="rounded border-gray-300">
|
||||||
|
<span>Stock</span>
|
||||||
|
</label>
|
||||||
|
<label class="flex items-center gap-3 text-sm text-gray-700">
|
||||||
|
<input id="export-col-competitor" type="checkbox" class="rounded border-gray-300">
|
||||||
|
<span>Конкуренты</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
<div id="project-export-status" class="hidden text-sm rounded border px-3 py-2"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex justify-end space-x-3 mt-6">
|
||||||
|
<button onclick="closeExportModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
||||||
|
<button id="project-export-submit" onclick="exportProject()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">Скачать CSV</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div id="new-variant-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
<div id="new-variant-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg p-6">
|
<div class="bg-white rounded-lg shadow-xl w-full max-w-lg p-6">
|
||||||
<h2 class="text-xl font-semibold mb-4">Новый вариант</h2>
|
<h2 class="text-xl font-semibold mb-4">Новый вариант</h2>
|
||||||
@@ -116,7 +175,7 @@
|
|||||||
|
|
||||||
<div id="config-action-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
<div id="config-action-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||||
<h2 class="text-xl font-semibold mb-4">Действия с квотой</h2>
|
<h2 class="text-xl font-semibold mb-4">Действия с конфигурацией</h2>
|
||||||
<div class="space-y-4">
|
<div class="space-y-4">
|
||||||
<div>
|
<div>
|
||||||
<label class="block text-sm font-medium text-gray-700 mb-1">Название</label>
|
<label class="block text-sm font-medium text-gray-700 mb-1">Название</label>
|
||||||
@@ -154,25 +213,6 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div id="import-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
|
||||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
|
||||||
<h2 class="text-xl font-semibold mb-4">Импорт квоты в проект</h2>
|
|
||||||
<div class="space-y-3">
|
|
||||||
<label class="block text-sm font-medium text-gray-700">Квота</label>
|
|
||||||
<input id="import-config-input"
|
|
||||||
list="import-config-options"
|
|
||||||
placeholder="Начните вводить название квоты"
|
|
||||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
|
||||||
<datalist id="import-config-options"></datalist>
|
|
||||||
<div class="text-xs text-gray-500">Квота будет перемещена в текущий проект.</div>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-end gap-2 mt-6">
|
|
||||||
<button onclick="closeImportModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
|
||||||
<button onclick="importConfigToProject()" class="px-4 py-2 bg-indigo-600 text-white rounded hover:bg-indigo-700">Импортировать</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div id="project-settings-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
<div id="project-settings-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||||
<h2 class="text-xl font-semibold mb-4">Параметры проекта</h2>
|
<h2 class="text-xl font-semibold mb-4">Параметры проекта</h2>
|
||||||
@@ -347,7 +387,7 @@ function applyStatusModeUI() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function renderConfigs(configs) {
|
function renderConfigs(configs) {
|
||||||
const emptyText = configStatusMode === 'archived' ? 'Архив пуст' : 'Нет квот в проекте';
|
const emptyText = configStatusMode === 'archived' ? 'Архив пуст' : 'Нет конфигураций в проекте';
|
||||||
if (configs.length === 0) {
|
if (configs.length === 0) {
|
||||||
document.getElementById('configs-list').innerHTML =
|
document.getElementById('configs-list').innerHTML =
|
||||||
'<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">' + emptyText + '</div>';
|
'<div class="bg-white rounded-lg shadow p-8 text-center text-gray-500">' + emptyText + '</div>';
|
||||||
@@ -540,6 +580,86 @@ function closeCreateModal() {
|
|||||||
document.getElementById('create-modal').classList.remove('flex');
|
document.getElementById('create-modal').classList.remove('flex');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function setVendorImportStatus(message, type) {
|
||||||
|
const box = document.getElementById('vendor-import-status');
|
||||||
|
if (!box) return;
|
||||||
|
if (!message) {
|
||||||
|
box.textContent = '';
|
||||||
|
box.className = 'hidden text-sm rounded border px-3 py-2';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
let classes = 'text-sm rounded border px-3 py-2 ';
|
||||||
|
if (type === 'error') {
|
||||||
|
classes += 'border-red-200 bg-red-50 text-red-700';
|
||||||
|
} else if (type === 'success') {
|
||||||
|
classes += 'border-emerald-200 bg-emerald-50 text-emerald-700';
|
||||||
|
} else {
|
||||||
|
classes += 'border-amber-200 bg-amber-50 text-amber-700';
|
||||||
|
}
|
||||||
|
box.className = classes;
|
||||||
|
box.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
function openVendorImportModal() {
|
||||||
|
document.getElementById('vendor-import-file').value = '';
|
||||||
|
setVendorImportStatus('', '');
|
||||||
|
document.getElementById('vendor-import-submit').disabled = false;
|
||||||
|
document.getElementById('vendor-import-submit').textContent = 'Импортировать';
|
||||||
|
document.getElementById('vendor-import-modal').classList.remove('hidden');
|
||||||
|
document.getElementById('vendor-import-modal').classList.add('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeVendorImportModal() {
|
||||||
|
document.getElementById('vendor-import-modal').classList.add('hidden');
|
||||||
|
document.getElementById('vendor-import-modal').classList.remove('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function importVendorWorkspace() {
|
||||||
|
const input = document.getElementById('vendor-import-file');
|
||||||
|
const submit = document.getElementById('vendor-import-submit');
|
||||||
|
if (!input || !input.files || !input.files[0]) {
|
||||||
|
setVendorImportStatus('Выберите XML-файл выгрузки', 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const formData = new FormData();
|
||||||
|
formData.append('file', input.files[0]);
|
||||||
|
submit.disabled = true;
|
||||||
|
submit.textContent = 'Импорт...';
|
||||||
|
setVendorImportStatus('Импортирую файл в проект...', 'info');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/projects/' + projectUUID + '/vendor-import', {
|
||||||
|
method: 'POST',
|
||||||
|
body: formData
|
||||||
|
});
|
||||||
|
const data = await resp.json().catch(() => ({}));
|
||||||
|
if (!resp.ok) {
|
||||||
|
throw new Error(data.error || 'Не удалось импортировать выгрузку');
|
||||||
|
}
|
||||||
|
|
||||||
|
const imported = Array.isArray(data.configs) ? data.configs : [];
|
||||||
|
const importedNames = imported.map(c => c.name).filter(Boolean);
|
||||||
|
const message = importedNames.length
|
||||||
|
? 'Импортировано ' + imported.length + ': ' + importedNames.join(', ')
|
||||||
|
: 'Импортировано конфигураций: ' + (data.imported || 0);
|
||||||
|
setVendorImportStatus(message, 'success');
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast('Импорт завершён', 'success');
|
||||||
|
}
|
||||||
|
await loadConfigs();
|
||||||
|
setTimeout(closeVendorImportModal, 900);
|
||||||
|
} catch (e) {
|
||||||
|
setVendorImportStatus(e.message || 'Не удалось импортировать выгрузку', 'error');
|
||||||
|
if (typeof showToast === 'function') {
|
||||||
|
showToast(e.message || 'Не удалось импортировать выгрузку', 'error');
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
submit.disabled = false;
|
||||||
|
submit.textContent = 'Импортировать';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
async function createConfig() {
|
async function createConfig() {
|
||||||
const name = document.getElementById('create-name').value.trim();
|
const name = document.getElementById('create-name').value.trim();
|
||||||
if (!name) {
|
if (!name) {
|
||||||
@@ -552,7 +672,7 @@ async function createConfig() {
|
|||||||
body: JSON.stringify({name: name, items: [], notes: '', server_count: 1})
|
body: JSON.stringify({name: name, items: [], notes: '', server_count: 1})
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
alert('Не удалось создать квоту');
|
alert('Не удалось создать конфигурацию');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
closeCreateModal();
|
closeCreateModal();
|
||||||
@@ -560,7 +680,7 @@ async function createConfig() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function deleteConfig(uuid) {
|
async function deleteConfig(uuid) {
|
||||||
if (!confirm('Переместить квоту в архив?')) return;
|
if (!confirm('Переместить конфигурацию в архив?')) return;
|
||||||
await fetch('/api/configs/' + uuid, {method: 'DELETE'});
|
await fetch('/api/configs/' + uuid, {method: 'DELETE'});
|
||||||
loadConfigs();
|
loadConfigs();
|
||||||
}
|
}
|
||||||
@@ -568,7 +688,7 @@ async function deleteConfig(uuid) {
|
|||||||
async function reactivateConfig(uuid) {
|
async function reactivateConfig(uuid) {
|
||||||
const resp = await fetch('/api/configs/' + uuid + '/reactivate', {method: 'POST'});
|
const resp = await fetch('/api/configs/' + uuid + '/reactivate', {method: 'POST'});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
alert('Не удалось восстановить квоту');
|
alert('Не удалось восстановить конфигурацию');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const moved = await fetch('/api/configs/' + uuid + '/project', {
|
const moved = await fetch('/api/configs/' + uuid + '/project', {
|
||||||
@@ -577,7 +697,7 @@ async function reactivateConfig(uuid) {
|
|||||||
body: JSON.stringify({project_uuid: projectUUID})
|
body: JSON.stringify({project_uuid: projectUUID})
|
||||||
});
|
});
|
||||||
if (!moved.ok) {
|
if (!moved.ok) {
|
||||||
alert('Квота восстановлена, но не удалось вернуть в проект');
|
alert('Конфигурация восстановлена, но не удалось вернуть в проект');
|
||||||
}
|
}
|
||||||
loadConfigs();
|
loadConfigs();
|
||||||
}
|
}
|
||||||
@@ -752,7 +872,7 @@ async function saveConfigAction() {
|
|||||||
body: JSON.stringify({name: name})
|
body: JSON.stringify({name: name})
|
||||||
});
|
});
|
||||||
if (!cloneResp.ok) {
|
if (!cloneResp.ok) {
|
||||||
notify('Не удалось скопировать квоту', 'error');
|
notify('Не удалось скопировать конфигурацию', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
closeConfigActionModal();
|
closeConfigActionModal();
|
||||||
@@ -773,7 +893,7 @@ async function saveConfigAction() {
|
|||||||
body: JSON.stringify({name: name})
|
body: JSON.stringify({name: name})
|
||||||
});
|
});
|
||||||
if (!renameResp.ok) {
|
if (!renameResp.ok) {
|
||||||
notify('Не удалось переименовать квоту', 'error');
|
notify('Не удалось переименовать конфигурацию', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
changed = true;
|
changed = true;
|
||||||
@@ -786,7 +906,7 @@ async function saveConfigAction() {
|
|||||||
body: JSON.stringify({project_uuid: targetProjectUUID})
|
body: JSON.stringify({project_uuid: targetProjectUUID})
|
||||||
});
|
});
|
||||||
if (!moveResp.ok) {
|
if (!moveResp.ok) {
|
||||||
notify('Не удалось перенести квоту', 'error');
|
notify('Не удалось перенести конфигурацию', 'error');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
changed = true;
|
changed = true;
|
||||||
@@ -805,21 +925,6 @@ async function saveConfigAction() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function openImportModal() {
|
|
||||||
const activeOther = allConfigs.length ? null : null; // no-op placeholder
|
|
||||||
void activeOther;
|
|
||||||
document.getElementById('import-config-input').value = '';
|
|
||||||
document.getElementById('import-config-options').innerHTML = '';
|
|
||||||
loadImportOptions();
|
|
||||||
document.getElementById('import-modal').classList.remove('hidden');
|
|
||||||
document.getElementById('import-modal').classList.add('flex');
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeImportModal() {
|
|
||||||
document.getElementById('import-modal').classList.add('hidden');
|
|
||||||
document.getElementById('import-modal').classList.remove('flex');
|
|
||||||
}
|
|
||||||
|
|
||||||
function openProjectSettingsModal() {
|
function openProjectSettingsModal() {
|
||||||
if (!project) return;
|
if (!project) return;
|
||||||
if (project.is_system) {
|
if (project.is_system) {
|
||||||
@@ -879,89 +984,10 @@ async function saveProjectSettings() {
|
|||||||
closeProjectSettingsModal();
|
closeProjectSettingsModal();
|
||||||
}
|
}
|
||||||
|
|
||||||
async function loadImportOptions() {
|
|
||||||
const resp = await fetch('/api/configs?page=1&per_page=500&status=active');
|
|
||||||
if (!resp.ok) return;
|
|
||||||
const data = await resp.json();
|
|
||||||
const options = document.getElementById('import-config-options');
|
|
||||||
options.innerHTML = '';
|
|
||||||
(data.configurations || [])
|
|
||||||
.filter(c => c.project_uuid !== projectUUID)
|
|
||||||
.forEach(c => {
|
|
||||||
const opt = document.createElement('option');
|
|
||||||
opt.value = c.name;
|
|
||||||
options.appendChild(opt);
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
async function importConfigToProject() {
|
|
||||||
const query = document.getElementById('import-config-input').value.trim();
|
|
||||||
if (!query) {
|
|
||||||
alert('Выберите квоту');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const resp = await fetch('/api/configs?page=1&per_page=500&status=active');
|
|
||||||
if (!resp.ok) {
|
|
||||||
alert('Не удалось загрузить список квот');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
const data = await resp.json();
|
|
||||||
const sourceConfigs = (data.configurations || []).filter(c => c.project_uuid !== projectUUID);
|
|
||||||
|
|
||||||
let targets = [];
|
|
||||||
if (query.includes('*')) {
|
|
||||||
targets = sourceConfigs.filter(c => wildcardMatch(c.name || '', query));
|
|
||||||
} else {
|
|
||||||
const found = sourceConfigs.find(c => (c.name || '').toLowerCase() === query.toLowerCase());
|
|
||||||
if (found) {
|
|
||||||
targets = [found];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!targets.length) {
|
|
||||||
alert('Подходящие квоты не найдены');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let moved = 0;
|
|
||||||
let failed = 0;
|
|
||||||
for (const cfg of targets) {
|
|
||||||
const move = await fetch('/api/configs/' + cfg.uuid + '/project', {
|
|
||||||
method: 'PATCH',
|
|
||||||
headers: {'Content-Type': 'application/json'},
|
|
||||||
body: JSON.stringify({project_uuid: projectUUID})
|
|
||||||
});
|
|
||||||
if (move.ok) {
|
|
||||||
moved++;
|
|
||||||
} else {
|
|
||||||
failed++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (!moved) {
|
|
||||||
alert('Не удалось импортировать квоты');
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
closeImportModal();
|
|
||||||
await loadConfigs();
|
|
||||||
if (targets.length > 1 || failed > 0) {
|
|
||||||
alert('Импорт завершен: перенесено ' + moved + ', ошибок ' + failed);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function wildcardMatch(value, pattern) {
|
|
||||||
const escaped = pattern
|
|
||||||
.replace(/[.+^${}()|[\]\\]/g, '\\$&')
|
|
||||||
.replace(/\*/g, '.*');
|
|
||||||
const regex = new RegExp('^' + escaped + '$', 'i');
|
|
||||||
return regex.test(value);
|
|
||||||
}
|
|
||||||
|
|
||||||
async function deleteVariant() {
|
async function deleteVariant() {
|
||||||
if (!project) return;
|
if (!project) return;
|
||||||
const variantLabel = normalizeVariantLabel(project.variant);
|
const variantLabel = normalizeVariantLabel(project.variant);
|
||||||
if (!confirm('Удалить вариант «' + variantLabel + '»? Все квоты будут архивированы.')) return;
|
if (!confirm('Удалить вариант «' + variantLabel + '»? Все конфигурации будут архивированы.')) return;
|
||||||
const resp = await fetch('/api/projects/' + projectUUID, {method: 'DELETE'});
|
const resp = await fetch('/api/projects/' + projectUUID, {method: 'DELETE'});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
const data = await resp.json().catch(() => ({}));
|
const data = await resp.json().catch(() => ({}));
|
||||||
@@ -988,9 +1014,9 @@ function updateDeleteVariantButton() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
document.getElementById('create-modal').addEventListener('click', function(e) { if (e.target === this) closeCreateModal(); });
|
document.getElementById('create-modal').addEventListener('click', function(e) { if (e.target === this) closeCreateModal(); });
|
||||||
|
document.getElementById('vendor-import-modal').addEventListener('click', function(e) { if (e.target === this) closeVendorImportModal(); });
|
||||||
document.getElementById('new-variant-modal').addEventListener('click', function(e) { if (e.target === this) closeNewVariantModal(); });
|
document.getElementById('new-variant-modal').addEventListener('click', function(e) { if (e.target === this) closeNewVariantModal(); });
|
||||||
document.getElementById('config-action-modal').addEventListener('click', function(e) { if (e.target === this) closeConfigActionModal(); });
|
document.getElementById('config-action-modal').addEventListener('click', function(e) { if (e.target === this) closeConfigActionModal(); });
|
||||||
document.getElementById('import-modal').addEventListener('click', function(e) { if (e.target === this) closeImportModal(); });
|
|
||||||
document.getElementById('project-settings-modal').addEventListener('click', function(e) { if (e.target === this) closeProjectSettingsModal(); });
|
document.getElementById('project-settings-modal').addEventListener('click', function(e) { if (e.target === this) closeProjectSettingsModal(); });
|
||||||
document.getElementById('config-action-project-input').addEventListener('input', function(e) {
|
document.getElementById('config-action-project-input').addEventListener('input', function(e) {
|
||||||
const code = resolveProjectCodeFromInput(e.target.value);
|
const code = resolveProjectCodeFromInput(e.target.value);
|
||||||
@@ -1007,8 +1033,8 @@ document.getElementById('config-action-copy').addEventListener('change', functio
|
|||||||
document.addEventListener('keydown', function(e) {
|
document.addEventListener('keydown', function(e) {
|
||||||
if (e.key === 'Escape') {
|
if (e.key === 'Escape') {
|
||||||
closeCreateModal();
|
closeCreateModal();
|
||||||
|
closeVendorImportModal();
|
||||||
closeConfigActionModal();
|
closeConfigActionModal();
|
||||||
closeImportModal();
|
|
||||||
closeProjectSettingsModal();
|
closeProjectSettingsModal();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -1162,8 +1188,82 @@ function updateFooterTotal() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function exportProject() {
|
function openExportModal() {
|
||||||
window.location.href = '/api/projects/' + projectUUID + '/export';
|
const modal = document.getElementById('project-export-modal');
|
||||||
|
if (!modal) return;
|
||||||
|
setProjectExportStatus('', '');
|
||||||
|
modal.classList.remove('hidden');
|
||||||
|
modal.classList.add('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeExportModal() {
|
||||||
|
const modal = document.getElementById('project-export-modal');
|
||||||
|
if (!modal) return;
|
||||||
|
modal.classList.add('hidden');
|
||||||
|
modal.classList.remove('flex');
|
||||||
|
}
|
||||||
|
|
||||||
|
function setProjectExportStatus(message, tone) {
|
||||||
|
const status = document.getElementById('project-export-status');
|
||||||
|
if (!status) return;
|
||||||
|
if (!message) {
|
||||||
|
status.className = 'hidden text-sm rounded border px-3 py-2';
|
||||||
|
status.textContent = '';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
const palette = tone === 'error'
|
||||||
|
? 'text-red-700 bg-red-50 border-red-200'
|
||||||
|
: 'text-gray-700 bg-gray-50 border-gray-200';
|
||||||
|
status.className = 'text-sm rounded border px-3 py-2 ' + palette;
|
||||||
|
status.textContent = message;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function exportProject() {
|
||||||
|
const submitBtn = document.getElementById('project-export-submit');
|
||||||
|
const payload = {
|
||||||
|
include_lot: !!document.getElementById('export-col-lot')?.checked,
|
||||||
|
include_bom: !!document.getElementById('export-col-bom')?.checked,
|
||||||
|
include_estimate: !!document.getElementById('export-col-estimate')?.checked,
|
||||||
|
include_stock: !!document.getElementById('export-col-stock')?.checked,
|
||||||
|
include_competitor: !!document.getElementById('export-col-competitor')?.checked
|
||||||
|
};
|
||||||
|
|
||||||
|
if (submitBtn) submitBtn.disabled = true;
|
||||||
|
setProjectExportStatus('', '');
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/projects/' + projectUUID + '/export', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {'Content-Type': 'application/json'},
|
||||||
|
body: JSON.stringify(payload)
|
||||||
|
});
|
||||||
|
if (!resp.ok) {
|
||||||
|
let message = 'Не удалось экспортировать CSV';
|
||||||
|
try {
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data && data.error) message = data.error;
|
||||||
|
} catch (_) {}
|
||||||
|
setProjectExportStatus(message, 'error');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = await resp.blob();
|
||||||
|
const link = document.createElement('a');
|
||||||
|
const url = window.URL.createObjectURL(blob);
|
||||||
|
const disposition = resp.headers.get('Content-Disposition') || '';
|
||||||
|
const match = disposition.match(/filename="([^"]+)"/);
|
||||||
|
link.href = url;
|
||||||
|
link.download = match ? match[1] : 'project-export.csv';
|
||||||
|
document.body.appendChild(link);
|
||||||
|
link.click();
|
||||||
|
document.body.removeChild(link);
|
||||||
|
window.URL.revokeObjectURL(url);
|
||||||
|
closeExportModal();
|
||||||
|
} catch (e) {
|
||||||
|
setProjectExportStatus('Ошибка сети при экспорте CSV', 'error');
|
||||||
|
} finally {
|
||||||
|
if (submitBtn) submitBtn.disabled = false;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
document.addEventListener('DOMContentLoaded', async function() {
|
document.addEventListener('DOMContentLoaded', async function() {
|
||||||
|
|||||||
@@ -315,7 +315,7 @@ async function loadProjects() {
|
|||||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>';
|
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path></svg>';
|
||||||
html += '</button>';
|
html += '</button>';
|
||||||
|
|
||||||
html += '<button onclick="addConfigToProject(\'' + p.uuid + '\')" class="text-indigo-700 hover:text-indigo-900" title="Добавить квоту">';
|
html += '<button onclick="addConfigToProject(\'' + p.uuid + '\')" class="text-indigo-700 hover:text-indigo-900" title="Добавить конфигурацию">';
|
||||||
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>';
|
html += '<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4"></path></svg>';
|
||||||
html += '</button>';
|
html += '</button>';
|
||||||
} else {
|
} else {
|
||||||
@@ -472,7 +472,7 @@ async function reactivateProject(projectUUID) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function addConfigToProject(projectUUID) {
|
async function addConfigToProject(projectUUID) {
|
||||||
const name = prompt('Название новой квоты');
|
const name = prompt('Название новой конфигурации');
|
||||||
if (!name || !name.trim()) return;
|
if (!name || !name.trim()) return;
|
||||||
const resp = await fetch('/api/projects/' + projectUUID + '/configs', {
|
const resp = await fetch('/api/projects/' + projectUUID + '/configs', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -480,7 +480,7 @@ async function addConfigToProject(projectUUID) {
|
|||||||
body: JSON.stringify({name: name.trim(), items: [], notes: '', server_count: 1})
|
body: JSON.stringify({name: name.trim(), items: [], notes: '', server_count: 1})
|
||||||
});
|
});
|
||||||
if (!resp.ok) {
|
if (!resp.ok) {
|
||||||
alert('Не удалось создать квоту');
|
alert('Не удалось создать конфигурацию');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
loadProjects();
|
loadProjects();
|
||||||
@@ -510,7 +510,7 @@ async function copyProject(projectUUID, projectName) {
|
|||||||
|
|
||||||
const listResp = await fetch('/api/projects/' + projectUUID + '/configs');
|
const listResp = await fetch('/api/projects/' + projectUUID + '/configs');
|
||||||
if (!listResp.ok) {
|
if (!listResp.ok) {
|
||||||
alert('Проект скопирован без квот (не удалось загрузить исходные квоты)');
|
alert('Проект скопирован без конфигураций (не удалось загрузить исходные конфигурации)');
|
||||||
loadProjects();
|
loadProjects();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user