Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
4bc7979a70 | ||
|
|
1137c6d4db | ||
|
|
7e1e2ac18d | ||
|
|
aea6bf91ab | ||
|
|
d58d52c5e7 | ||
|
|
7a628deb8a | ||
|
|
7f6be786a8 | ||
|
|
a360992a01 | ||
|
|
1ea21ece33 | ||
|
|
7ae804d2d3 | ||
|
|
da5414c708 | ||
|
|
7a69c1513d | ||
|
|
f448111e77 | ||
|
|
a5dafd37d3 |
2
bible
2
bible
Submodule bible updated: 5a69e0bba8...52444350c1
@@ -106,6 +106,17 @@ Rules:
|
||||
- copy checkboxes and copy modals must prefill `_копия`, not ` (копия)`;
|
||||
- the literal variant name `main` is reserved and must not be allowed for non-main variants.
|
||||
|
||||
## Configuration types
|
||||
|
||||
Configurations have a `config_type` field: `"server"` (default) or `"storage"`.
|
||||
|
||||
Rules:
|
||||
- `config_type` defaults to `"server"` for all existing and new configurations unless explicitly set;
|
||||
- the configurator page is shared for both types; the SW tab is always visible regardless of type;
|
||||
- storage configurations use the same vendor_spec + PN→LOT + pricing flow as server configurations;
|
||||
- storage component categories map to existing tabs: `ENC`/`DKC`/`CTL` → Base, `HIC` → PCI (HIC-карты СХД; `HBA`/`NIC` — серверные, не смешивать), `SSD`/`HDD` → Storage (используют существующие серверные LOT), `ACC` → Accessories (используют существующие серверные LOT), `SW` → SW.
|
||||
- `DKC` = контроллерная полка (модель СХД + тип дисков + кол-во слотов + кол-во контроллеров); `CTL` = контроллер (кэш + встроенные порты); `ENC` = дисковая полка без контроллера.
|
||||
|
||||
## Vendor BOM contract
|
||||
|
||||
Vendor BOM is stored in `vendor_spec` on the configuration row.
|
||||
|
||||
@@ -29,17 +29,15 @@ Rules:
|
||||
|
||||
## MariaDB
|
||||
|
||||
MariaDB is the central sync database (`RFQ_LOG`). Final schema as of 2026-03-21.
|
||||
MariaDB is the central sync database (`RFQ_LOG`). Final schema as of 2026-04-15.
|
||||
|
||||
### QuoteForge tables (qt_* and stock_*)
|
||||
### QuoteForge tables (qt_*)
|
||||
|
||||
Runtime read:
|
||||
- `qt_categories` — pricelist categories
|
||||
- `qt_lot_metadata` — component metadata, price settings
|
||||
- `qt_pricelists` — pricelist headers (source: estimate / warehouse / competitor)
|
||||
- `qt_pricelist_items` — pricelist rows
|
||||
- `stock_log` — raw supplier price log, source for pricelist generation
|
||||
- `stock_ignore_rules` — patterns to skip during stock import
|
||||
- `qt_partnumber_books` — partnumber book headers
|
||||
- `qt_partnumber_book_items` — PN→LOT catalog payload
|
||||
|
||||
@@ -69,18 +67,20 @@ QuoteForge references competitor pricelists only via `qt_pricelists` (source='co
|
||||
### Legacy RFQ tables (pre-QuoteForge, no Go code references)
|
||||
|
||||
- `lot` — original component registry (data preserved; superseded by `qt_lot_metadata`)
|
||||
- `lot_log` — original supplier price log (superseded by `stock_log`)
|
||||
- `lot_log` — original supplier price log
|
||||
- `supplier` — supplier registry (FK target for lot_log and machine_log)
|
||||
- `machine` — device model registry
|
||||
- `machine_log` — device price/quote log
|
||||
- `parts_log` — supplier partnumber log used by server-side import/pricing workflows, not by QuoteForge runtime
|
||||
|
||||
These tables are retained for historical data. QuoteForge does not read or write them at runtime.
|
||||
|
||||
Rules:
|
||||
- QuoteForge runtime must not depend on any legacy RFQ tables;
|
||||
- stock enrichment happens during sync and is persisted into SQLite;
|
||||
- QuoteForge sync reads prices and categories from `qt_pricelists` / `qt_pricelist_items` only;
|
||||
- QuoteForge does not enrich local pricelist rows from `parts_log` or any other raw supplier log table;
|
||||
- normal UI requests must not query MariaDB tables directly;
|
||||
- `qt_client_local_migrations` was removed from the schema on 2026-03-21 (was in earlier drafts).
|
||||
- `qt_client_local_migrations` exists in the 2026-04-15 schema dump, but runtime sync does not depend on it.
|
||||
|
||||
## MariaDB Table Structures
|
||||
|
||||
@@ -360,6 +360,39 @@ Retained for historical data only. Not queried by QuoteForge.
|
||||
**machine**: machine_name (PK, char 255), machine_description
|
||||
**machine_log**: machine_log_id AUTO_INCREMENT, date, supplier (FK→supplier), country, opty, type, machine (FK→machine), customer_requirement, variant, price_gpl, price_estimate, qty, quality, carepack, lead_time_weeks, prepayment_percent, price_got, Comment
|
||||
|
||||
## MariaDB User Permissions
|
||||
|
||||
The application user needs read-only access to reference tables and read/write access to runtime tables.
|
||||
|
||||
```sql
|
||||
-- Read-only: reference and pricing data
|
||||
GRANT SELECT ON RFQ_LOG.qt_categories TO 'qfs_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'qfs_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'qfs_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'qfs_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.stock_log TO 'qfs_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.stock_ignore_rules TO 'qfs_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_partnumber_books TO 'qfs_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_partnumber_book_items TO 'qfs_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.lot TO 'qfs_user'@'%';
|
||||
|
||||
-- Read/write: runtime sync and user data
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON RFQ_LOG.qt_projects TO 'qfs_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE, DELETE ON RFQ_LOG.qt_configurations TO 'qfs_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_client_schema_state TO 'qfs_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_pricelist_sync_status TO 'qfs_user'@'%';
|
||||
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_vendor_partnumber_seen TO 'qfs_user'@'%';
|
||||
|
||||
FLUSH PRIVILEGES;
|
||||
```
|
||||
|
||||
Rules:
|
||||
- `qt_client_schema_state` requires INSERT + UPDATE for sync status tracking (uses `ON DUPLICATE KEY UPDATE`);
|
||||
- `qt_vendor_partnumber_seen` requires INSERT + UPDATE (vendor PN discovery during sync);
|
||||
- no DELETE is needed on sync/tracking tables — rows are never removed by the client;
|
||||
- `lot` SELECT is required for the connection validation probe in `/setup`;
|
||||
- the setup page shows `can_write: true` only when `qt_client_schema_state` INSERT succeeds.
|
||||
|
||||
## Migrations
|
||||
|
||||
SQLite:
|
||||
|
||||
@@ -1544,7 +1544,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
project, err := projectService.Update(c.Param("uuid"), dbUsername, &req)
|
||||
if err != nil {
|
||||
switch {
|
||||
case errors.Is(err, services.ErrReservedMainVariant):
|
||||
case errors.Is(err, services.ErrReservedMainVariant),
|
||||
errors.Is(err, services.ErrCannotRenameMainVariant):
|
||||
respondError(c, http.StatusBadRequest, "invalid request", err)
|
||||
case errors.Is(err, services.ErrProjectCodeExists):
|
||||
respondError(c, http.StatusConflict, "conflict detected", err)
|
||||
|
||||
213
docs/storage-components-guide.md
Normal file
213
docs/storage-components-guide.md
Normal file
@@ -0,0 +1,213 @@
|
||||
# Руководство по составлению каталога лотов СХД
|
||||
|
||||
## Что такое LOT и зачем он нужен
|
||||
|
||||
LOT — это внутренний идентификатор типа компонента в системе QuoteForge.
|
||||
|
||||
Каждый LOT представляет одну рыночную позицию и хранит **средневзвешенную рыночную цену**, рассчитанную по историческим данным от поставщиков. Это позволяет получать актуальную оценку стоимости независимо от конкретного поставщика или прайс-листа.
|
||||
|
||||
Партномера вендора (Part Number, Feature Code) сами по себе не имеют цены в системе — они **переводятся в LOT** через книгу партномеров. Именно через LOT происходит расценка конфигурации.
|
||||
|
||||
**Пример:** Feature Code `B4B9` и Part Number `4C57A14368` — это два разных обозначения одной и той же HIC-карты от Lenovo. Оба маппируются на один LOT `HIC_4pFC32`, у которого есть рыночная цена.
|
||||
|
||||
---
|
||||
|
||||
## Категории и вкладки конфигуратора
|
||||
|
||||
Категория LOT определяет, в какой вкладке конфигуратора он появится.
|
||||
|
||||
| Код категории | Название | Вкладка | Что сюда относится |
|
||||
|---|---|---|---|
|
||||
| `ENC` | Storage Enclosure | **Base** | Дисковая полка без контроллера |
|
||||
| `DKC` | Disk/Controller Enclosure | **Base** | Контроллерная полка: модель СХД + тип дисков + кол-во слотов + кол-во контроллеров |
|
||||
| `CTL` | Storage Controller | **Base** | Контроллер СХД: объём кэша + встроенные хост-порты |
|
||||
| `HIC` | Host Interface Card | **PCI** | HIC-карты СХД: интерфейсы подключения (FC, iSCSI, SAS) |
|
||||
| `HDD` | HDD | **Storage** | Жёсткие диски (HDD) |
|
||||
| `SSD` | SSD | **Storage** | Твердотельные диски (SSD, NVMe) |
|
||||
| `ACC` | Accessories | **Accessories** | Кабели подключения, кабели питания |
|
||||
| `SW` | Software | **SW** | Программные лицензии |
|
||||
| *(прочее)* | — | **Other** | Гарантийные опции, инсталляция |
|
||||
|
||||
---
|
||||
|
||||
## Правила именования LOT
|
||||
|
||||
Формат: `КАТЕГОРИЯ_МОДЕЛЬСХД_СПЕЦИФИКА`
|
||||
|
||||
- только латиница, цифры и знак `_`
|
||||
- регистр — ВЕРХНИЙ
|
||||
- без пробелов, дефисов, точек
|
||||
- каждый LOT уникален — два разных компонента не могут иметь одинаковое имя
|
||||
|
||||
### DKC — контроллерная полка
|
||||
|
||||
Специфика: `ТИПДИСКА_СЛОТЫ_NCTRL`
|
||||
|
||||
| Пример | Расшифровка |
|
||||
|---|---|
|
||||
| `DKC_DE4000H_SFF_24_2CTRL` | DE4000H, 24 слота SFF (2.5"), 2 контроллера |
|
||||
| `DKC_DE4000H_LFF_12_2CTRL` | DE4000H, 12 слотов LFF (3.5"), 2 контроллера |
|
||||
| `DKC_DE4000H_SFF_24_1CTRL` | DE4000H, 24 слота SFF, 1 контроллер (симплекс) |
|
||||
|
||||
Обозначения типа диска: `SFF` — 2.5", `LFF` — 3.5", `NVMe` — U.2/U.3.
|
||||
|
||||
### CTL — контроллер
|
||||
|
||||
Специфика: `КЭШГБ_ПОРТЫТИП` (если встроенные порты есть) или `КЭШГБ_BASE` (если без портов, добавляются через HIC)
|
||||
|
||||
| Пример | Расшифровка |
|
||||
|---|---|
|
||||
| `CTL_DE4000H_32GB_BASE` | 32GB кэш, без встроенных хост-портов |
|
||||
| `CTL_DE4000H_8GB_BASE` | 8GB кэш, без встроенных хост-портов |
|
||||
| `CTL_MSA2060_8GB_ISCSI10G_4P` | 8GB кэш, встроенные 4× iSCSI 10GbE |
|
||||
|
||||
### HIC — HIC-карты (интерфейс подключения)
|
||||
|
||||
Специфика: `NpПРОТОКОЛ` — без привязки к модели СХД, по аналогии с серверными `HBA_2pFC16`, `HBA_4pFC32_Gen6`.
|
||||
|
||||
| Пример | Расшифровка |
|
||||
|---|---|
|
||||
| `HIC_4pFC32` | 4 порта FC 32Gb |
|
||||
| `HIC_4pFC16` | 4 порта FC 16G/10GbE |
|
||||
| `HIC_4p25G_iSCSI` | 4 порта 25G iSCSI |
|
||||
| `HIC_4p12G_SAS` | 4 порта SAS 12Gb |
|
||||
| `HIC_2p10G_BaseT` | 2 порта 10G Base-T |
|
||||
|
||||
### HDD / SSD / NVMe — диски
|
||||
|
||||
Диски **не привязываются к модели СХД** — используются существующие LOT из серверного каталога (`HDD_...`, `SSD_...`, `NVME_...`). Новые LOT для дисков СХД не создаются; партномера дисков маппируются на уже существующие серверные LOT.
|
||||
|
||||
### ACC — кабели
|
||||
|
||||
Кабели **не привязываются к модели СХД**. Формат: `ACC_CABLE_{ТИП}_{ДЛИНА}` — универсальные LOT, одинаковые для серверов и СХД.
|
||||
|
||||
| Пример | Расшифровка |
|
||||
|---|---|
|
||||
| `ACC_CABLE_CAT6_10M` | Кабель CAT6 10м |
|
||||
| `ACC_CABLE_FC_OM4_3M` | Кабель FC LC-LC OM4 до 3м |
|
||||
| `ACC_CABLE_PWR_C13C14_15M` | Кабель питания C13–C14 1.5м |
|
||||
|
||||
### SW — программные лицензии
|
||||
|
||||
Специфика: краткое название функции.
|
||||
|
||||
| Пример | Расшифровка |
|
||||
|---|---|
|
||||
| `SW_DE4000H_ASYNC_MIRROR` | Async Mirroring |
|
||||
| `SW_DE4000H_SNAPSHOT_512` | Snapshot 512 |
|
||||
|
||||
---
|
||||
|
||||
## Таблица лотов: DE4000H (пример заполнения)
|
||||
|
||||
### DKC — контроллерная полка
|
||||
|
||||
| lot_name | vendor | model | description | disk_slots | disk_type | controllers |
|
||||
|---|---|---|---|---|---|---|
|
||||
| `DKC_DE4000H_SFF_24_2CTRL` | Lenovo | DE4000H 2U24 | DE4000H, 24× SFF, 2 контроллера | 24 | SFF | 2 |
|
||||
| `DKC_DE4000H_LFF_12_2CTRL` | Lenovo | DE4000H 2U12 | DE4000H, 12× LFF, 2 контроллера | 12 | LFF | 2 |
|
||||
|
||||
### CTL — контроллер
|
||||
|
||||
| lot_name | vendor | model | description | cache_gb | host_ports |
|
||||
|---|---|---|---|---|---|
|
||||
| `CTL_DE4000H_32GB_BASE` | Lenovo | DE4000 Controller 32GB Gen2 | Контроллер DE4000, 32GB кэш, без встроенных портов | 32 | — |
|
||||
| `CTL_DE4000H_8GB_BASE` | Lenovo | DE4000 Controller 8GB Gen2 | Контроллер DE4000, 8GB кэш, без встроенных портов | 8 | — |
|
||||
|
||||
### HIC — HIC-карты
|
||||
|
||||
| lot_name | vendor | model | description |
|
||||
|---|---|---|---|
|
||||
| `HIC_2p10G_BaseT` | Lenovo | HIC 10GBASE-T 2-Ports | HIC 10GBASE-T, 2 порта |
|
||||
| `HIC_4p25G_iSCSI` | Lenovo | HIC 10/25GbE iSCSI 4-ports | HIC iSCSI 10/25GbE, 4 порта |
|
||||
| `HIC_4p12G_SAS` | Lenovo | HIC 12Gb SAS 4-ports | HIC SAS 12Gb, 4 порта |
|
||||
| `HIC_4pFC32` | Lenovo | HIC 32Gb FC 4-ports | HIC FC 32Gb, 4 порта |
|
||||
| `HIC_4pFC16` | Lenovo | HIC 16G FC/10GbE 4-ports | HIC FC 16G/10GbE, 4 порта |
|
||||
|
||||
### HDD / SSD / NVMe / ACC — диски и кабели
|
||||
|
||||
Для дисков и кабелей новые LOT не создаются. Партномера маппируются на существующие серверные LOT из каталога.
|
||||
|
||||
### SW — программные лицензии
|
||||
|
||||
| lot_name | vendor | model | description |
|
||||
|---|---|---|---|
|
||||
| `SW_DE4000H_ASYNC_MIRROR` | Lenovo | DE4000H Asynchronous Mirroring | Лицензия Async Mirroring |
|
||||
| `SW_DE4000H_SNAPSHOT_512` | Lenovo | DE4000H Snapshot Upgrade 512 | Лицензия Snapshot 512 |
|
||||
| `SW_DE4000H_SYNC_MIRROR` | Lenovo | DE4000 Synchronous Mirroring | Лицензия Sync Mirroring |
|
||||
|
||||
---
|
||||
|
||||
## Таблица партномеров: DE4000H (пример заполнения)
|
||||
|
||||
Каждый Feature Code и Part Number должен быть привязан к своему LOT.
|
||||
Если у компонента есть оба — добавить две строки.
|
||||
|
||||
| partnumber | lot_name | описание |
|
||||
|---|---|---|
|
||||
| `BEY7` | `ENC_2U24_CHASSIS` | Lenovo ThinkSystem Storage 2U24 Chassis |
|
||||
| `BQA0` | `CTL_DE4000H_32GB_BASE` | DE4000 Controller 32GB Gen2 |
|
||||
| `BQ9Z` | `CTL_DE4000H_8GB_BASE` | DE4000 Controller 8GB Gen2 |
|
||||
| `B4B1` | `HIC_2p10G_BaseT` | HIC 10GBASE-T 2-Ports |
|
||||
| `4C57A14376` | `HIC_2p10G_BaseT` | HIC 10GBASE-T 2-Ports |
|
||||
| `B4BA` | `HIC_4p25G_iSCSI` | HIC 10/25GbE iSCSI 4-ports |
|
||||
| `4C57A14369` | `HIC_4p25G_iSCSI` | HIC 10/25GbE iSCSI 4-ports |
|
||||
| `B4B8` | `HIC_4p12G_SAS` | HIC 12Gb SAS 4-ports |
|
||||
| `4C57A14367` | `HIC_4p12G_SAS` | HIC 12Gb SAS 4-ports |
|
||||
| `B4B9` | `HIC_4pFC32` | HIC 32Gb FC 4-ports |
|
||||
| `4C57A14368` | `HIC_4pFC32` | HIC 32Gb FC 4-ports |
|
||||
| `B4B7` | `HIC_4pFC16` | HIC 16G FC/10GbE 4-ports |
|
||||
| `4C57A14366` | `HIC_4pFC16` | HIC 16G FC/10GbE 4-ports |
|
||||
| `BW12` | `HDD_SAS_02.4TB` | 2.4TB 10K 2.5" HDD 2U24 |
|
||||
| `4XB7A88046` | `HDD_SAS_02.4TB` | 2.4TB 10K 2.5" HDD 2U24 |
|
||||
| `B4C0` | `HDD_SAS_01.8TB` | 1.8TB 10K 2.5" HDD SED FIPS |
|
||||
| `4XB7A14114` | `HDD_SAS_01.8TB` | 1.8TB 10K 2.5" HDD SED FIPS |
|
||||
| `BW13` | `HDD_SAS_02.4TB` | 2.4TB 10K 2.5" HDD FIPS |
|
||||
| `4XB7A88048` | `HDD_SAS_02.4TB` | 2.4TB 10K 2.5" HDD FIPS |
|
||||
| `BKUQ` | `SSD_SAS_0.960T` | 960GB 1DWD 2.5" SSD |
|
||||
| `4XB7A74948` | `SSD_SAS_0.960T` | 960GB 1DWD 2.5" SSD |
|
||||
| `BKUT` | `SSD_SAS_01.92T` | 1.92TB 1DWD 2.5" SSD |
|
||||
| `4XB7A74951` | `SSD_SAS_01.92T` | 1.92TB 1DWD 2.5" SSD |
|
||||
| `BKUK` | `SSD_SAS_03.84T` | 3.84TB 1DWD 2.5" SSD |
|
||||
| `4XB7A74955` | `SSD_SAS_03.84T` | 3.84TB 1DWD 2.5" SSD |
|
||||
| `B4RY` | `SSD_SAS_07.68T` | 7.68TB 1DWD 2.5" SSD |
|
||||
| `4XB7A14176` | `SSD_SAS_07.68T` | 7.68TB 1DWD 2.5" SSD |
|
||||
| `B4CD` | `SSD_SAS_15.36T` | 15.36TB 1DWD 2.5" SSD |
|
||||
| `4XB7A14110` | `SSD_SAS_15.36T` | 15.36TB 1DWD 2.5" SSD |
|
||||
| `BWCJ` | `SSD_SAS_03.84T` | 3.84TB 1DWD 2.5" SSD FIPS |
|
||||
| `4XB7A88469` | `SSD_SAS_03.84T` | 3.84TB 1DWD 2.5" SSD FIPS |
|
||||
| `BW2B` | `SSD_SAS_15.36T` | 15.36TB 1DWD 2.5" SSD SED |
|
||||
| `4XB7A88466` | `SSD_SAS_15.36T` | 15.36TB 1DWD 2.5" SSD SED |
|
||||
| `AVFW` | `ACC_CABLE_CAT6_1M` | CAT6 0.75-1.5m |
|
||||
| `A1MT` | `ACC_CABLE_CAT6_10M` | CAT6 10m |
|
||||
| `90Y3718` | `ACC_CABLE_CAT6_10M` | CAT6 10m |
|
||||
| `A1MW` | `ACC_CABLE_CAT6_25M` | CAT6 25m |
|
||||
| `90Y3727` | `ACC_CABLE_CAT6_25M` | CAT6 25m |
|
||||
| `39Y7937` | `ACC_CABLE_PWR_C13C14_15M` | C13–C14 1.5m |
|
||||
| `39Y7938` | `ACC_CABLE_PWR_C13C20_28M` | C13–C20 2.8m |
|
||||
| `4L67A08371` | `ACC_CABLE_PWR_C13C14_43M` | C13–C14 4.3m |
|
||||
| `C932` | `SW_DE4000H_ASYNC_MIRROR` | DE4000H Asynchronous Mirroring |
|
||||
| `00WE123` | `SW_DE4000H_ASYNC_MIRROR` | DE4000H Asynchronous Mirroring |
|
||||
| `C930` | `SW_DE4000H_SNAPSHOT_512` | DE4000H Snapshot Upgrade 512 |
|
||||
| `C931` | `SW_DE4000H_SYNC_MIRROR` | DE4000 Synchronous Mirroring |
|
||||
|
||||
---
|
||||
|
||||
## Шаблон для новых моделей СХД
|
||||
|
||||
```
|
||||
DKC_МОДЕЛЬ_ТИПДИСКА_СЛОТЫ_NCTRL — контроллерная полка
|
||||
CTL_МОДЕЛЬ_КЭШГБ_ПОРТЫ — контроллер
|
||||
HIC_МОДЕЛЬ_ПРОТОКОЛ_СКОРОСТЬ_ПОРТЫ — HIC-карта (интерфейс подключения)
|
||||
SW_МОДЕЛЬ_ФУНКЦИЯ — лицензия
|
||||
```
|
||||
|
||||
Диски (HDD/SSD/NVMe) и кабели (ACC) — маппируются на существующие серверные LOT, новые не создаются.
|
||||
|
||||
Пример для HPE MSA 2060:
|
||||
```
|
||||
DKC_MSA2060_SFF_24_2CTRL
|
||||
CTL_MSA2060_8GB_ISCSI10G_4P
|
||||
HIC_MSA2060_FC32G_2P
|
||||
SW_MSA2060_REMOTE_SNAP
|
||||
```
|
||||
@@ -329,33 +329,60 @@ func parseGPUModel(lotName string) string {
|
||||
}
|
||||
parts := strings.Split(upper, "_")
|
||||
model := ""
|
||||
numSuffix := ""
|
||||
mem := ""
|
||||
for i, p := range parts {
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
switch p {
|
||||
case "NV", "NVIDIA", "INTEL", "AMD", "RADEON", "PCIE", "PCI", "SXM", "SXMX":
|
||||
case "NV", "NVIDIA", "INTEL", "AMD", "RADEON", "PCIE", "PCI", "SXM", "SXMX", "SFF", "LOVELACE":
|
||||
continue
|
||||
case "ADA", "AMPERE", "HOPPER", "BLACKWELL":
|
||||
if model != "" {
|
||||
archAbbr := map[string]string{
|
||||
"ADA": "ADA", "AMPERE": "AMP", "HOPPER": "HOP", "BLACKWELL": "BWL",
|
||||
}
|
||||
numSuffix += archAbbr[p]
|
||||
}
|
||||
continue
|
||||
default:
|
||||
if strings.Contains(p, "GB") {
|
||||
mem = p
|
||||
continue
|
||||
}
|
||||
if model == "" && (i > 0) {
|
||||
if model == "" && i > 0 {
|
||||
model = p
|
||||
} else if model != "" && numSuffix == "" && isNumeric(p) {
|
||||
numSuffix = p
|
||||
}
|
||||
}
|
||||
}
|
||||
if model != "" && mem != "" {
|
||||
return model + "_" + mem
|
||||
full := model
|
||||
if numSuffix != "" {
|
||||
full = model + numSuffix
|
||||
}
|
||||
if model != "" {
|
||||
return model
|
||||
if full != "" && mem != "" {
|
||||
return full + "_" + mem
|
||||
}
|
||||
if full != "" {
|
||||
return full
|
||||
}
|
||||
return normalizeModelToken(lotName)
|
||||
}
|
||||
|
||||
func isNumeric(s string) bool {
|
||||
if s == "" {
|
||||
return false
|
||||
}
|
||||
for _, r := range s {
|
||||
if r < '0' || r > '9' {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func parseMemGiB(lotName string) int {
|
||||
if m := reMemTiB.FindStringSubmatch(lotName); len(m) == 3 {
|
||||
return atoi(m[1]) * 1024
|
||||
|
||||
@@ -48,11 +48,13 @@ type ExportRequest struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
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"`
|
||||
Basis string `json:"basis"` // "fob" or "ddp"
|
||||
SaleMarkup float64 `json:"sale_markup"` // DDP multiplier; 0 defaults to 1.3
|
||||
}
|
||||
|
||||
func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
||||
@@ -252,6 +254,8 @@ func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) {
|
||||
IncludeEstimate: req.IncludeEstimate,
|
||||
IncludeStock: req.IncludeStock,
|
||||
IncludeCompetitor: req.IncludeCompetitor,
|
||||
Basis: req.Basis,
|
||||
SaleMarkup: req.SaleMarkup,
|
||||
}
|
||||
|
||||
data, err := h.exportService.ProjectToPricingExportData(result.Configs, opts)
|
||||
@@ -260,7 +264,15 @@ func (h *ExportHandler) ExportProjectPricingCSV(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
filename := fmt.Sprintf("%s (%s) pricing.csv", time.Now().Format("2006-01-02"), project.Code)
|
||||
basisLabel := "FOB"
|
||||
if strings.EqualFold(strings.TrimSpace(req.Basis), "ddp") {
|
||||
basisLabel = "DDP"
|
||||
}
|
||||
variantLabel := strings.TrimSpace(project.Variant)
|
||||
if variantLabel == "" {
|
||||
variantLabel = "main"
|
||||
}
|
||||
filename := fmt.Sprintf("%s (%s) %s %s.csv", time.Now().Format("2006-01-02"), project.Code, basisLabel, variantLabel)
|
||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||
|
||||
|
||||
@@ -28,8 +28,9 @@ type ComponentSyncResult struct {
|
||||
func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) {
|
||||
startTime := time.Now()
|
||||
|
||||
// Query to join lot with qt_lot_metadata (metadata only, no pricing)
|
||||
// Use LEFT JOIN to include lots without metadata
|
||||
// Build the component catalog from every runtime source of LOT names.
|
||||
// Storage lots may exist in qt_lot_metadata / qt_pricelist_items before they appear in lot,
|
||||
// so the sync cannot start from lot alone.
|
||||
type componentRow struct {
|
||||
LotName string
|
||||
LotDescription string
|
||||
@@ -40,15 +41,29 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error)
|
||||
var rows []componentRow
|
||||
err := mariaDB.Raw(`
|
||||
SELECT
|
||||
l.lot_name,
|
||||
l.lot_description,
|
||||
COALESCE(c.code, SUBSTRING_INDEX(l.lot_name, '_', 1)) as category,
|
||||
m.model
|
||||
FROM lot l
|
||||
LEFT JOIN qt_lot_metadata m ON l.lot_name = m.lot_name
|
||||
src.lot_name,
|
||||
COALESCE(MAX(NULLIF(TRIM(l.lot_description), '')), '') AS lot_description,
|
||||
COALESCE(
|
||||
MAX(NULLIF(TRIM(c.code), '')),
|
||||
MAX(NULLIF(TRIM(l.lot_category), '')),
|
||||
SUBSTRING_INDEX(src.lot_name, '_', 1)
|
||||
) AS category,
|
||||
MAX(NULLIF(TRIM(m.model), '')) AS model
|
||||
FROM (
|
||||
SELECT lot_name FROM lot
|
||||
UNION
|
||||
SELECT lot_name FROM qt_lot_metadata
|
||||
WHERE is_hidden = FALSE OR is_hidden IS NULL
|
||||
UNION
|
||||
SELECT lot_name FROM qt_pricelist_items
|
||||
) src
|
||||
LEFT JOIN lot l ON l.lot_name = src.lot_name
|
||||
LEFT JOIN qt_lot_metadata m
|
||||
ON m.lot_name = src.lot_name
|
||||
AND (m.is_hidden = FALSE OR m.is_hidden IS NULL)
|
||||
LEFT JOIN qt_categories c ON m.category_id = c.id
|
||||
WHERE m.is_hidden = FALSE OR m.is_hidden IS NULL
|
||||
ORDER BY l.lot_name
|
||||
GROUP BY src.lot_name
|
||||
ORDER BY src.lot_name
|
||||
`).Scan(&rows).Error
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("querying components from MariaDB: %w", err)
|
||||
@@ -71,18 +86,25 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error)
|
||||
existingMap[c.LotName] = true
|
||||
}
|
||||
|
||||
// Prepare components for batch insert/update
|
||||
// Prepare components for batch insert/update.
|
||||
// Source joins may duplicate the same lot_name, so collapse them before insert.
|
||||
syncTime := time.Now()
|
||||
components := make([]LocalComponent, 0, len(rows))
|
||||
componentIndex := make(map[string]int, len(rows))
|
||||
newCount := 0
|
||||
|
||||
for _, row := range rows {
|
||||
lotName := strings.TrimSpace(row.LotName)
|
||||
if lotName == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
category := ""
|
||||
if row.Category != nil {
|
||||
category = *row.Category
|
||||
category = strings.TrimSpace(*row.Category)
|
||||
} else {
|
||||
// Parse category from lot_name (e.g., "CPU_AMD_9654" -> "CPU")
|
||||
parts := strings.SplitN(row.LotName, "_", 2)
|
||||
parts := strings.SplitN(lotName, "_", 2)
|
||||
if len(parts) >= 1 {
|
||||
category = parts[0]
|
||||
}
|
||||
@@ -90,18 +112,34 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error)
|
||||
|
||||
model := ""
|
||||
if row.Model != nil {
|
||||
model = *row.Model
|
||||
model = strings.TrimSpace(*row.Model)
|
||||
}
|
||||
|
||||
comp := LocalComponent{
|
||||
LotName: row.LotName,
|
||||
LotDescription: row.LotDescription,
|
||||
LotName: lotName,
|
||||
LotDescription: strings.TrimSpace(row.LotDescription),
|
||||
Category: category,
|
||||
Model: model,
|
||||
}
|
||||
|
||||
if idx, exists := componentIndex[lotName]; exists {
|
||||
// Keep the first row, but fill any missing metadata from duplicates.
|
||||
if components[idx].LotDescription == "" && comp.LotDescription != "" {
|
||||
components[idx].LotDescription = comp.LotDescription
|
||||
}
|
||||
if components[idx].Category == "" && comp.Category != "" {
|
||||
components[idx].Category = comp.Category
|
||||
}
|
||||
if components[idx].Model == "" && comp.Model != "" {
|
||||
components[idx].Model = comp.Model
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
componentIndex[lotName] = len(components)
|
||||
components = append(components, comp)
|
||||
|
||||
if !existingMap[row.LotName] {
|
||||
if !existingMap[lotName] {
|
||||
newCount++
|
||||
}
|
||||
}
|
||||
|
||||
@@ -34,6 +34,7 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
|
||||
PricelistID: cfg.PricelistID,
|
||||
WarehousePricelistID: cfg.WarehousePricelistID,
|
||||
CompetitorPricelistID: cfg.CompetitorPricelistID,
|
||||
ConfigType: cfg.ConfigType,
|
||||
VendorSpec: modelVendorSpecToLocal(cfg.VendorSpec),
|
||||
DisablePriceRefresh: cfg.DisablePriceRefresh,
|
||||
OnlyInStock: cfg.OnlyInStock,
|
||||
@@ -82,6 +83,7 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
|
||||
PricelistID: local.PricelistID,
|
||||
WarehousePricelistID: local.WarehousePricelistID,
|
||||
CompetitorPricelistID: local.CompetitorPricelistID,
|
||||
ConfigType: local.ConfigType,
|
||||
VendorSpec: localVendorSpecToModel(local.VendorSpec),
|
||||
DisablePriceRefresh: local.DisablePriceRefresh,
|
||||
OnlyInStock: local.OnlyInStock,
|
||||
|
||||
@@ -116,6 +116,14 @@ func New(dbPath string) (*LocalDB, error) {
|
||||
return nil, fmt.Errorf("opening sqlite database: %w", err)
|
||||
}
|
||||
|
||||
// Enable WAL mode so background sync writes never block UI reads.
|
||||
if err := db.Exec("PRAGMA journal_mode=WAL").Error; err != nil {
|
||||
slog.Warn("failed to enable WAL mode", "error", err)
|
||||
}
|
||||
if err := db.Exec("PRAGMA synchronous=NORMAL").Error; err != nil {
|
||||
slog.Warn("failed to set synchronous=NORMAL", "error", err)
|
||||
}
|
||||
|
||||
if err := ensureLocalProjectsTable(db); err != nil {
|
||||
return nil, fmt.Errorf("ensure local_projects table: %w", err)
|
||||
}
|
||||
@@ -1152,6 +1160,29 @@ func (l *LocalDB) CountLocalPricelists() int64 {
|
||||
return count
|
||||
}
|
||||
|
||||
// CountAllPricelistItems returns total rows across all local_pricelist_items.
|
||||
func (l *LocalDB) CountAllPricelistItems() int64 {
|
||||
var count int64
|
||||
l.db.Model(&LocalPricelistItem{}).Count(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
// CountComponents returns the number of rows in local_components.
|
||||
func (l *LocalDB) CountComponents() int64 {
|
||||
var count int64
|
||||
l.db.Model(&LocalComponent{}).Count(&count)
|
||||
return count
|
||||
}
|
||||
|
||||
// DBFileSizeBytes returns the size of the SQLite database file in bytes.
|
||||
func (l *LocalDB) DBFileSizeBytes() int64 {
|
||||
info, err := os.Stat(l.path)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return info.Size()
|
||||
}
|
||||
|
||||
// GetLatestLocalPricelist returns the most recently synced pricelist
|
||||
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
|
||||
var pricelist LocalPricelist
|
||||
@@ -1319,6 +1350,32 @@ func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64
|
||||
return item.Price, nil
|
||||
}
|
||||
|
||||
// GetLocalPricesForLots returns prices for multiple lots from a local pricelist in a single query.
|
||||
// Uses the composite index (pricelist_id, lot_name). Missing lots are omitted from the result.
|
||||
func (l *LocalDB) GetLocalPricesForLots(pricelistID uint, lotNames []string) (map[string]float64, error) {
|
||||
result := make(map[string]float64, len(lotNames))
|
||||
if len(lotNames) == 0 {
|
||||
return result, nil
|
||||
}
|
||||
type row struct {
|
||||
LotName string `gorm:"column:lot_name"`
|
||||
Price float64 `gorm:"column:price"`
|
||||
}
|
||||
var rows []row
|
||||
if err := l.db.Model(&LocalPricelistItem{}).
|
||||
Select("lot_name, price").
|
||||
Where("pricelist_id = ? AND lot_name IN ?", pricelistID, lotNames).
|
||||
Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
for _, r := range rows {
|
||||
if r.Price > 0 {
|
||||
result[r.LotName] = r.Price
|
||||
}
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetLocalLotCategoriesByServerPricelistID returns lot_category for each lot_name from a local pricelist resolved by server ID.
|
||||
// Missing lots are not included in the map; caller is responsible for strict validation.
|
||||
func (l *LocalDB) GetLocalLotCategoriesByServerPricelistID(serverPricelistID uint, lotNames []string) (map[string]string, error) {
|
||||
|
||||
@@ -110,6 +110,7 @@ type LocalConfiguration struct {
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
UpdatedAt time.Time `json:"updated_at"`
|
||||
SyncedAt *time.Time `json:"synced_at"`
|
||||
ConfigType string `gorm:"default:server" json:"config_type"` // "server" | "storage"
|
||||
SyncStatus string `gorm:"default:'local'" json:"sync_status"` // 'local', 'synced', 'modified'
|
||||
OriginalUserID uint `json:"original_user_id"` // UserID from MariaDB for reference
|
||||
OriginalUsername string `gorm:"not null;default:'';index" json:"original_username"`
|
||||
|
||||
@@ -111,6 +111,7 @@ type Configuration struct {
|
||||
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
||||
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
||||
VendorSpec VendorSpec `gorm:"type:json" json:"vendor_spec,omitempty"`
|
||||
ConfigType string `gorm:"size:20;default:server" json:"config_type"` // "server" | "storage"
|
||||
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
|
||||
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
||||
Line int `gorm:"column:line_no;index" json:"line"`
|
||||
|
||||
@@ -31,7 +31,9 @@ func Migrate(db *gorm.DB) error {
|
||||
errStr := err.Error()
|
||||
if strings.Contains(errStr, "Can't DROP") ||
|
||||
strings.Contains(errStr, "Duplicate key name") ||
|
||||
strings.Contains(errStr, "check that it exists") {
|
||||
strings.Contains(errStr, "check that it exists") ||
|
||||
strings.Contains(errStr, "Cannot change column") ||
|
||||
strings.Contains(errStr, "used in a foreign key constraint") {
|
||||
slog.Warn("migration warning (skipped)", "model", model, "error", errStr)
|
||||
continue
|
||||
}
|
||||
|
||||
@@ -58,6 +58,7 @@ type CreateConfigRequest struct {
|
||||
PricelistID *uint `json:"pricelist_id,omitempty"`
|
||||
WarehousePricelistID *uint `json:"warehouse_pricelist_id,omitempty"`
|
||||
CompetitorPricelistID *uint `json:"competitor_pricelist_id,omitempty"`
|
||||
ConfigType string `json:"config_type,omitempty"` // "server" | "storage"
|
||||
DisablePriceRefresh bool `json:"disable_price_refresh"`
|
||||
OnlyInStock bool `json:"only_in_stock"`
|
||||
}
|
||||
@@ -103,9 +104,13 @@ func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigReq
|
||||
PricelistID: pricelistID,
|
||||
WarehousePricelistID: req.WarehousePricelistID,
|
||||
CompetitorPricelistID: req.CompetitorPricelistID,
|
||||
ConfigType: req.ConfigType,
|
||||
DisablePriceRefresh: req.DisablePriceRefresh,
|
||||
OnlyInStock: req.OnlyInStock,
|
||||
}
|
||||
if config.ConfigType == "" {
|
||||
config.ConfigType = "server"
|
||||
}
|
||||
|
||||
if err := s.configRepo.Create(config); err != nil {
|
||||
return nil, err
|
||||
|
||||
@@ -56,11 +56,24 @@ type ProjectExportData struct {
|
||||
}
|
||||
|
||||
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"`
|
||||
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"`
|
||||
Basis string `json:"basis"` // "fob" or "ddp"; empty defaults to "fob"
|
||||
SaleMarkup float64 `json:"sale_markup"` // DDP multiplier; 0 defaults to 1.3
|
||||
}
|
||||
|
||||
func (o ProjectPricingExportOptions) saleMarkupFactor() float64 {
|
||||
if o.SaleMarkup > 0 {
|
||||
return o.SaleMarkup
|
||||
}
|
||||
return 1.3
|
||||
}
|
||||
|
||||
func (o ProjectPricingExportOptions) isDDP() bool {
|
||||
return strings.EqualFold(strings.TrimSpace(o.Basis), "ddp")
|
||||
}
|
||||
|
||||
type ProjectPricingExportData struct {
|
||||
@@ -251,18 +264,16 @@ func (s *ExportService) ToPricingCSV(w io.Writer, data *ProjectPricingExportData
|
||||
return fmt.Errorf("failed to write pricing header: %w", err)
|
||||
}
|
||||
|
||||
for idx, cfg := range data.Configs {
|
||||
writeRows := opts.IncludeLOT || opts.IncludeBOM
|
||||
for _, 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)
|
||||
if writeRows {
|
||||
for _, row := range cfg.Rows {
|
||||
if err := csvWriter.Write(pricingCSVRow(row, opts)); err != nil {
|
||||
return fmt.Errorf("failed to write pricing row: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -424,6 +435,9 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
|
||||
Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity),
|
||||
})
|
||||
}
|
||||
if opts.isDDP() {
|
||||
applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
|
||||
}
|
||||
return block, nil
|
||||
}
|
||||
|
||||
@@ -443,9 +457,29 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
|
||||
})
|
||||
}
|
||||
|
||||
if opts.isDDP() {
|
||||
applyDDPMarkup(block.Rows, opts.saleMarkupFactor())
|
||||
}
|
||||
|
||||
return block, nil
|
||||
}
|
||||
|
||||
func applyDDPMarkup(rows []ProjectPricingExportRow, factor float64) {
|
||||
for i := range rows {
|
||||
rows[i].Estimate = scaleFloatPtr(rows[i].Estimate, factor)
|
||||
rows[i].Stock = scaleFloatPtr(rows[i].Stock, factor)
|
||||
rows[i].Competitor = scaleFloatPtr(rows[i].Competitor, factor)
|
||||
}
|
||||
}
|
||||
|
||||
func scaleFloatPtr(v *float64, factor float64) *float64 {
|
||||
if v == nil {
|
||||
return nil
|
||||
}
|
||||
result := *v * factor
|
||||
return &result
|
||||
}
|
||||
|
||||
// resolveCategories returns lot_name → category map.
|
||||
// Primary source: pricelist items (lot_category). Fallback: local_components table.
|
||||
func (s *ExportService) resolveCategories(pricelistID *uint, lotNames []string) map[string]string {
|
||||
@@ -735,7 +769,7 @@ func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricing
|
||||
record = append(record, "")
|
||||
}
|
||||
record = append(record,
|
||||
"",
|
||||
emptyDash(cfg.Article),
|
||||
emptyDash(cfg.Name),
|
||||
fmt.Sprintf("%d", exportPositiveInt(cfg.ServerCount, 1)),
|
||||
)
|
||||
|
||||
@@ -49,11 +49,13 @@ func NewLocalConfigurationService(
|
||||
|
||||
// Create creates a new configuration in local SQLite and queues it for sync
|
||||
func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
|
||||
// If online, check for new pricelists first
|
||||
// If online, trigger pricelist sync in the background — do not block config creation
|
||||
if s.isOnline() {
|
||||
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
|
||||
// Log but don't fail - we can still use local pricelists
|
||||
}
|
||||
go func() {
|
||||
if err := s.syncService.SyncPricelistsIfNeeded(); err != nil {
|
||||
// Log but don't fail - we can still use local pricelists
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
||||
@@ -99,10 +101,14 @@ func (s *LocalConfigurationService) Create(ownerUsername string, req *CreateConf
|
||||
PricelistID: pricelistID,
|
||||
WarehousePricelistID: req.WarehousePricelistID,
|
||||
CompetitorPricelistID: req.CompetitorPricelistID,
|
||||
ConfigType: req.ConfigType,
|
||||
DisablePriceRefresh: req.DisablePriceRefresh,
|
||||
OnlyInStock: req.OnlyInStock,
|
||||
CreatedAt: time.Now(),
|
||||
}
|
||||
if cfg.ConfigType == "" {
|
||||
cfg.ConfigType = "server"
|
||||
}
|
||||
|
||||
// Convert to local model
|
||||
localCfg := localdb.ConfigurationToLocal(cfg)
|
||||
|
||||
@@ -21,6 +21,7 @@ var (
|
||||
ErrProjectCodeExists = errors.New("project code and variant already exist")
|
||||
ErrCannotDeleteMainVariant = errors.New("cannot delete main variant")
|
||||
ErrReservedMainVariant = errors.New("variant name 'main' is reserved")
|
||||
ErrCannotRenameMainVariant = errors.New("cannot rename main variant")
|
||||
)
|
||||
|
||||
type ProjectService struct {
|
||||
@@ -108,7 +109,12 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
|
||||
localProject.Code = code
|
||||
}
|
||||
if req.Variant != nil {
|
||||
localProject.Variant = strings.TrimSpace(*req.Variant)
|
||||
newVariant := strings.TrimSpace(*req.Variant)
|
||||
// Block renaming of the main variant (empty Variant) — there must always be a main.
|
||||
if strings.TrimSpace(localProject.Variant) == "" && newVariant != "" {
|
||||
return nil, ErrCannotRenameMainVariant
|
||||
}
|
||||
localProject.Variant = newVariant
|
||||
if err := validateProjectVariantName(localProject.Variant); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
@@ -388,13 +388,14 @@ func (s *QuoteService) lookupPricesByPricelistID(pricelistID uint, lotNames []st
|
||||
}
|
||||
}
|
||||
|
||||
// Fallback path (usually offline): local per-lot lookup.
|
||||
// Fallback path (usually offline): batch local lookup (single query via index).
|
||||
if s.localDB != nil {
|
||||
for _, lotName := range missing {
|
||||
price, found := s.lookupPriceByPricelistID(pricelistID, lotName)
|
||||
if found && price > 0 {
|
||||
result[lotName] = price
|
||||
loaded[lotName] = price
|
||||
if localPL, err := s.localDB.GetLocalPricelistByServerID(pricelistID); err == nil {
|
||||
if batchPrices, err := s.localDB.GetLocalPricesForLots(localPL.ID, missing); err == nil {
|
||||
for lotName, price := range batchPrices {
|
||||
result[lotName] = price
|
||||
loaded[lotName] = price
|
||||
}
|
||||
}
|
||||
}
|
||||
s.updateCache(pricelistID, missing, loaded)
|
||||
|
||||
@@ -168,6 +168,10 @@ func ensureClientSchemaStateTable(db *gorm.DB) error {
|
||||
"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",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS local_pricelist_count INT NOT NULL DEFAULT 0 AFTER last_sync_error_text",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS pricelist_items_count INT NOT NULL DEFAULT 0 AFTER local_pricelist_count",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS components_count INT NOT NULL DEFAULT 0 AFTER pricelist_items_count",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS db_size_bytes BIGINT NOT NULL DEFAULT 0 AFTER components_count",
|
||||
} {
|
||||
if err := db.Exec(stmt).Error; err != nil {
|
||||
return fmt.Errorf("expand qt_client_schema_state: %w", err)
|
||||
@@ -215,6 +219,10 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
|
||||
warehouseVersion := latestPricelistVersion(s.localDB, "warehouse")
|
||||
competitorVersion := latestPricelistVersion(s.localDB, "competitor")
|
||||
lastSyncErrorCode, lastSyncErrorText := latestSyncErrorState(s.localDB)
|
||||
localPricelistCount := s.localDB.CountLocalPricelists()
|
||||
pricelistItemsCount := s.localDB.CountAllPricelistItems()
|
||||
componentsCount := s.localDB.CountComponents()
|
||||
dbSizeBytes := s.localDB.DBFileSizeBytes()
|
||||
return mariaDB.Exec(`
|
||||
INSERT INTO qt_client_schema_state (
|
||||
username, hostname, app_version,
|
||||
@@ -222,9 +230,10 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
|
||||
configurations_count, projects_count,
|
||||
estimate_pricelist_version, warehouse_pricelist_version, competitor_pricelist_version,
|
||||
last_sync_error_code, last_sync_error_text,
|
||||
local_pricelist_count, pricelist_items_count, components_count, db_size_bytes,
|
||||
last_checked_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
app_version = VALUES(app_version),
|
||||
last_sync_at = VALUES(last_sync_at),
|
||||
@@ -238,6 +247,10 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
|
||||
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),
|
||||
local_pricelist_count = VALUES(local_pricelist_count),
|
||||
pricelist_items_count = VALUES(pricelist_items_count),
|
||||
components_count = VALUES(components_count),
|
||||
db_size_bytes = VALUES(db_size_bytes),
|
||||
last_checked_at = VALUES(last_checked_at),
|
||||
updated_at = VALUES(updated_at)
|
||||
`, username, hostname, appmeta.Version(),
|
||||
@@ -245,6 +258,7 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
|
||||
configurationsCount, projectsCount,
|
||||
estimateVersion, warehouseVersion, competitorVersion,
|
||||
lastSyncErrorCode, lastSyncErrorText,
|
||||
localPricelistCount, pricelistItemsCount, componentsCount, dbSizeBytes,
|
||||
checkedAt, checkedAt).Error
|
||||
}
|
||||
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||
@@ -22,9 +23,10 @@ var ErrOffline = errors.New("database is offline")
|
||||
|
||||
// Service handles synchronization between MariaDB and local SQLite
|
||||
type Service struct {
|
||||
connMgr *db.ConnectionManager
|
||||
localDB *localdb.LocalDB
|
||||
directDB *gorm.DB
|
||||
connMgr *db.ConnectionManager
|
||||
localDB *localdb.LocalDB
|
||||
directDB *gorm.DB
|
||||
pricelistMu sync.Mutex // prevents concurrent pricelist syncs
|
||||
}
|
||||
|
||||
// NewService creates a new sync service
|
||||
@@ -787,9 +789,6 @@ func (s *Service) fetchServerPricelistItems(serverPricelistID uint) ([]localdb.L
|
||||
for i, item := range serverItems {
|
||||
localItems[i] = *localdb.PricelistItemToLocal(&item, 0)
|
||||
}
|
||||
if err := s.enrichLocalPricelistItemsWithStock(mariaDB, localItems); err != nil {
|
||||
slog.Warn("pricelist stock enrichment skipped", "server_pricelist_id", serverPricelistID, "error", err)
|
||||
}
|
||||
|
||||
return localItems, nil
|
||||
}
|
||||
@@ -803,111 +802,6 @@ func (s *Service) SyncPricelistItemsByServerID(serverPricelistID uint) (int, err
|
||||
return s.SyncPricelistItems(localPL.ID)
|
||||
}
|
||||
|
||||
func (s *Service) enrichLocalPricelistItemsWithStock(mariaDB *gorm.DB, items []localdb.LocalPricelistItem) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
bookRepo := repository.NewPartnumberBookRepository(s.localDB.DB())
|
||||
book, err := bookRepo.GetActiveBook()
|
||||
if err != nil || book == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
bookItems, err := bookRepo.GetBookItems(book.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(bookItems) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
partnumberToLots := make(map[string][]string, len(bookItems))
|
||||
for _, item := range bookItems {
|
||||
pn := strings.TrimSpace(item.Partnumber)
|
||||
if pn == "" {
|
||||
continue
|
||||
}
|
||||
seenLots := make(map[string]struct{}, len(item.LotsJSON))
|
||||
for _, lot := range item.LotsJSON {
|
||||
lotName := strings.TrimSpace(lot.LotName)
|
||||
if lotName == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(lotName)
|
||||
if _, exists := seenLots[key]; exists {
|
||||
continue
|
||||
}
|
||||
seenLots[key] = struct{}{}
|
||||
partnumberToLots[pn] = append(partnumberToLots[pn], lotName)
|
||||
}
|
||||
}
|
||||
if len(partnumberToLots) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
type stockRow struct {
|
||||
Partnumber string `gorm:"column:partnumber"`
|
||||
Qty *float64 `gorm:"column:qty"`
|
||||
}
|
||||
rows := make([]stockRow, 0)
|
||||
if err := mariaDB.Raw(`
|
||||
SELECT s.partnumber, s.qty
|
||||
FROM stock_log s
|
||||
INNER JOIN (
|
||||
SELECT partnumber, MAX(date) AS max_date
|
||||
FROM stock_log
|
||||
GROUP BY partnumber
|
||||
) latest ON latest.partnumber = s.partnumber AND latest.max_date = s.date
|
||||
WHERE s.qty IS NOT NULL
|
||||
`).Scan(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
lotTotals := make(map[string]float64, len(items))
|
||||
lotPartnumbers := make(map[string][]string, len(items))
|
||||
seenPartnumbers := make(map[string]map[string]struct{}, len(items))
|
||||
|
||||
for _, row := range rows {
|
||||
pn := strings.TrimSpace(row.Partnumber)
|
||||
if pn == "" || row.Qty == nil {
|
||||
continue
|
||||
}
|
||||
lots := partnumberToLots[pn]
|
||||
if len(lots) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, lotName := range lots {
|
||||
lotTotals[lotName] += *row.Qty
|
||||
if _, ok := seenPartnumbers[lotName]; !ok {
|
||||
seenPartnumbers[lotName] = make(map[string]struct{}, 4)
|
||||
}
|
||||
key := strings.ToLower(pn)
|
||||
if _, exists := seenPartnumbers[lotName][key]; exists {
|
||||
continue
|
||||
}
|
||||
seenPartnumbers[lotName][key] = struct{}{}
|
||||
lotPartnumbers[lotName] = append(lotPartnumbers[lotName], pn)
|
||||
}
|
||||
}
|
||||
|
||||
for i := range items {
|
||||
lotName := strings.TrimSpace(items[i].LotName)
|
||||
if qty, ok := lotTotals[lotName]; ok {
|
||||
qtyCopy := qty
|
||||
items[i].AvailableQty = &qtyCopy
|
||||
}
|
||||
if partnumbers := lotPartnumbers[lotName]; len(partnumbers) > 0 {
|
||||
sort.Slice(partnumbers, func(a, b int) bool {
|
||||
return strings.ToLower(partnumbers[a]) < strings.ToLower(partnumbers[b])
|
||||
})
|
||||
items[i].Partnumbers = append(localdb.LocalStringList{}, partnumbers...)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLocalPriceForLot returns the price for a lot from a local pricelist
|
||||
func (s *Service) GetLocalPriceForLot(localPricelistID uint, lotName string) (float64, error) {
|
||||
return s.localDB.GetLocalPriceForLot(localPricelistID, lotName)
|
||||
@@ -939,9 +833,15 @@ func (s *Service) GetPricelistForOffline(serverPricelistID uint) (*localdb.Local
|
||||
return localPL, nil
|
||||
}
|
||||
|
||||
// SyncPricelistsIfNeeded checks for new pricelists and syncs if needed
|
||||
// This should be called before creating a new configuration when online
|
||||
// SyncPricelistsIfNeeded checks for new pricelists and syncs if needed.
|
||||
// If a sync is already in progress, returns immediately without blocking.
|
||||
func (s *Service) SyncPricelistsIfNeeded() error {
|
||||
if !s.pricelistMu.TryLock() {
|
||||
slog.Debug("pricelist sync already in progress, skipping")
|
||||
return nil
|
||||
}
|
||||
defer s.pricelistMu.Unlock()
|
||||
|
||||
needSync, err := s.NeedSync()
|
||||
if err != nil {
|
||||
slog.Warn("failed to check if sync needed", "error", err)
|
||||
|
||||
@@ -17,7 +17,6 @@ func TestSyncPricelists_BackfillsLotCategoryForUsedPricelistItems(t *testing.T)
|
||||
&models.Pricelist{},
|
||||
&models.PricelistItem{},
|
||||
&models.Lot{},
|
||||
&models.StockLog{},
|
||||
); err != nil {
|
||||
t.Fatalf("migrate server tables: %v", err)
|
||||
}
|
||||
@@ -103,103 +102,3 @@ func TestSyncPricelists_BackfillsLotCategoryForUsedPricelistItems(t *testing.T)
|
||||
t.Fatalf("expected lot_category backfilled to CPU, got %q", items[0].LotCategory)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncPricelistItems_EnrichesStockFromLocalPartnumberBook(t *testing.T) {
|
||||
local := newLocalDBForSyncTest(t)
|
||||
serverDB := newServerDBForSyncTest(t)
|
||||
|
||||
if err := serverDB.AutoMigrate(
|
||||
&models.Pricelist{},
|
||||
&models.PricelistItem{},
|
||||
&models.Lot{},
|
||||
&models.StockLog{},
|
||||
); err != nil {
|
||||
t.Fatalf("migrate server tables: %v", err)
|
||||
}
|
||||
|
||||
serverPL := models.Pricelist{
|
||||
Source: "warehouse",
|
||||
Version: "2026-03-07-001",
|
||||
Notification: "server",
|
||||
CreatedBy: "tester",
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now().Add(-1 * time.Hour),
|
||||
}
|
||||
if err := serverDB.Create(&serverPL).Error; err != nil {
|
||||
t.Fatalf("create server pricelist: %v", err)
|
||||
}
|
||||
if err := serverDB.Create(&models.PricelistItem{
|
||||
PricelistID: serverPL.ID,
|
||||
LotName: "CPU_A",
|
||||
LotCategory: "CPU",
|
||||
Price: 10,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create server pricelist item: %v", err)
|
||||
}
|
||||
qty := 7.0
|
||||
if err := serverDB.Create(&models.StockLog{
|
||||
Partnumber: "CPU-PN-1",
|
||||
Date: time.Now(),
|
||||
Price: 100,
|
||||
Qty: &qty,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create stock log: %v", err)
|
||||
}
|
||||
|
||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||
ServerID: serverPL.ID,
|
||||
Source: serverPL.Source,
|
||||
Version: serverPL.Version,
|
||||
Name: serverPL.Notification,
|
||||
CreatedAt: serverPL.CreatedAt,
|
||||
SyncedAt: time.Now(),
|
||||
IsUsed: false,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed local pricelist: %v", err)
|
||||
}
|
||||
localPL, err := local.GetLocalPricelistByServerID(serverPL.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get local pricelist: %v", err)
|
||||
}
|
||||
|
||||
if err := local.DB().Create(&localdb.LocalPartnumberBook{
|
||||
ServerID: 1,
|
||||
Version: "2026-03-07-001",
|
||||
CreatedAt: time.Now(),
|
||||
IsActive: true,
|
||||
PartnumbersJSON: localdb.LocalStringList{"CPU-PN-1"},
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create local partnumber book: %v", err)
|
||||
}
|
||||
if err := local.DB().Create(&localdb.LocalPartnumberBookItem{
|
||||
Partnumber: "CPU-PN-1",
|
||||
LotsJSON: localdb.LocalPartnumberBookLots{
|
||||
{LotName: "CPU_A", Qty: 1},
|
||||
},
|
||||
Description: "CPU PN",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create local partnumber book item: %v", err)
|
||||
}
|
||||
|
||||
svc := syncsvc.NewServiceWithDB(serverDB, local)
|
||||
if _, err := svc.SyncPricelistItems(localPL.ID); err != nil {
|
||||
t.Fatalf("sync pricelist items: %v", err)
|
||||
}
|
||||
|
||||
items, err := local.GetLocalPricelistItems(localPL.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("load local items: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 local item, got %d", len(items))
|
||||
}
|
||||
if items[0].AvailableQty == nil {
|
||||
t.Fatalf("expected available_qty to be set")
|
||||
}
|
||||
if *items[0].AvailableQty != 7 {
|
||||
t.Fatalf("expected available_qty=7, got %v", *items[0].AvailableQty)
|
||||
}
|
||||
if len(items[0].Partnumbers) != 1 || items[0].Partnumbers[0] != "CPU-PN-1" {
|
||||
t.Fatalf("expected partnumbers [CPU-PN-1], got %v", items[0].Partnumbers)
|
||||
}
|
||||
}
|
||||
|
||||
2
migrations/029_add_config_type.sql
Normal file
2
migrations/029_add_config_type.sql
Normal file
@@ -0,0 +1,2 @@
|
||||
ALTER TABLE qt_configurations
|
||||
ADD COLUMN config_type VARCHAR(20) NOT NULL DEFAULT 'server';
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}Ревизии - QuoteForge{{end}}
|
||||
{{define "title"}}Ревизии - OFS{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}Мои конфигурации - QuoteForge{{end}}
|
||||
{{define "title"}}Мои конфигурации - OFS{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
@@ -53,6 +53,19 @@
|
||||
<h2 class="text-xl font-semibold mb-4">Новая конфигурация</h2>
|
||||
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Тип оборудования</label>
|
||||
<div class="inline-flex rounded-lg border border-gray-200 overflow-hidden w-full">
|
||||
<button type="button" id="type-server-btn" onclick="setCreateType('server')"
|
||||
class="flex-1 py-2 text-sm font-medium bg-blue-600 text-white">
|
||||
Сервер
|
||||
</button>
|
||||
<button type="button" id="type-storage-btn" onclick="setCreateType('storage')"
|
||||
class="flex-1 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200">
|
||||
СХД
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Название конфигурации</label>
|
||||
<input type="text" id="opportunity-number" placeholder="Например: Сервер для проекта X"
|
||||
@@ -518,7 +531,19 @@ async function cloneConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
let createConfigType = 'server';
|
||||
|
||||
function setCreateType(type) {
|
||||
createConfigType = type;
|
||||
document.getElementById('type-server-btn').className = 'flex-1 py-2 text-sm font-medium ' +
|
||||
(type === 'server' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200');
|
||||
document.getElementById('type-storage-btn').className = 'flex-1 py-2 text-sm font-medium border-l border-gray-200 ' +
|
||||
(type === 'storage' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50');
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
createConfigType = 'server';
|
||||
setCreateType('server');
|
||||
document.getElementById('opportunity-number').value = '';
|
||||
document.getElementById('create-project-input').value = '';
|
||||
document.getElementById('create-modal').classList.remove('hidden');
|
||||
@@ -573,7 +598,8 @@ async function createConfigWithProject(name, projectUUID) {
|
||||
items: [],
|
||||
notes: '',
|
||||
server_count: 1,
|
||||
project_uuid: projectUUID || null
|
||||
project_uuid: projectUUID || null,
|
||||
config_type: createConfigType
|
||||
})
|
||||
});
|
||||
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}QuoteForge - Конфигуратор{{end}}
|
||||
{{define "title"}}OFS - Конфигуратор{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
@@ -108,6 +108,10 @@
|
||||
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
|
||||
Accessories
|
||||
</button>
|
||||
<button onclick="switchTab('sw')" data-tab="sw"
|
||||
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
|
||||
SW
|
||||
</button>
|
||||
<button onclick="switchTab('other')" data-tab="other"
|
||||
class="tab-btn px-4 py-3 text-sm font-medium border-b-2 border-transparent text-gray-500 hover:text-gray-700 whitespace-nowrap">
|
||||
Other
|
||||
@@ -365,7 +369,7 @@
|
||||
// Tab configuration - will be populated dynamically
|
||||
let TAB_CONFIG = {
|
||||
base: {
|
||||
categories: ['MB', 'CPU', 'MEM'],
|
||||
categories: ['MB', 'CPU', 'MEM', 'ENC', 'DKC', 'CTL'],
|
||||
singleSelect: true,
|
||||
label: 'Base'
|
||||
},
|
||||
@@ -374,18 +378,18 @@ let TAB_CONFIG = {
|
||||
singleSelect: false,
|
||||
label: 'Storage',
|
||||
sections: [
|
||||
{ title: 'RAID Контроллеры', categories: ['RAID'] },
|
||||
{ title: 'Диски', categories: ['M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'] }
|
||||
]
|
||||
},
|
||||
pci: {
|
||||
categories: ['GPU', 'DPU', 'NIC', 'HCA', 'HBA'],
|
||||
categories: ['GPU', 'DPU', 'NIC', 'HCA', 'HBA', 'HIC'],
|
||||
singleSelect: false,
|
||||
label: 'PCI',
|
||||
sections: [
|
||||
{ title: 'GPU / DPU', categories: ['GPU', 'DPU'] },
|
||||
{ title: 'NIC / HCA', categories: ['NIC', 'HCA'] },
|
||||
{ title: 'HBA', categories: ['HBA'] }
|
||||
{ title: 'HBA', categories: ['HBA'] },
|
||||
{ title: 'HIC', categories: ['HIC'] }
|
||||
]
|
||||
},
|
||||
power: {
|
||||
@@ -398,6 +402,11 @@ let TAB_CONFIG = {
|
||||
singleSelect: false,
|
||||
label: 'Accessories'
|
||||
},
|
||||
sw: {
|
||||
categories: ['SW'],
|
||||
singleSelect: false,
|
||||
label: 'SW'
|
||||
},
|
||||
other: {
|
||||
categories: [],
|
||||
singleSelect: false,
|
||||
@@ -411,6 +420,7 @@ let ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
|
||||
|
||||
// State
|
||||
let configUUID = '{{.ConfigUUID}}';
|
||||
let configType = 'server';
|
||||
let configName = '';
|
||||
let currentVersionNo = 1;
|
||||
let projectUUID = '';
|
||||
@@ -477,6 +487,7 @@ function updateConfigBreadcrumbs() {
|
||||
configEl.textContent = truncateBreadcrumbSpecName(fullConfigName);
|
||||
configEl.title = fullConfigName;
|
||||
versionEl.textContent = 'main';
|
||||
document.title = code + ' / ' + variant + ' / ' + fullConfigName + ' — OFS';
|
||||
const configNameLinkEl = document.getElementById('breadcrumb-config-name-link');
|
||||
if (configNameLinkEl && configUUID) {
|
||||
configNameLinkEl.href = '/configs/' + configUUID + '/revisions';
|
||||
@@ -792,6 +803,8 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
|
||||
const config = await resp.json();
|
||||
configName = config.name;
|
||||
configType = config.config_type || 'server';
|
||||
applyConfigTypeToTabs();
|
||||
currentVersionNo = config.current_version_no || 1;
|
||||
projectUUID = config.project_uuid || '';
|
||||
await loadProjectIndex();
|
||||
@@ -823,6 +836,7 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
serverModelForQuote = config.server_model || '';
|
||||
supportCode = config.support_code || '';
|
||||
currentArticle = config.article || '';
|
||||
restorePricingStateFromNotes(config.notes || '');
|
||||
|
||||
// Restore custom price if saved
|
||||
if (config.custom_price) {
|
||||
@@ -1138,6 +1152,81 @@ function switchTab(tab) {
|
||||
renderTab();
|
||||
}
|
||||
|
||||
const ALWAYS_VISIBLE_TABS = new Set(['base', 'storage', 'pci']);
|
||||
|
||||
// Storage-only categories — hidden for server configs
|
||||
const STORAGE_ONLY_BASE_CATEGORIES = ['DKC', 'CTL', 'ENC'];
|
||||
// Server-only categories — hidden for storage configs
|
||||
const SERVER_ONLY_BASE_CATEGORIES = ['MB', 'CPU', 'MEM'];
|
||||
const STORAGE_HIDDEN_STORAGE_CATEGORIES = ['RAID'];
|
||||
const STORAGE_HIDDEN_PCI_CATEGORIES = ['GPU', 'DPU'];
|
||||
const STORAGE_HIDDEN_POWER_CATEGORIES = ['PS', 'PSU'];
|
||||
|
||||
function applyConfigTypeToTabs() {
|
||||
const baseCategories = ['MB', 'CPU', 'MEM', 'DKC', 'CTL', 'ENC'];
|
||||
const storageCategories = ['RAID', 'M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'];
|
||||
const storageSections = [
|
||||
{ title: 'Диски', categories: ['M2', 'SSD', 'HDD', 'EDSFF', 'HHHL'] }
|
||||
];
|
||||
const pciCategories = ['GPU', 'DPU', 'NIC', 'HCA', 'HBA', 'HIC'];
|
||||
const pciSections = [
|
||||
{ title: 'GPU / DPU', categories: ['GPU', 'DPU'] },
|
||||
{ title: 'NIC / HCA', categories: ['NIC', 'HCA'] },
|
||||
{ title: 'HBA', categories: ['HBA'] },
|
||||
{ title: 'HIC', categories: ['HIC'] }
|
||||
];
|
||||
const powerCategories = ['PS', 'PSU'];
|
||||
|
||||
TAB_CONFIG.base.categories = baseCategories.filter(c => {
|
||||
if (configType === 'storage') {
|
||||
return !SERVER_ONLY_BASE_CATEGORIES.includes(c);
|
||||
}
|
||||
return !STORAGE_ONLY_BASE_CATEGORIES.includes(c);
|
||||
});
|
||||
|
||||
TAB_CONFIG.storage.categories = storageCategories.filter(c => {
|
||||
return configType === 'storage' ? !STORAGE_HIDDEN_STORAGE_CATEGORIES.includes(c) : true;
|
||||
});
|
||||
TAB_CONFIG.storage.sections = storageSections;
|
||||
|
||||
TAB_CONFIG.pci.categories = pciCategories.filter(c => {
|
||||
return configType === 'storage' ? !STORAGE_HIDDEN_PCI_CATEGORIES.includes(c) : c !== 'HIC';
|
||||
});
|
||||
TAB_CONFIG.pci.sections = pciSections.filter(section => {
|
||||
if (configType === 'storage') {
|
||||
return !section.categories.every(cat => STORAGE_HIDDEN_PCI_CATEGORIES.includes(cat));
|
||||
}
|
||||
return section.title !== 'HIC';
|
||||
});
|
||||
TAB_CONFIG.power.categories = powerCategories.filter(c => {
|
||||
return configType === 'storage' ? !STORAGE_HIDDEN_POWER_CATEGORIES.includes(c) : true;
|
||||
});
|
||||
|
||||
// Rebuild assigned categories index
|
||||
ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
|
||||
.flatMap(t => t.categories)
|
||||
.map(c => c.toUpperCase());
|
||||
}
|
||||
|
||||
function updateTabVisibility() {
|
||||
for (const tabId of Object.keys(TAB_CONFIG)) {
|
||||
if (ALWAYS_VISIBLE_TABS.has(tabId)) continue;
|
||||
const btn = document.querySelector(`[data-tab="${tabId}"]`);
|
||||
if (!btn) continue;
|
||||
const hasComponents = getComponentsForTab(tabId).length > 0;
|
||||
const hasCartItems = cart.some(item => {
|
||||
const cat = (item.category || getCategoryFromLotName(item.lot_name) || '').toUpperCase();
|
||||
return getTabForCategory(cat) === tabId;
|
||||
});
|
||||
const visible = hasComponents || hasCartItems;
|
||||
btn.classList.toggle('hidden', !visible);
|
||||
// If the current tab just got hidden, fall back to base
|
||||
if (!visible && currentTab === tabId) {
|
||||
switchTab('base');
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function getComponentsForTab(tab) {
|
||||
const config = TAB_CONFIG[tab];
|
||||
return allComponents.filter(comp => {
|
||||
@@ -1173,7 +1262,7 @@ function renderSingleSelectTab(categories) {
|
||||
if (currentTab === 'base') {
|
||||
html += `
|
||||
<div class="mb-1 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
|
||||
<label for="server-model-input" class="block text-sm font-medium text-gray-700">Модель сервера для КП:</label>
|
||||
<label for="server-model-input" class="block text-sm font-medium text-gray-700">Модель системы для партномера:</label>
|
||||
<label for="support-code-select" class="block text-sm font-medium text-gray-700">Уровень техподдержки:</label>
|
||||
</div>
|
||||
<div class="mb-3 grid grid-cols-1 md:grid-cols-[1fr,16rem] gap-3 items-start">
|
||||
@@ -1869,12 +1958,14 @@ function updateMultiQuantity(lotName, value) {
|
||||
|
||||
function removeFromCart(lotName) {
|
||||
cart = cart.filter(i => i.lot_name !== lotName);
|
||||
updateTabVisibility();
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
triggerAutoSave();
|
||||
}
|
||||
|
||||
function updateCartUI() {
|
||||
updateTabVisibility();
|
||||
window._currentCart = cart; // expose for BOM/Pricing tabs
|
||||
const total = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
|
||||
document.getElementById('cart-total').textContent = formatMoney(total);
|
||||
@@ -2024,6 +2115,58 @@ function getCurrentArticle() {
|
||||
return currentArticle || '';
|
||||
}
|
||||
|
||||
function buildPricingState() {
|
||||
const buyCustom = parseDecimalInput(document.getElementById('pricing-custom-price-buy')?.value || '');
|
||||
const saleUplift = parseDecimalInput(document.getElementById('pricing-uplift-sale')?.value || '');
|
||||
const saleCustom = parseDecimalInput(document.getElementById('pricing-custom-price-sale')?.value || '');
|
||||
|
||||
return {
|
||||
buy_custom_price: buyCustom > 0 ? buyCustom : null,
|
||||
sale_uplift: saleUplift > 0 ? saleUplift : null,
|
||||
sale_custom_price: saleCustom > 0 ? saleCustom : null,
|
||||
};
|
||||
}
|
||||
|
||||
function serializeConfigNotes() {
|
||||
return JSON.stringify({
|
||||
pricing_ui: buildPricingState()
|
||||
});
|
||||
}
|
||||
|
||||
function restorePricingStateFromNotes(notesRaw) {
|
||||
if (!notesRaw) return;
|
||||
let parsed;
|
||||
try {
|
||||
parsed = JSON.parse(notesRaw);
|
||||
} catch (_) {
|
||||
return;
|
||||
}
|
||||
|
||||
const pricing = parsed?.pricing_ui;
|
||||
if (!pricing || typeof pricing !== 'object') return;
|
||||
|
||||
const buyInput = document.getElementById('pricing-custom-price-buy');
|
||||
if (buyInput) {
|
||||
buyInput.value = typeof pricing.buy_custom_price === 'number' && pricing.buy_custom_price > 0
|
||||
? pricing.buy_custom_price.toFixed(2)
|
||||
: '';
|
||||
}
|
||||
|
||||
const upliftInput = document.getElementById('pricing-uplift-sale');
|
||||
if (upliftInput) {
|
||||
upliftInput.value = typeof pricing.sale_uplift === 'number' && pricing.sale_uplift > 0
|
||||
? formatUpliftInput(pricing.sale_uplift)
|
||||
: '';
|
||||
}
|
||||
|
||||
const saleInput = document.getElementById('pricing-custom-price-sale');
|
||||
if (saleInput) {
|
||||
saleInput.value = typeof pricing.sale_custom_price === 'number' && pricing.sale_custom_price > 0
|
||||
? pricing.sale_custom_price.toFixed(2)
|
||||
: '';
|
||||
}
|
||||
}
|
||||
|
||||
function getAutosaveStorageKey() {
|
||||
return `qf_config_autosave_${configUUID || 'default'}`;
|
||||
}
|
||||
@@ -2037,7 +2180,7 @@ function buildSavePayload() {
|
||||
name: configName,
|
||||
items: cart,
|
||||
custom_price: customPrice,
|
||||
notes: '',
|
||||
notes: serializeConfigNotes(),
|
||||
server_count: serverCount,
|
||||
server_model: serverModelForQuote,
|
||||
support_code: supportCode,
|
||||
@@ -2516,66 +2659,67 @@ async function refreshPrices() {
|
||||
return;
|
||||
}
|
||||
|
||||
const refreshBtn = document.getElementById('refresh-prices-btn');
|
||||
const previousLabel = refreshBtn ? refreshBtn.textContent : '';
|
||||
|
||||
try {
|
||||
const refreshPayload = {};
|
||||
if (selectedPricelistIds.estimate) {
|
||||
refreshPayload.pricelist_id = selectedPricelistIds.estimate;
|
||||
if (refreshBtn) {
|
||||
refreshBtn.disabled = true;
|
||||
refreshBtn.textContent = 'Обновление...';
|
||||
refreshBtn.className = 'px-4 py-2 bg-gray-300 text-gray-500 rounded cursor-not-allowed';
|
||||
}
|
||||
const resp = await fetch('/api/configs/' + configUUID + '/refresh-prices', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(refreshPayload)
|
||||
|
||||
const componentSyncResp = await fetch('/api/sync/components', { method: 'POST' });
|
||||
if (!componentSyncResp.ok) {
|
||||
throw new Error('component sync failed');
|
||||
}
|
||||
|
||||
const pricelistSyncResp = await fetch('/api/sync/pricelists', { method: 'POST' });
|
||||
if (!pricelistSyncResp.ok) {
|
||||
throw new Error('pricelist sync failed');
|
||||
}
|
||||
|
||||
await Promise.all([
|
||||
loadActivePricelists(true),
|
||||
loadAllComponents()
|
||||
]);
|
||||
|
||||
['estimate', 'warehouse', 'competitor'].forEach(source => {
|
||||
const latest = activePricelistsBySource[source]?.[0];
|
||||
if (latest && latest.id) {
|
||||
selectedPricelistIds[source] = Number(latest.id);
|
||||
resolvedAutoPricelistIds[source] = null;
|
||||
}
|
||||
});
|
||||
|
||||
if (!resp.ok) {
|
||||
showToast('Ошибка обновления цен', 'error');
|
||||
return;
|
||||
}
|
||||
syncPriceSettingsControls();
|
||||
renderPricelistSettingsSummary();
|
||||
persistLocalPriceSettings();
|
||||
|
||||
const config = await resp.json();
|
||||
|
||||
// Update cart with new prices
|
||||
if (config.items && config.items.length > 0) {
|
||||
cart = config.items.map(item => ({
|
||||
lot_name: item.lot_name,
|
||||
quantity: item.quantity,
|
||||
unit_price: item.unit_price,
|
||||
estimate_price: item.unit_price,
|
||||
warehouse_price: null,
|
||||
competitor_price: null,
|
||||
description: item.description || '',
|
||||
category: item.category || getCategoryFromLotName(item.lot_name)
|
||||
}));
|
||||
}
|
||||
|
||||
// Update price update date
|
||||
if (config.price_updated_at) {
|
||||
updatePriceUpdateDate(config.price_updated_at);
|
||||
}
|
||||
if (config.pricelist_id) {
|
||||
if (selectedPricelistIds.estimate) {
|
||||
selectedPricelistIds.estimate = config.pricelist_id;
|
||||
} else {
|
||||
resolvedAutoPricelistIds.estimate = Number(config.pricelist_id);
|
||||
}
|
||||
if (!activePricelistsBySource.estimate.some(opt => Number(opt.id) === Number(config.pricelist_id))) {
|
||||
await loadActivePricelists();
|
||||
}
|
||||
syncPriceSettingsControls();
|
||||
renderPricelistSettingsSummary();
|
||||
if (selectedPricelistIds.estimate) {
|
||||
persistLocalPriceSettings();
|
||||
}
|
||||
}
|
||||
|
||||
// Re-render UI
|
||||
await saveConfig(false);
|
||||
await refreshPriceLevels({ force: true, noCache: true });
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
|
||||
if (configUUID) {
|
||||
const configResp = await fetch('/api/configs/' + configUUID);
|
||||
if (configResp.ok) {
|
||||
const config = await configResp.json();
|
||||
if (config.price_updated_at) {
|
||||
updatePriceUpdateDate(config.price_updated_at);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
showToast('Цены обновлены', 'success');
|
||||
} catch(e) {
|
||||
showToast('Ошибка обновления цен', 'error');
|
||||
} finally {
|
||||
if (refreshBtn) {
|
||||
refreshBtn.disabled = false;
|
||||
refreshBtn.textContent = previousLabel || 'Обновить цены';
|
||||
updateRefreshPricesButtonState();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3947,14 +4091,17 @@ function applyCustomPrice(table) {
|
||||
|
||||
function onBuyCustomPriceInput() {
|
||||
applyCustomPrice('buy');
|
||||
triggerAutoSave();
|
||||
}
|
||||
|
||||
function onSaleCustomPriceInput() {
|
||||
applyCustomPrice('sale');
|
||||
triggerAutoSave();
|
||||
}
|
||||
|
||||
function onSaleMarkupInput() {
|
||||
renderPricingTab();
|
||||
triggerAutoSave();
|
||||
}
|
||||
|
||||
function setPricingCustomPriceFromVendor() {
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}QuoteForge - Партномера{{end}}
|
||||
{{define "title"}}OFS - Партномера{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}Прайслист - QuoteForge{{end}}
|
||||
{{define "title"}}Прайслист - OFS{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-6">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}Прайслисты - QuoteForge{{end}}
|
||||
{{define "title"}}Прайслисты - OFS{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-6">
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}Проект - QuoteForge{{end}}
|
||||
{{define "title"}}Проект - OFS{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
@@ -77,6 +77,19 @@
|
||||
<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-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Тип оборудования</label>
|
||||
<div class="inline-flex rounded-lg border border-gray-200 overflow-hidden w-full">
|
||||
<button type="button" id="pd-type-server-btn" onclick="pdSetCreateType('server')"
|
||||
class="flex-1 py-2 text-sm font-medium bg-blue-600 text-white">
|
||||
Сервер
|
||||
</button>
|
||||
<button type="button" id="pd-type-storage-btn" onclick="pdSetCreateType('storage')"
|
||||
class="flex-1 py-2 text-sm font-medium bg-white text-gray-700 hover:bg-gray-50 border-l border-gray-200">
|
||||
СХД
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Название конфигурации</label>
|
||||
<input type="text" id="create-name" placeholder="Например: OPP-2026-001"
|
||||
@@ -113,33 +126,60 @@
|
||||
|
||||
<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.
|
||||
<h2 class="text-xl font-semibold mb-5">Экспорт CSV</h2>
|
||||
<div class="space-y-5">
|
||||
|
||||
<!-- Section 1: Артикул -->
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-2">Артикул</p>
|
||||
<div class="space-y-2">
|
||||
<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 class="text-gray-400 font-normal">(строки по BOM, иначе по Estimate)</span></span>
|
||||
</label>
|
||||
</div>
|
||||
</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>
|
||||
|
||||
<!-- Section 2: Цены -->
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-2">Цены</p>
|
||||
<div class="space-y-2">
|
||||
<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>Est</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>
|
||||
|
||||
<!-- Section 3: Базис поставки -->
|
||||
<div>
|
||||
<p class="text-xs font-semibold uppercase tracking-wide text-gray-500 mb-2">Базис поставки</p>
|
||||
<div class="flex gap-6">
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
|
||||
<input type="radio" name="export-basis" value="fob" class="border-gray-300" checked>
|
||||
<span class="font-medium">FOB</span>
|
||||
<span class="text-gray-400">— Цена покупки</span>
|
||||
</label>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700 cursor-pointer">
|
||||
<input type="radio" name="export-basis" value="ddp" class="border-gray-300">
|
||||
<span class="font-medium">DDP</span>
|
||||
<span class="text-gray-400">— Цена продажи ×1,3</span>
|
||||
</label>
|
||||
</div>
|
||||
</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">
|
||||
@@ -363,6 +403,7 @@ function renderVariantSelect() {
|
||||
if (item.uuid === projectUUID) {
|
||||
option.className += ' font-semibold text-gray-900';
|
||||
label.textContent = variantLabel;
|
||||
document.title = (project && project.code ? project.code : '—') + ' / ' + variantLabel + ' — OFS';
|
||||
}
|
||||
option.textContent = variantLabel;
|
||||
option.onclick = function() {
|
||||
@@ -548,7 +589,19 @@ async function loadConfigs() {
|
||||
}
|
||||
}
|
||||
|
||||
let pdCreateConfigType = 'server';
|
||||
|
||||
function pdSetCreateType(type) {
|
||||
pdCreateConfigType = type;
|
||||
document.getElementById('pd-type-server-btn').className = 'flex-1 py-2 text-sm font-medium ' +
|
||||
(type === 'server' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50');
|
||||
document.getElementById('pd-type-storage-btn').className = 'flex-1 py-2 text-sm font-medium border-l border-gray-200 ' +
|
||||
(type === 'storage' ? 'bg-blue-600 text-white' : 'bg-white text-gray-700 hover:bg-gray-50');
|
||||
}
|
||||
|
||||
function openCreateModal() {
|
||||
pdCreateConfigType = 'server';
|
||||
pdSetCreateType('server');
|
||||
document.getElementById('create-name').value = '';
|
||||
document.getElementById('create-modal').classList.remove('hidden');
|
||||
document.getElementById('create-modal').classList.add('flex');
|
||||
@@ -906,7 +959,7 @@ async function createConfig() {
|
||||
const resp = await fetch('/api/projects/' + projectUUID + '/configs', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: name, items: [], notes: '', server_count: 1})
|
||||
body: JSON.stringify({name: name, items: [], notes: '', server_count: 1, config_type: pdCreateConfigType})
|
||||
});
|
||||
if (!resp.ok) {
|
||||
alert('Не удалось создать конфигурацию');
|
||||
@@ -1474,7 +1527,8 @@ async function exportProject() {
|
||||
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
|
||||
include_competitor: !!document.getElementById('export-col-competitor')?.checked,
|
||||
basis: document.querySelector('input[name="export-basis"]:checked')?.value || 'fob'
|
||||
};
|
||||
|
||||
if (submitBtn) submitBtn.disabled = true;
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
{{define "title"}}Мои проекты - QuoteForge{{end}}
|
||||
{{define "title"}}Мои проекты - OFS{{end}}
|
||||
|
||||
{{define "content"}}
|
||||
<div class="space-y-4">
|
||||
|
||||
@@ -4,7 +4,7 @@
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>QuoteForge - Настройка подключения</title>
|
||||
<title>OFS - Настройка подключения</title>
|
||||
<link rel="stylesheet" href="/static/app.css">
|
||||
<script src="/static/vendor/tailwindcss.browser.js"></script>
|
||||
</head>
|
||||
|
||||
Reference in New Issue
Block a user