Files
QuoteForge/TASK_world_pricelist_fallback.md
T
Mikhail ChusavitinandClaude Opus 4.8 70a3ff255f feat: world-прайслист как заглушка для отсутствующих цен
Там, где для LOT нет цены в estimate/warehouse/competitor, подставляется
цена из world-прайслиста. Такие ячейки в таблицах «Цена покупки»/«Цена продажи»
подсвечиваются (amber), участвуют в «Итого» и убирают красную «*».
В CSV-экспорте добавлена колонка «Заглушка (world)» с перечнем столбцов,
где сработал фолбэк.

Добавлены Tx-версии GetLatestLocalPricelistBySource/GetLocalPricesForLots,
чтобы резолв прайслистов внутри транзакции не дедлочил single-connection пул.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-07-10 10:25:46 +03:00

21 KiB
Raw Blame History

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. Добавь константу и включи её в валидацию:

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.goCalculatePriceLevels

Это основной источник данных для экранной таблицы (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 != 0worldPrices, _ := 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.htmlrenderPricingTab

Данные приходят в 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) → ConfigToPricingExportDatabuildPricingExportBlockresolvePricingTotals (строка ~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 (чтобы не споткнуться)

  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-прайслист с ценами:
    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):
    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/). Обнови в том же коммите, что и код.