Там, где для LOT нет цены в estimate/warehouse/competitor, подставляется цена из world-прайслиста. Такие ячейки в таблицах «Цена покупки»/«Цена продажи» подсвечиваются (amber), участвуют в «Итого» и убирают красную «*». В CSV-экспорте добавлена колонка «Заглушка (world)» с перечнем столбцов, где сработал фолбэк. Добавлены Tx-версии GetLatestLocalPricelistBySource/GetLocalPricesForLots, чтобы резолв прайслистов внутри транзакции не дедлочил single-connection пул. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
21 KiB
Task: World-прайслист как заглушка для отсутствующих цен
Это задание для агента-исполнителя. Оно самодостаточно: содержит все файлы, точки входа, edge-cases и способ проверки. Не отступай от контрактов ниже без причины.
Context (зачем)
В приложение синхронизируется 4 типа прайслистов (source): estimate, warehouse,
competitor и world. Первые три отображаются в таблицах «Цена покупки» / «Цена продажи»
на вкладке «Ценообразование». world сейчас не читается нигде — он уже приезжает и
лежит в local_pricelists (sync копирует любой source, что прислал сервер), но код его
игнорирует.
Задача: сделать world фолбэком-заглушкой. Там, где для LOT нет цены в
estimate/warehouse/competitor, подставить цену из world и выделить такую ячейку цветом,
чтобы было видно, что цена не из «родного» прайслиста.
На скриншоте видно проблему: у ряда позиций пусто («—») в колонках «Склад» и «Конкуренты», из-за чего «Итого» неполный и помечен красной звёздочкой «*».
Решения по продукту (согласованы с заказчиком)
- Колонки: world подставляется во все три — Estimate, Склад (warehouse), Конкуренты (competitor), в любую, где нет цены.
- Итого: подставленная world-цена участвует в «Итого». За счёт этого покрытие становится полным и красная «*» пропадает (если все пробелы закрыты world).
- Подсветка: красим только конкретную ячейку, где сработала подстановка (не всю строку).
Используем янтарный/amber (см. ниже), т.к. семейства gray/green/red/blue стираются регэкспом
в
applyCustomPrice. - Охват: та же логика применяется в таблице «Цена продажи» и в CSV-экспорте
(в CSV цвета нет → добавляем отдельную колонку-комментарий «Заглушка (world)», где перечислены
столбцы с фолбэком, напр.
world: Stock, Конкуренты; см. раздел CSV).
Ключевой факт: source-константы
Файл internal/models/pricelist.go определяет PricelistSource. Сейчас нет world.
Добавь константу и включи её в валидацию:
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:
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):
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)
Верни ещё и флаги источника:
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:
const WORLD_CLS = 'bg-amber-50 text-amber-700'; // фон ячейки + цвет текста
Применяй к соответствующей <td>:
<td class="px-3 py-1.5 text-right text-xs ${borderTop} ${r.estWorld ? WORLD_CLS : ''}">${r.estUnit > 0 ? formatCurrency(r.estUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs ${borderTop} ${r.whWorld ? WORLD_CLS : ''}">${r.warehouseUnit != null ? formatCurrency(r.warehouseUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs ${borderTop} ${r.compWorld ? WORLD_CLS : ''}">${r.competitorUnit != null ? formatCurrency(r.competitorUnit) : '—'}</td>
Важно про цвет: НЕ используй
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) флагами источника:
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)
Добавь колонку последней (после «Ручная цена»), чтобы не сдвигать существующие столбцы:
headers = append(headers, "Заглушка (world)")
Строка — pricingCSVRow (строки ~792-819)
Собери человекочитаемый список названий колонок, где сработал world-фолбэк, и добавь его
последним полем. Названия бери те же, что в заголовках таблицы: Estimate, Stock, Конкуренты.
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)
Добавь пустую ячейку в конец (комментарий на суммарной строке не нужен), чтобы число колонок совпадало с шапкой:
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 (чтобы не споткнуться)
- World-прайслиста нет вовсе →
worldID == 0,worldPricesпуст → поведение как сейчас (пустые «—» и «*»). Не должно падать. - World-цена = 0 или ≤ 0 → считается отсутствующей, фолбэк не срабатывает (проверка
wp > 0). - Родная цена есть → world НЕ используется, флаг
false, ячейка не красится. - LOT есть только в world → все три колонки берутся из world и красятся;
PriceMissingпустой; строка полностью «заглушечная». - Кэш цен (
priceCacheв quote.go) — world идёт через тот жеlookupPricesByPricelistID, ключ кэша включаетpricelistID, коллизий нет. applyCustomPriceregex — маркер-класс должен бытьamber(или иное семейство, кроме gray/green/red/blue), иначе будет затёрт на vendor-ячейках. Vendor-колонку world не трогает.- Sale-таблица множит на коэффициент — world-цена корректно умножается, красим по флагу.
- Нормализация LOT — все lookup'ы идут по
NormalizeLotName(uppercase); world-цены тоже резолвятся черезlookupPricesByPricelistID, который матчитUPPER(lot_name). Согласовано.
Verification (как проверить end-to-end)
- Сборка:
go build ./cmd/qfs && go vet ./... - Убедись, что в локальной БД есть world-прайслист с ценами:
Если world-прайслиста нет — синхронизировать/залить тестовый (иначе фолбэк нечем проверять).
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);" - Backend-проверка API напрямую (подставь LOT, у которого нет warehouse/competitor цены, но
есть world):
Ожидаем: у позиции без родной competitor-цены
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}]}' | jqcompetitor_price != nullиcompetitor_from_world: true, аprice_missingне содержитcompetitor. - UI (
go run ./cmd/qfs, вкладка «Ценообразование», раздел «Цена покупки»):- ячейки, ранее «—» в «Склад»/«Конкуренты», теперь показывают цену на amber-фоне;
- «Итого» по этим колонкам стало полным и без красной «*» (если все пробелы закрыты world);
- то же самое в «Цена продажи».
Прогони этот шаг через
/verifyили браузерную автоматизацию для скриншота до/после.
- CSV: нажми «Экспорт CSV», убедись, что world-цены присутствуют в Estimate/Stock/Конкуренты,
а в последней колонке «Заглушка (world)» указано, где именно сработал фолбэк
(напр.
world: Stock, Конкуренты). Число колонок в шапке, строках и итоговой строке совпадает. - Существующие тесты:
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/). Обнови в том же коммите, что и код.