# 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/`). Обнови в том же коммите, что и код.