From 70a3ff255f02e49242a1d7cce7d9265f6cb72ba7 Mon Sep 17 00:00:00 2001 From: Mikhail Chusavitin Date: Fri, 10 Jul 2026 10:25:46 +0300 Subject: [PATCH] =?UTF-8?q?feat:=20world-=D0=BF=D1=80=D0=B0=D0=B9=D1=81?= =?UTF-8?q?=D0=BB=D0=B8=D1=81=D1=82=20=D0=BA=D0=B0=D0=BA=20=D0=B7=D0=B0?= =?UTF-8?q?=D0=B3=D0=BB=D1=83=D1=88=D0=BA=D0=B0=20=D0=B4=D0=BB=D1=8F=20?= =?UTF-8?q?=D0=BE=D1=82=D1=81=D1=83=D1=82=D1=81=D1=82=D0=B2=D1=83=D1=8E?= =?UTF-8?q?=D1=89=D0=B8=D1=85=20=D1=86=D0=B5=D0=BD?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Там, где для LOT нет цены в estimate/warehouse/competitor, подставляется цена из world-прайслиста. Такие ячейки в таблицах «Цена покупки»/«Цена продажи» подсвечиваются (amber), участвуют в «Итого» и убирают красную «*». В CSV-экспорте добавлена колонка «Заглушка (world)» с перечнем столбцов, где сработал фолбэк. Добавлены Tx-версии GetLatestLocalPricelistBySource/GetLocalPricesForLots, чтобы резолв прайслистов внутри транзакции не дедлочил single-connection пул. Co-Authored-By: Claude Opus 4.8 --- TASK_world_pricelist_fallback.md | 328 ++++++++++++++++++ bible-local/04-api.md | 2 +- .../2026-07-10-world-pricelist-fallback.md | 45 +++ internal/localdb/localdb.go | 20 +- internal/models/pricelist.go | 5 +- internal/services/export.go | 115 ++++-- internal/services/quote.go | 38 ++ internal/services/quote_price_levels_test.go | 45 +++ internal/services/vendor_workspace_import.go | 16 +- web/templates/index.html | 24 +- 10 files changed, 586 insertions(+), 52 deletions(-) create mode 100644 TASK_world_pricelist_fallback.md create mode 100644 bible-local/decisions/2026-07-10-world-pricelist-fallback.md diff --git a/TASK_world_pricelist_fallback.md b/TASK_world_pricelist_fallback.md new file mode 100644 index 0000000..04109b6 --- /dev/null +++ b/TASK_world_pricelist_fallback.md @@ -0,0 +1,328 @@ +# Task: World-прайслист как заглушка для отсутствующих цен + +> Это задание для агента-исполнителя. Оно самодостаточно: содержит все файлы, точки +> входа, edge-cases и способ проверки. Не отступай от контрактов ниже без причины. + +## Context (зачем) + +В приложение синхронизируется 4 типа прайслистов (`source`): `estimate`, `warehouse`, +`competitor` и `world`. Первые три отображаются в таблицах «Цена покупки» / «Цена продажи» +на вкладке «Ценообразование». **`world` сейчас не читается нигде** — он уже приезжает и +лежит в `local_pricelists` (sync копирует любой `source`, что прислал сервер), но код его +игнорирует. + +Задача: сделать `world` **фолбэком-заглушкой**. Там, где для LOT нет цены в +estimate/warehouse/competitor, подставить цену из `world` и **выделить такую ячейку цветом**, +чтобы было видно, что цена не из «родного» прайслиста. + +На скриншоте видно проблему: у ряда позиций пусто («—») в колонках «Склад» и «Конкуренты», +из-за чего «Итого» неполный и помечен красной звёздочкой «*». + +## Решения по продукту (согласованы с заказчиком) + +1. **Колонки:** world подставляется во **все три** — Estimate, Склад (warehouse), Конкуренты + (competitor), в любую, где нет цены. +2. **Итого:** подставленная world-цена **участвует в «Итого»**. За счёт этого покрытие + становится полным и красная «*» пропадает (если все пробелы закрыты world). +3. **Подсветка:** красим **только конкретную ячейку**, где сработала подстановка (не всю строку). + Используем янтарный/amber (см. ниже), т.к. семейства gray/green/red/blue стираются регэкспом + в `applyCustomPrice`. +4. **Охват:** та же логика применяется в таблице **«Цена продажи»** и в **CSV-экспорте** + (в CSV цвета нет → добавляем отдельную колонку-комментарий «Заглушка (world)», где перечислены + столбцы с фолбэком, напр. `world: Stock, Конкуренты`; см. раздел CSV). + +## Ключевой факт: source-константы + +Файл `internal/models/pricelist.go` определяет `PricelistSource`. Сейчас нет `world`. +Добавь константу и включи её в валидацию: + +```go +const ( + PricelistSourceEstimate PricelistSource = "estimate" + PricelistSourceWarehouse PricelistSource = "warehouse" + PricelistSourceCompetitor PricelistSource = "competitor" + PricelistSourceWorld PricelistSource = "world" // NEW +) +``` +- Добавь `PricelistSourceWorld` в `IsValid()`. +- **НЕ трогай** `NormalizePricelistSource` так, чтобы `world` схлопывался в `estimate` — наоборот, + добавь ветку `case PricelistSourceWorld: return PricelistSourceWorld`, иначе где-то world + превратится в estimate. Проверь всех вызывающих `NormalizePricelistSource`. +- `world`-строки уже синкаются, схема БД менять не нужно (`source` — свободная строка). + Отдельный столбец `world_pricelist_id` в конфигурациях **не добавляем** — world резолвим + всегда через «последний активный», как ниже. + +--- + +## ЧАСТЬ 1 — Backend: `internal/services/quote.go` → `CalculatePriceLevels` + +Это основной источник данных для экранной таблицы (endpoint `POST /api/quote/price-levels`, +handler `internal/handlers/quote.go:53`). + +### 1.1 Расширить ответную структуру `PriceLevelsItem` (строки ~80-93) + +Добавь три флага, показывающих, что соответствующая цена подставлена из world: + +```go +type PriceLevelsItem struct { + // ... существующие поля ... + EstimateFromWorld bool `json:"estimate_from_world"` + WarehouseFromWorld bool `json:"warehouse_from_world"` + CompetitorFromWorld bool `json:"competitor_from_world"` + PriceMissing []string `json:"price_missing"` +} +``` + +### 1.2 Резолв world-прайслиста и его цен + +В `CalculatePriceLevels` карта `levelBySource` (строки ~205-209) содержит только 3 источника. +Добавь резолв world **отдельно** (не обязательно добавлять его в `levelBySource`, чтобы не +попасть в `ResolvedPricelistIDs` как «обычный» уровень — хотя можно и добавить, тогда +он просто не отрисуется на фронте). Проще всего — отдельный блок после цикла резолва: + +- Определи `worldID uint`: + - если `req.PricelistIDs["world"] > 0` — взять его; + - иначе `s.pricelistRepo.GetLatestActiveBySource("world")`; + - иначе `s.localDB.GetLatestLocalPricelistBySource("world")`. +- Если `worldID != 0` — `worldPrices, _ := s.lookupPricesByPricelistID(worldID, lotNames, req.NoCache)`. +- Если world-прайслиста нет — `worldPrices` пустой, фолбэк просто не срабатывает (штатно). + +Переиспользуй существующий `lookupPricesByPricelistID` (строка ~286) — он уже умеет +server→local fallback и кэш. Ничего нового писать не надо. + +### 1.3 Применить фолбэк в цикле по позициям (строки ~253-274) + +Сейчас логика: `EstimatePrice`/`WarehousePrice`/`CompetitorPrice` ставятся только если +`p > 0`, иначе `nil` и `source` дописывается в `PriceMissing`. + +Новая логика для КАЖДОЙ из трёх колонок (пример для warehouse): + +```go +if p, ok := levelBySource[...Warehouse].prices[reqItem.LotName]; ok && p > 0 { + price := p + item.WarehousePrice = &price +} else if wp, ok := worldPrices[reqItem.LotName]; ok && wp > 0 { + price := wp + item.WarehousePrice = &price + item.WarehouseFromWorld = true +} +``` + +- Аналогично для Estimate (`EstimateFromWorld`) и Competitor (`CompetitorFromWorld`). +- **`PriceMissing`**: позиция считается missing **только если и родной, и world цены нет**. + Т.е. дописывай source в `PriceMissing`, только когда `item.XxxPrice == nil` ПОСЛЕ попытки + world-фолбэка. Существующие проверки `if item.WarehousePrice == nil { append PriceMissing }` + сработают корректно сами, т.к. при успешном world-фолбэке указатель уже не nil. ✅ +- Дельты (`calculateDelta`, строки ~276-278) остаются как есть — они работают по указателям и + автоматически учтут world-цены. + +**Gotcha:** порядок важен — сначала пытаемся родную цену, только при её отсутствии/≤0 берём world. + +--- + +## ЧАСТЬ 2 — Frontend: `web/templates/index.html` → `renderPricingTab` + +Данные приходят в `priceMap[U(lot)]` = объект `PriceLevelsItem` с новыми полями +`*_from_world`. Нужно протянуть флаги до ячеек и покрасить. + +### 2.1 `_getUnitPrices(pl)` (строки ~4125-4129) + +Верни ещё и флаги источника: + +```js +const _getUnitPrices = (pl) => ({ + estUnit: (pl && pl.estimate_price > 0) ? pl.estimate_price : 0, + warehouseUnit: (pl && pl.warehouse_price > 0) ? pl.warehouse_price : null, + competitorUnit: (pl && pl.competitor_price > 0) ? pl.competitor_price : null, + estWorld: !!(pl && pl.estimate_from_world), + whWorld: !!(pl && pl.warehouse_from_world), + compWorld: !!(pl && pl.competitor_from_world), +}); +``` + +### 2.2 Протянуть флаги через `_buildRows` (строки ~4135-4244) + +В каждом месте, где формируется sub-row/row объект (`_pushCartRow` ~4143, baseLot ~4185, +allocs ~4197, финальный push ~4221), добавь поля `estWorld/whWorld/compWorld` из `u`. +- Для «н/д»-строки без subRows (строки ~4208-4215) — все три `false`. +- В финальном push (строки ~4221-4232) прокинь `estWorld: sub.estWorld` и т.д. + +### 2.3 Покрасить ячейки в Buy-таблице (строки ~4283-4285) + +Определи хелпер класса подсветки один раз в начале `renderPricingTab`: + +```js +const WORLD_CLS = 'bg-amber-50 text-amber-700'; // фон ячейки + цвет текста +``` + +Применяй к соответствующей ``: + +```js +${r.estUnit > 0 ? formatCurrency(r.estUnit) : '—'} +${r.warehouseUnit != null ? formatCurrency(r.warehouseUnit) : '—'} +${r.competitorUnit != null ? formatCurrency(r.competitorUnit) : '—'} +``` + +> **Важно про цвет:** НЕ используй `text-gray/green/red/blue-*` для маркера — `applyCustomPrice` +> (строки ~4393-4461) вырезает эти семейства регэкспом на vendor-ячейках. `amber` безопасен. +> `amber-50/amber-700` уже в духе палитры проекта (там есть `bg-orange-*`, `text-red-*`). + +### 2.4 Итого и красная «*» (строки ~4269-4293, `_setPartialTotal` ~4357-4367) + +Здесь **менять почти ничего не нужно**: т.к. backend теперь отдаёт world-цену в +`warehouse_price`/`competitor_price` (не nil), значения `r.warehouse`/`r.competitor` перестают +быть null там, где сработал world → `cntWh`/`cntComp` увеличиваются → покрытие полное → +`_setPartialTotal` сам уберёт «*». ✅ Просто проверь это поведение при тесте. + +### 2.5 Таблица «Цена продажи» (строки ~4303-4345) + +Sale-таблица считает цены как `unit * множитель` из тех же `r.warehouseUnit`/`r.competitorUnit` +(строки ~4310-4312), поэтому world-цены попадут туда автоматически. Нужно только **покрасить** +ячейки Estimate/Склад/Конкуренты по тем же флагам (строки ~4335-4337) — добавь +`${r.estWorld ? WORLD_CLS : ''}` и аналоги, как в 2.3. + +--- + +## ЧАСТЬ 3 — CSV-экспорт: `internal/services/export.go` + +Путь экспорта полностью отдельный от `CalculatePriceLevels`. Точка входа: +`ExportConfigPricingCSV` (`internal/handlers/export.go:226`) → +`ConfigToPricingExportData` → `buildPricingExportBlock` → `resolvePricingTotals` (строка ~593). + +### 3.1 World-фолбэк в `resolvePricingTotals` (строки ~593-641) + +- После резолва `estimateID/warehouseID/competitorID` добавь `worldID`: + `s.localDB.GetLatestLocalPricelistBySource("world")` → `worldID = &latest.ServerID`. +- `worldPrices := s.batchLookupPrices(worldID, lots)` (переиспользуй существующий batch, строка ~645). +- В цикле по `lots` (строки ~628-639): если родной цены нет — подставь `worldPrices[lot]` и + запомни, что она из world. + +### 3.2 Пометка world-цен в CSV + +Расширь `pricingLevels` (строка ~587) флагами источника: + +```go +type pricingLevels struct { + Estimate *float64 + Stock *float64 + Competitor *float64 + EstimateWorld bool + StockWorld bool + CompetitorWorld bool +} +``` + +Прокинь их до `ProjectPricingExportRow` (строка ~90) — добавь такие же bool-поля +(смотри, где строится row: `buildPricingExportBlock` ~строка 353). + +**Способ пометки: отдельная колонка-комментарий** (согласовано с заказчиком). Значения цен НЕ +меняем (никаких суффиксов) — добавляем в конец строки новую текстовую колонку, где перечислены +столбцы, в которых цена взята из world-прайслиста. + +#### Заголовок — `pricingCSVHeaders` (строки ~767-790) + +Добавь колонку **последней** (после «Ручная цена»), чтобы не сдвигать существующие столбцы: + +```go +headers = append(headers, "Заглушка (world)") +``` + +#### Строка — `pricingCSVRow` (строки ~792-819) + +Собери человекочитаемый список названий колонок, где сработал world-фолбэк, и добавь его +последним полем. Названия бери те же, что в заголовках таблицы: `Estimate`, `Stock`, `Конкуренты`. + +```go +var worldCols []string +if row.EstimateWorld { worldCols = append(worldCols, "Estimate") } +if row.StockWorld { worldCols = append(worldCols, "Stock") } +if row.CompetitorWorld { worldCols = append(worldCols, "Конкуренты") } +comment := "" +if len(worldCols) > 0 { + comment = "world: " + strings.Join(worldCols, ", ") +} +record = append(record, comment) // напр. "world: Stock, Конкуренты"; пусто, если фолбэка не было +``` + +- Если world-фолбэк ни в одной колонке не сработал — ячейка пустая (`""`). +- Порядок и число колонок в заголовке и в строке должны совпадать — колонку-комментарий + добавляй **и туда, и туда** последней, безусловно (не под флагом `opts.*`), чтобы CSV не + «съехал». + +#### Итоговая строка — `pricingConfigSummaryRow` (строки ~821-848) + +Добавь пустую ячейку в конец (комментарий на суммарной строке не нужен), чтобы число колонок +совпадало с шапкой: + +```go +record = append(record, "") +``` + +- Суммы (`sumPricingColumn`, строки ~877+) считают по указателям — world-цены уже включены в + Estimate/Stock/Competitor, ничего не меняем. +- Не забудь `import "strings"` — он в файле уже есть (используется в `collectPricingLots`). + +--- + +## Files to modify + +| Файл | Что | +|------|-----| +| `internal/models/pricelist.go` | Константа `PricelistSourceWorld`, `IsValid`, `NormalizePricelistSource` | +| `internal/services/quote.go` | `PriceLevelsItem` + 3 флага; резолв world; фолбэк в `CalculatePriceLevels` | +| `web/templates/index.html` | `_getUnitPrices`, `_buildRows`, покраска ячеек Buy+Sale (`WORLD_CLS`) | +| `internal/services/export.go` | `pricingLevels`+флаги, `resolvePricingTotals` world-фолбэк, `ProjectPricingExportRow`, `pricingCSVRow` маркер | + +## Edge cases (чтобы не споткнуться) + +1. **World-прайслиста нет вовсе** → `worldID == 0`, `worldPrices` пуст → поведение как сейчас + (пустые «—» и «*»). Не должно падать. +2. **World-цена = 0 или ≤ 0** → считается отсутствующей, фолбэк не срабатывает (проверка `wp > 0`). +3. **Родная цена есть** → world НЕ используется, флаг `false`, ячейка не красится. +4. **LOT есть только в world** → все три колонки берутся из world и красятся; `PriceMissing` + пустой; строка полностью «заглушечная». +5. **Кэш цен** (`priceCache` в quote.go) — world идёт через тот же `lookupPricesByPricelistID`, + ключ кэша включает `pricelistID`, коллизий нет. +6. **`applyCustomPrice` regex** — маркер-класс должен быть `amber` (или иное семейство, кроме + gray/green/red/blue), иначе будет затёрт на vendor-ячейках. Vendor-колонку world не трогает. +7. **Sale-таблица** множит на коэффициент — world-цена корректно умножается, красим по флагу. +8. **Нормализация LOT** — все lookup'ы идут по `NormalizeLotName` (uppercase); world-цены + тоже резолвятся через `lookupPricesByPricelistID`, который матчит `UPPER(lot_name)`. Согласовано. + +## Verification (как проверить end-to-end) + +1. Сборка: `go build ./cmd/qfs && go vet ./...` +2. Убедись, что в локальной БД есть world-прайслист с ценами: + ```bash + sqlite3 ~/.local/state/quoteforge/qfs.db \ + "SELECT id,source,version FROM local_pricelists WHERE source='world';" + sqlite3 ~/.local/state/quoteforge/qfs.db \ + "SELECT COUNT(*) FROM local_pricelist_items WHERE pricelist_id=(SELECT id FROM local_pricelists WHERE source='world' ORDER BY id DESC LIMIT 1);" + ``` + Если world-прайслиста нет — синхронизировать/залить тестовый (иначе фолбэк нечем проверять). +3. Backend-проверка API напрямую (подставь LOT, у которого нет warehouse/competitor цены, но + есть world): + ```bash + curl -s -X POST http://localhost:8080/api/quote/price-levels \ + -H 'Content-Type: application/json' \ + -d '{"items":[{"lot_name":"MB_INTEL_4.SAPHIRE(EMERALD)RAPIDS_2S_32XDDR5_HGX8","quantity":1}]}' | jq + ``` + Ожидаем: у позиции без родной competitor-цены `competitor_price != null` и + `competitor_from_world: true`, а `price_missing` не содержит `competitor`. +4. UI (`go run ./cmd/qfs`, вкладка «Ценообразование», раздел «Цена покупки»): + - ячейки, ранее «—» в «Склад»/«Конкуренты», теперь показывают цену на **amber-фоне**; + - «Итого» по этим колонкам стало полным и **без красной «*»** (если все пробелы закрыты world); + - то же самое в «Цена продажи». + Прогони этот шаг через `/verify` или браузерную автоматизацию для скриншота до/после. +5. CSV: нажми «Экспорт CSV», убедись, что world-цены присутствуют в Estimate/Stock/Конкуренты, + а в последней колонке «Заглушка (world)» указано, где именно сработал фолбэк + (напр. `world: Stock, Конкуренты`). Число колонок в шапке, строках и итоговой строке совпадает. +6. Существующие тесты: `go test ./internal/services/...` + (см. `internal/services/quote_price_levels_test.go` — добавь кейс на world-фолбэк: + позиция без warehouse/competitor цены, но с world → флаги `*_from_world=true`, `price_missing` пуст). + +## Docs + +Согласно `CLAUDE.md`/`bible-local`: архитектурное решение о world-фолбэке записать в +`bible-local/` (например, в `04-api.md` — новые поля `*_from_world` в `/api/quote/price-levels`, +и/или короткая заметка в `bible-local/decisions/`). Обнови в том же коммите, что и код. diff --git a/bible-local/04-api.md b/bible-local/04-api.md index 4dc2277..ef49f66 100644 --- a/bible-local/04-api.md +++ b/bible-local/04-api.md @@ -50,7 +50,7 @@ | --- | --- | --- | | `POST` | `/api/quote/validate` | validate config items | | `POST` | `/api/quote/calculate` | calculate quote totals | -| `POST` | `/api/quote/price-levels` | resolve estimate/warehouse/competitor prices | +| `POST` | `/api/quote/price-levels` | resolve estimate/warehouse/competitor prices (falls back to `world` source per column, see [decisions/2026-07-10-world-pricelist-fallback.md](decisions/2026-07-10-world-pricelist-fallback.md)) | | `POST` | `/api/export/csv` | export a single configuration | | `GET` | `/api/configs/:uuid/export` | export a stored configuration | | `GET` | `/api/projects/:uuid/export` | legacy project BOM export | diff --git a/bible-local/decisions/2026-07-10-world-pricelist-fallback.md b/bible-local/decisions/2026-07-10-world-pricelist-fallback.md new file mode 100644 index 0000000..97a68b4 --- /dev/null +++ b/bible-local/decisions/2026-07-10-world-pricelist-fallback.md @@ -0,0 +1,45 @@ +# Decision: `world` pricelist as a fallback stub for missing prices + +**Date:** 2026-07-10 +**Status:** active + +## Context + +Four pricelist `source` values sync from the server: `estimate`, `warehouse`, `competitor`, +`world`. Only the first three were ever read — `world` synced into `local_pricelists` / +`local_pricelist_items` but nothing consumed it, so LOTs with no warehouse/competitor price +showed as "—" in the "Ценообразование" tab, which also broke the "Итого" coverage (red "*"). + +## Decision + +`world` is a fallback stub, applied per-column, only when the native price for that column +is missing or ≤ 0: + +- Resolution order per column: native source price → `world` price → still missing. +- Applies to all three Buy columns (Estimate, Склад, Конкуренты), the Sale table (same + unit prices, multiplied by the existing sale coefficients), and CSV export. +- A world-fallback price counts toward "Итого" like a native price (closes the red "*" gap). +- UI marks only the specific cell that used a fallback, with `bg-amber-50 text-amber-700` + (amber was chosen because `applyCustomPrice()` strips `gray/green/red/blue` text-color + classes via regex on vendor-price cells — amber survives that). +- CSV keeps price values untouched (no suffixes) and adds one trailing comment column, + "Заглушка (world)", listing which columns used the fallback per row (e.g. + `world: Stock, Конкуренты`), always present regardless of export options so column counts + stay aligned across header/rows/summary. +- `world` is resolved independently in each call site (`quote.go` `CalculatePriceLevels`, + `export.go` `resolvePricingTotals`) via "explicit ID → latest active → latest local", same + pattern as the other three sources. It is intentionally NOT added to `levelBySource` / + `ResolvedPricelistIDs` as a fourth on-screen level. +- `NormalizePricelistSource("world")` returns `PricelistSourceWorld`, not `estimate` — it must + not collapse into the estimate source. + +## Consequences + +- `PriceLevelsItem` (API `POST /api/quote/price-levels`) carries three new bools: + `estimate_from_world`, `warehouse_from_world`, `competitor_from_world`. +- `price_missing` on that endpoint only lists a source when BOTH the native and the + `world` price are unavailable. +- No new config-level column (e.g. `world_pricelist_id`) — world always resolves to + "latest active `world` pricelist," not a per-configuration pin. +- If no `world` pricelist exists locally, behavior is unchanged (missing prices stay "—", + no error). diff --git a/internal/localdb/localdb.go b/internal/localdb/localdb.go index 112c6a6..6b3ae36 100644 --- a/internal/localdb/localdb.go +++ b/internal/localdb/localdb.go @@ -1351,8 +1351,17 @@ func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) { // GetLatestLocalPricelistBySource returns the most recently synced active pricelist for a source. func (l *LocalDB) GetLatestLocalPricelistBySource(source string) (*LocalPricelist, error) { + return GetLatestLocalPricelistBySourceTx(l.db, source) +} + +// GetLatestLocalPricelistBySourceTx is the transaction-scoped variant of +// GetLatestLocalPricelistBySource. Callers already inside an l.db.Transaction(...) callback +// must use this with the given tx instead of the method above — the connection pool has a +// single connection (see New()), so querying via l.db while the transaction holds that +// connection deadlocks forever. +func GetLatestLocalPricelistBySourceTx(tx *gorm.DB, source string) (*LocalPricelist, error) { var pricelist LocalPricelist - if err := l.db. + if err := tx. Where("source = ? AND is_active = ?", source, true). Where("EXISTS (SELECT 1 FROM local_pricelist_items WHERE local_pricelist_items.pricelist_id = local_pricelists.id)"). Order("created_at DESC, id DESC"). @@ -1545,6 +1554,13 @@ func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64 // legacy rows that were stored in mixed case before normalization was enforced at sync time. // Keys in the returned map are uppercased (matching the input lotNames). func (l *LocalDB) GetLocalPricesForLots(pricelistID uint, lotNames []string) (map[string]float64, error) { + return GetLocalPricesForLotsTx(l.db, pricelistID, lotNames) +} + +// GetLocalPricesForLotsTx is the transaction-scoped variant of GetLocalPricesForLots. See +// GetLatestLocalPricelistBySourceTx for why callers inside an l.db.Transaction(...) callback +// must use this with the given tx instead of the method above. +func GetLocalPricesForLotsTx(tx *gorm.DB, pricelistID uint, lotNames []string) (map[string]float64, error) { result := make(map[string]float64, len(lotNames)) if len(lotNames) == 0 { return result, nil @@ -1556,7 +1572,7 @@ func (l *LocalDB) GetLocalPricesForLots(pricelistID uint, lotNames []string) (ma } var rows []row // Use UPPER(lot_name) so rows synced before normalization (mixed-case) are still matched. - if err := l.db.Model(&LocalPricelistItem{}). + if err := tx.Model(&LocalPricelistItem{}). Select("lot_name, price"). Where("pricelist_id = ? AND UPPER(lot_name) IN ?", pricelistID, lotNames). Find(&rows).Error; err != nil { diff --git a/internal/models/pricelist.go b/internal/models/pricelist.go index ffc7b1c..7a5ed1d 100644 --- a/internal/models/pricelist.go +++ b/internal/models/pricelist.go @@ -10,11 +10,12 @@ const ( PricelistSourceEstimate PricelistSource = "estimate" PricelistSourceWarehouse PricelistSource = "warehouse" PricelistSourceCompetitor PricelistSource = "competitor" + PricelistSourceWorld PricelistSource = "world" ) func (s PricelistSource) IsValid() bool { switch s { - case PricelistSourceEstimate, PricelistSourceWarehouse, PricelistSourceCompetitor: + case PricelistSourceEstimate, PricelistSourceWarehouse, PricelistSourceCompetitor, PricelistSourceWorld: return true default: return false @@ -27,6 +28,8 @@ func NormalizePricelistSource(source string) PricelistSource { return PricelistSourceWarehouse case PricelistSourceCompetitor: return PricelistSourceCompetitor + case PricelistSourceWorld: + return PricelistSourceWorld default: return PricelistSourceEstimate } diff --git a/internal/services/export.go b/internal/services/export.go index 14babd8..d89a662 100644 --- a/internal/services/export.go +++ b/internal/services/export.go @@ -88,15 +88,18 @@ type ProjectPricingExportConfig struct { } type ProjectPricingExportRow struct { - LotDisplay string - VendorPN string - Description string - Quantity int - BOMTotal *float64 - Estimate *float64 - Stock *float64 - Competitor *float64 - ManualPrice *float64 // proportional share of the user-defined total price + LotDisplay string + VendorPN string + Description string + Quantity int + BOMTotal *float64 + Estimate *float64 + Stock *float64 + Competitor *float64 + ManualPrice *float64 // proportional share of the user-defined total price + EstimateWorld bool + StockWorld bool + CompetitorWorld bool } // ToCSV writes project export data in the new structured CSV format. @@ -411,14 +414,17 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts bomTotal = vendorRowTotal(row) } block.Rows = append(block.Rows, ProjectPricingExportRow{ - LotDisplay: mapping.LotName, - VendorPN: row.VendorPartnumber, - Description: description, - Quantity: lotQty, - BOMTotal: bomTotal, - Estimate: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Estimate }), - Stock: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Stock }), - Competitor: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Competitor }), + LotDisplay: mapping.LotName, + VendorPN: row.VendorPartnumber, + Description: description, + Quantity: lotQty, + BOMTotal: bomTotal, + Estimate: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Estimate }), + Stock: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Stock }), + Competitor: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Competitor }), + EstimateWorld: priceMap[mapping.LotName].EstimateWorld, + StockWorld: priceMap[mapping.LotName].StockWorld, + CompetitorWorld: priceMap[mapping.LotName].CompetitorWorld, }) } } @@ -433,13 +439,16 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts } estimate := estimateOnlyTotal(priceMap[lot].Estimate, item.UnitPrice, item.Quantity) block.Rows = append(block.Rows, ProjectPricingExportRow{ - LotDisplay: lot, - VendorPN: "—", - Description: componentDescriptions[lot], - Quantity: exportPositiveInt(item.Quantity, 1), - Estimate: estimate, - Stock: totalForUnitPrice(priceMap[lot].Stock, item.Quantity), - Competitor: totalForUnitPrice(priceMap[lot].Competitor, item.Quantity), + LotDisplay: lot, + VendorPN: "—", + Description: componentDescriptions[lot], + Quantity: exportPositiveInt(item.Quantity, 1), + Estimate: estimate, + Stock: totalForUnitPrice(priceMap[lot].Stock, item.Quantity), + Competitor: totalForUnitPrice(priceMap[lot].Competitor, item.Quantity), + EstimateWorld: priceMap[lot].EstimateWorld && priceMap[lot].Estimate != nil && *priceMap[lot].Estimate > 0, + StockWorld: priceMap[lot].StockWorld, + CompetitorWorld: priceMap[lot].CompetitorWorld, }) } if opts.isDDP() { @@ -466,13 +475,16 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts } 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), + 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), + EstimateWorld: priceMap[item.LotName].EstimateWorld && priceMap[item.LotName].Estimate != nil && *priceMap[item.LotName].Estimate > 0, + StockWorld: priceMap[item.LotName].StockWorld, + CompetitorWorld: priceMap[item.LotName].CompetitorWorld, }) } @@ -585,9 +597,12 @@ func sortItemsByCategory(items []ExportItem, categoryOrder map[string]int) { } type pricingLevels struct { - Estimate *float64 - Stock *float64 - Competitor *float64 + Estimate *float64 + Stock *float64 + Competitor *float64 + EstimateWorld bool + StockWorld bool + CompetitorWorld bool } func (s *ExportService) resolvePricingTotals(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, opts ProjectPricingExportOptions) map[string]pricingLevels { @@ -621,20 +636,35 @@ func (s *ExportService) resolvePricingTotals(cfg *models.Configuration, localCfg } } + var worldID *uint + if latest, err := s.localDB.GetLatestLocalPricelistBySource("world"); err == nil && latest != nil { + worldID = &latest.ServerID + } + estimatePrices := s.batchLookupPrices(estimateID, lots) stockPrices := s.batchLookupPrices(warehouseID, lots) competitorPrices := s.batchLookupPrices(competitorID, lots) + worldPrices := s.batchLookupPrices(worldID, lots) for _, lot := range lots { level := pricingLevels{} if p, ok := estimatePrices[lot]; ok { level.Estimate = floatPtr(p) + } else if p, ok := worldPrices[lot]; ok && p > 0 { + level.Estimate = floatPtr(p) + level.EstimateWorld = true } if p, ok := stockPrices[lot]; ok { level.Stock = floatPtr(p) + } else if p, ok := worldPrices[lot]; ok && p > 0 { + level.Stock = floatPtr(p) + level.StockWorld = true } if p, ok := competitorPrices[lot]; ok { level.Competitor = floatPtr(p) + } else if p, ok := worldPrices[lot]; ok && p > 0 { + level.Competitor = floatPtr(p) + level.CompetitorWorld = true } result[lot] = level } @@ -786,6 +816,7 @@ func pricingCSVHeaders(opts ProjectPricingExportOptions) []string { if opts.ManualPrice != nil && *opts.ManualPrice > 0 { headers = append(headers, "Ручная цена") } + headers = append(headers, "Заглушка (world)") return headers } @@ -815,6 +846,21 @@ func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions if opts.ManualPrice != nil && *opts.ManualPrice > 0 { record = append(record, formatMoneyValue(row.ManualPrice)) } + var worldCols []string + if row.EstimateWorld { + worldCols = append(worldCols, "Estimate") + } + if row.StockWorld { + worldCols = append(worldCols, "Stock") + } + if row.CompetitorWorld { + worldCols = append(worldCols, "Конкуренты") + } + comment := "" + if len(worldCols) > 0 { + comment = "world: " + strings.Join(worldCols, ", ") + } + record = append(record, comment) return record } @@ -844,6 +890,7 @@ func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricing if opts.ManualPrice != nil && *opts.ManualPrice > 0 { record = append(record, formatMoneyValue(opts.ManualPrice)) } + record = append(record, "") return record } diff --git a/internal/services/quote.go b/internal/services/quote.go index 7610f58..e4960f8 100644 --- a/internal/services/quote.go +++ b/internal/services/quote.go @@ -89,6 +89,9 @@ type PriceLevelsItem struct { DeltaCompEstimatePct *float64 `json:"delta_comp_estimate_pct"` DeltaCompWhAbs *float64 `json:"delta_comp_wh_abs"` DeltaCompWhPct *float64 `json:"delta_comp_wh_pct"` + EstimateFromWorld bool `json:"estimate_from_world"` + WarehouseFromWorld bool `json:"warehouse_from_world"` + CompetitorFromWorld bool `json:"competitor_from_world"` PriceMissing []string `json:"price_missing"` } @@ -239,6 +242,29 @@ func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLeve } } + var worldID uint + if req.PricelistIDs != nil { + if explicitID, ok := req.PricelistIDs[string(models.PricelistSourceWorld)]; ok && explicitID > 0 { + worldID = explicitID + } + } + if worldID == 0 && s.pricelistRepo != nil { + if latest, err := s.pricelistRepo.GetLatestActiveBySource(string(models.PricelistSourceWorld)); err == nil { + worldID = latest.ID + } + } + if worldID == 0 && s.localDB != nil { + if localPL, err := s.localDB.GetLatestLocalPricelistBySource(string(models.PricelistSourceWorld)); err == nil && localPL != nil { + worldID = localPL.ServerID + } + } + worldPrices := map[string]float64{} + if worldID != 0 { + if prices, err := s.lookupPricesByPricelistID(worldID, lotNames, req.NoCache); err == nil { + worldPrices = prices + } + } + for _, reqItem := range req.Items { responseLotName := originalLotNames[reqItem.LotName] if responseLotName == "" { @@ -253,14 +279,26 @@ func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLeve if p, ok := levelBySource[models.PricelistSourceEstimate].prices[reqItem.LotName]; ok && p > 0 { price := p item.EstimatePrice = &price + } else if wp, ok := worldPrices[reqItem.LotName]; ok && wp > 0 { + price := wp + item.EstimatePrice = &price + item.EstimateFromWorld = true } if p, ok := levelBySource[models.PricelistSourceWarehouse].prices[reqItem.LotName]; ok && p > 0 { price := p item.WarehousePrice = &price + } else if wp, ok := worldPrices[reqItem.LotName]; ok && wp > 0 { + price := wp + item.WarehousePrice = &price + item.WarehouseFromWorld = true } if p, ok := levelBySource[models.PricelistSourceCompetitor].prices[reqItem.LotName]; ok && p > 0 { price := p item.CompetitorPrice = &price + } else if wp, ok := worldPrices[reqItem.LotName]; ok && wp > 0 { + price := wp + item.CompetitorPrice = &price + item.CompetitorFromWorld = true } if item.EstimatePrice == nil { diff --git a/internal/services/quote_price_levels_test.go b/internal/services/quote_price_levels_test.go index bf5abe0..e264f21 100644 --- a/internal/services/quote_price_levels_test.go +++ b/internal/services/quote_price_levels_test.go @@ -82,6 +82,51 @@ func TestCalculatePriceLevels_UsesExplicitPricelistIDs(t *testing.T) { } } +func TestCalculatePriceLevels_WorldFallback(t *testing.T) { + db := newPriceLevelsTestDB(t) + repo := repository.NewPricelistRepository(db) + service := NewQuoteService(repo, nil) + + seedPricelistWithItem(t, repo, "estimate", "CPU_Z", 100) + seedPricelistWithItem(t, repo, "world", "CPU_Z", 150) + + result, err := service.CalculatePriceLevels(&PriceLevelsRequest{ + Items: []struct { + LotName string `json:"lot_name"` + Quantity int `json:"quantity"` + }{ + {LotName: "CPU_Z", Quantity: 1}, + }, + }) + if err != nil { + t.Fatalf("CalculatePriceLevels returned error: %v", err) + } + item := result.Items[0] + + if item.EstimatePrice == nil || *item.EstimatePrice != 100 { + t.Fatalf("expected native estimate 100, got %#v", item.EstimatePrice) + } + if item.EstimateFromWorld { + t.Fatalf("expected estimate_from_world false when native price exists") + } + + if item.WarehousePrice == nil || *item.WarehousePrice != 150 { + t.Fatalf("expected world-fallback warehouse 150, got %#v", item.WarehousePrice) + } + if !item.WarehouseFromWorld { + t.Fatalf("expected warehouse_from_world true") + } + if item.CompetitorPrice == nil || *item.CompetitorPrice != 150 { + t.Fatalf("expected world-fallback competitor 150, got %#v", item.CompetitorPrice) + } + if !item.CompetitorFromWorld { + t.Fatalf("expected competitor_from_world true") + } + if len(item.PriceMissing) != 0 { + t.Fatalf("expected no price_missing after world fallback, got %#v", item.PriceMissing) + } +} + func newPriceLevelsTestDB(t *testing.T) *gorm.DB { t.Helper() db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) diff --git a/internal/services/vendor_workspace_import.go b/internal/services/vendor_workspace_import.go index 81daaf8..22be18b 100644 --- a/internal/services/vendor_workspace_import.go +++ b/internal/services/vendor_workspace_import.go @@ -165,7 +165,7 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s if len(imported.DirectItems) > 0 { items = imported.DirectItems - estimatePricelist, _ := s.localDB.GetLatestLocalPricelistBySource("estimate") + estimatePricelist, _ := localdb.GetLatestLocalPricelistBySourceTx(tx, "estimate") if estimatePricelist != nil { estimatePricelistID = &estimatePricelist.ServerID } @@ -173,7 +173,7 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s totalPrice = &val } else { var prepErr error - groupRows, items, totalPrice, estimatePricelistID, prepErr = s.prepareImportedConfiguration(imported.Rows, imported.ServerCount, bookRepo) + groupRows, items, totalPrice, estimatePricelistID, prepErr = s.prepareImportedConfiguration(tx, imported.Rows, imported.ServerCount, bookRepo) if prepErr != nil { return fmt.Errorf("prepare imported configuration group %s: %w", imported.GroupID, prepErr) } @@ -219,7 +219,7 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s return result, nil } -func (s *LocalConfigurationService) prepareImportedConfiguration(rows []localdb.VendorSpecItem, serverCount int, bookRepo *repository.PartnumberBookRepository) (localdb.VendorSpec, localdb.LocalConfigItems, *float64, *uint, error) { +func (s *LocalConfigurationService) prepareImportedConfiguration(tx *gorm.DB, 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 { @@ -242,13 +242,13 @@ func (s *LocalConfigurationService) prepareImportedConfiguration(rows []localdb. canonical = append(canonical, row) } - estimatePricelist, _ := s.localDB.GetLatestLocalPricelistBySource("estimate") + estimatePricelist, _ := localdb.GetLatestLocalPricelistBySourceTx(tx, "estimate") var serverPricelistID *uint if estimatePricelist != nil { serverPricelistID = &estimatePricelist.ServerID } - items := aggregateVendorSpecToItems(canonical, estimatePricelist, s.localDB) + items := aggregateVendorSpecToItemsTx(tx, canonical, estimatePricelist) totalValue := items.Total() if serverCount > 1 { totalValue *= float64(serverCount) @@ -257,7 +257,7 @@ func (s *LocalConfigurationService) prepareImportedConfiguration(rows []localdb. return canonical, items, totalPrice, serverPricelistID, nil } -func aggregateVendorSpecToItems(spec localdb.VendorSpec, estimatePricelist *localdb.LocalPricelist, local *localdb.LocalDB) localdb.LocalConfigItems { +func aggregateVendorSpecToItemsTx(tx *gorm.DB, spec localdb.VendorSpec, estimatePricelist *localdb.LocalPricelist) localdb.LocalConfigItems { if len(spec) == 0 { return localdb.LocalConfigItems{} } @@ -276,8 +276,8 @@ func aggregateVendorSpecToItems(spec localdb.VendorSpec, estimatePricelist *loca sort.Strings(order) var priceMap map[string]float64 - if estimatePricelist != nil && local != nil && len(order) > 0 { - priceMap, _ = local.GetLocalPricesForLots(estimatePricelist.ID, order) + if estimatePricelist != nil && len(order) > 0 { + priceMap, _ = localdb.GetLocalPricesForLotsTx(tx, estimatePricelist.ID, order) } items := make(localdb.LocalConfigItems, 0, len(order)) diff --git a/web/templates/index.html b/web/templates/index.html index f659a9a..a781bf9 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -4126,8 +4126,15 @@ async function renderPricingTab() { estUnit: (pl && pl.estimate_price > 0) ? pl.estimate_price : 0, warehouseUnit: (pl && pl.warehouse_price > 0) ? pl.warehouse_price : null, competitorUnit: (pl && pl.competitor_price > 0) ? pl.competitor_price : null, + estWorld: !!(pl && pl.estimate_from_world), + whWorld: !!(pl && pl.warehouse_from_world), + compWorld: !!(pl && pl.competitor_from_world), }); + // Amber marker for world-fallback cells — must stay outside gray/green/red/blue + // families since applyCustomPrice() strips those on vendor-price cells via regex. + const WORLD_CLS = 'bg-amber-50 text-amber-700'; + // ─── Build shared row data (unit prices for display, totals for math) ──── // Each BOM row is exploded into per-LOT sub-rows; grouped by vendor PN via groupStart/groupSize. const cartQtyMap = {}; @@ -4151,6 +4158,7 @@ async function renderPricingTab() { competitor: u.competitorUnit != null ? u.competitorUnit * item.quantity : null, vendorOrig: null, vendorOrigUnit: null, isEstOnly, groupStart: true, groupSize: 1, + estWorld: u.estWorld, whWorld: u.whWorld, compWorld: u.compWorld, }); }; @@ -4189,6 +4197,7 @@ async function renderPricingTab() { est: u.estUnit > 0 ? u.estUnit * qty : 0, warehouse: u.warehouseUnit != null ? u.warehouseUnit * qty : null, competitor: u.competitorUnit != null ? u.competitorUnit * qty : null, + estWorld: u.estWorld, whWorld: u.whWorld, compWorld: u.compWorld, }); } allocs.forEach(a => { @@ -4201,6 +4210,7 @@ async function renderPricingTab() { est: u.estUnit > 0 ? u.estUnit * qty : 0, warehouse: u.warehouseUnit != null ? u.warehouseUnit * qty : null, competitor: u.competitorUnit != null ? u.competitorUnit * qty : null, + estWorld: u.estWorld, whWorld: u.whWorld, compWorld: u.compWorld, }); }); @@ -4212,6 +4222,7 @@ async function renderPricingTab() { est: 0, warehouse: null, competitor: null, vendorOrig, vendorOrigUnit, isEstOnly: false, groupStart: true, groupSize: 1, + estWorld: false, whWorld: false, compWorld: false, }); return; } @@ -4229,6 +4240,7 @@ async function renderPricingTab() { isEstOnly: false, groupStart: idx === 0, groupSize: idx === 0 ? groupSize : 0, + estWorld: sub.estWorld, whWorld: sub.whWorld, compWorld: sub.compWorld, }); }); }); @@ -4280,9 +4292,9 @@ async function renderPricingTab() { ${pnDescHtml} ${r.lotCell} ${r.qty} - ${r.estUnit > 0 ? formatCurrency(r.estUnit) : '—'} - ${r.warehouseUnit != null ? formatCurrency(r.warehouseUnit) : '—'} - ${r.competitorUnit != null ? formatCurrency(r.competitorUnit) : '—'} + ${r.estUnit > 0 ? formatCurrency(r.estUnit) : '—'} + ${r.warehouseUnit != null ? formatCurrency(r.warehouseUnit) : '—'} + ${r.competitorUnit != null ? formatCurrency(r.competitorUnit) : '—'} ${r.vendorOrigUnit != null ? formatCurrency(r.vendorOrigUnit) : '—'} `; tbodyBuy.appendChild(tr); @@ -4332,9 +4344,9 @@ async function renderPricingTab() { ${pnDescHtml} ${r.lotCell} ${r.qty} - ${saleEstUnit > 0 ? formatCurrency(saleEstUnit) : '—'} - ${saleWhUnit != null ? formatCurrency(saleWhUnit) : '—'} - ${saleCompUnit != null ? formatCurrency(saleCompUnit) : '—'} + ${saleEstUnit > 0 ? formatCurrency(saleEstUnit) : '—'} + ${saleWhUnit != null ? formatCurrency(saleWhUnit) : '—'} + ${saleCompUnit != null ? formatCurrency(saleCompUnit) : '—'} — `; tbodySale.appendChild(tr);