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>
This commit is contained in:
Mikhail Chusavitin
2026-07-10 10:25:46 +03:00
co-authored by Claude Opus 4.8
parent 498cbf5490
commit 70a3ff255f
10 changed files with 586 additions and 52 deletions
+328
View File
@@ -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'; // фон ячейки + цвет текста
```
Применяй к соответствующей `<td>`:
```js
<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) флагами источника:
```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/`). Обнови в том же коммите, что и код.
+1 -1
View File
@@ -50,7 +50,7 @@
| --- | --- | --- | | --- | --- | --- |
| `POST` | `/api/quote/validate` | validate config items | | `POST` | `/api/quote/validate` | validate config items |
| `POST` | `/api/quote/calculate` | calculate quote totals | | `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 | | `POST` | `/api/export/csv` | export a single configuration |
| `GET` | `/api/configs/:uuid/export` | export a stored configuration | | `GET` | `/api/configs/:uuid/export` | export a stored configuration |
| `GET` | `/api/projects/:uuid/export` | legacy project BOM export | | `GET` | `/api/projects/:uuid/export` | legacy project BOM export |
@@ -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).
+18 -2
View File
@@ -1351,8 +1351,17 @@ func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
// GetLatestLocalPricelistBySource returns the most recently synced active pricelist for a source. // GetLatestLocalPricelistBySource returns the most recently synced active pricelist for a source.
func (l *LocalDB) GetLatestLocalPricelistBySource(source string) (*LocalPricelist, error) { 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 var pricelist LocalPricelist
if err := l.db. if err := tx.
Where("source = ? AND is_active = ?", source, true). Where("source = ? AND is_active = ?", source, true).
Where("EXISTS (SELECT 1 FROM local_pricelist_items WHERE local_pricelist_items.pricelist_id = local_pricelists.id)"). Where("EXISTS (SELECT 1 FROM local_pricelist_items WHERE local_pricelist_items.pricelist_id = local_pricelists.id)").
Order("created_at DESC, id DESC"). 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. // 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). // Keys in the returned map are uppercased (matching the input lotNames).
func (l *LocalDB) GetLocalPricesForLots(pricelistID uint, lotNames []string) (map[string]float64, error) { 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)) result := make(map[string]float64, len(lotNames))
if len(lotNames) == 0 { if len(lotNames) == 0 {
return result, nil return result, nil
@@ -1556,7 +1572,7 @@ func (l *LocalDB) GetLocalPricesForLots(pricelistID uint, lotNames []string) (ma
} }
var rows []row var rows []row
// Use UPPER(lot_name) so rows synced before normalization (mixed-case) are still matched. // 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"). Select("lot_name, price").
Where("pricelist_id = ? AND UPPER(lot_name) IN ?", pricelistID, lotNames). Where("pricelist_id = ? AND UPPER(lot_name) IN ?", pricelistID, lotNames).
Find(&rows).Error; err != nil { Find(&rows).Error; err != nil {
+4 -1
View File
@@ -10,11 +10,12 @@ const (
PricelistSourceEstimate PricelistSource = "estimate" PricelistSourceEstimate PricelistSource = "estimate"
PricelistSourceWarehouse PricelistSource = "warehouse" PricelistSourceWarehouse PricelistSource = "warehouse"
PricelistSourceCompetitor PricelistSource = "competitor" PricelistSourceCompetitor PricelistSource = "competitor"
PricelistSourceWorld PricelistSource = "world"
) )
func (s PricelistSource) IsValid() bool { func (s PricelistSource) IsValid() bool {
switch s { switch s {
case PricelistSourceEstimate, PricelistSourceWarehouse, PricelistSourceCompetitor: case PricelistSourceEstimate, PricelistSourceWarehouse, PricelistSourceCompetitor, PricelistSourceWorld:
return true return true
default: default:
return false return false
@@ -27,6 +28,8 @@ func NormalizePricelistSource(source string) PricelistSource {
return PricelistSourceWarehouse return PricelistSourceWarehouse
case PricelistSourceCompetitor: case PricelistSourceCompetitor:
return PricelistSourceCompetitor return PricelistSourceCompetitor
case PricelistSourceWorld:
return PricelistSourceWorld
default: default:
return PricelistSourceEstimate return PricelistSourceEstimate
} }
+81 -34
View File
@@ -88,15 +88,18 @@ type ProjectPricingExportConfig struct {
} }
type ProjectPricingExportRow struct { type ProjectPricingExportRow struct {
LotDisplay string LotDisplay string
VendorPN string VendorPN string
Description string Description string
Quantity int Quantity int
BOMTotal *float64 BOMTotal *float64
Estimate *float64 Estimate *float64
Stock *float64 Stock *float64
Competitor *float64 Competitor *float64
ManualPrice *float64 // proportional share of the user-defined total price 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. // 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) bomTotal = vendorRowTotal(row)
} }
block.Rows = append(block.Rows, ProjectPricingExportRow{ block.Rows = append(block.Rows, ProjectPricingExportRow{
LotDisplay: mapping.LotName, LotDisplay: mapping.LotName,
VendorPN: row.VendorPartnumber, VendorPN: row.VendorPartnumber,
Description: description, Description: description,
Quantity: lotQty, Quantity: lotQty,
BOMTotal: bomTotal, BOMTotal: bomTotal,
Estimate: computeSingleLotTotal(priceMap, mapping.LotName, lotQty, func(p pricingLevels) *float64 { return p.Estimate }), 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 }), 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 }), 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) estimate := estimateOnlyTotal(priceMap[lot].Estimate, item.UnitPrice, item.Quantity)
block.Rows = append(block.Rows, ProjectPricingExportRow{ block.Rows = append(block.Rows, ProjectPricingExportRow{
LotDisplay: lot, LotDisplay: lot,
VendorPN: "—", VendorPN: "—",
Description: componentDescriptions[lot], Description: componentDescriptions[lot],
Quantity: exportPositiveInt(item.Quantity, 1), Quantity: exportPositiveInt(item.Quantity, 1),
Estimate: estimate, Estimate: estimate,
Stock: totalForUnitPrice(priceMap[lot].Stock, item.Quantity), Stock: totalForUnitPrice(priceMap[lot].Stock, item.Quantity),
Competitor: totalForUnitPrice(priceMap[lot].Competitor, 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() { 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) estimate := estimateOnlyTotal(priceMap[item.LotName].Estimate, item.UnitPrice, item.Quantity)
block.Rows = append(block.Rows, ProjectPricingExportRow{ block.Rows = append(block.Rows, ProjectPricingExportRow{
LotDisplay: item.LotName, LotDisplay: item.LotName,
VendorPN: "—", VendorPN: "—",
Description: componentDescriptions[item.LotName], Description: componentDescriptions[item.LotName],
Quantity: exportPositiveInt(item.Quantity, 1), Quantity: exportPositiveInt(item.Quantity, 1),
Estimate: estimate, Estimate: estimate,
Stock: totalForUnitPrice(priceMap[item.LotName].Stock, item.Quantity), Stock: totalForUnitPrice(priceMap[item.LotName].Stock, item.Quantity),
Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, 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 { type pricingLevels struct {
Estimate *float64 Estimate *float64
Stock *float64 Stock *float64
Competitor *float64 Competitor *float64
EstimateWorld bool
StockWorld bool
CompetitorWorld bool
} }
func (s *ExportService) resolvePricingTotals(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, opts ProjectPricingExportOptions) map[string]pricingLevels { 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) estimatePrices := s.batchLookupPrices(estimateID, lots)
stockPrices := s.batchLookupPrices(warehouseID, lots) stockPrices := s.batchLookupPrices(warehouseID, lots)
competitorPrices := s.batchLookupPrices(competitorID, lots) competitorPrices := s.batchLookupPrices(competitorID, lots)
worldPrices := s.batchLookupPrices(worldID, lots)
for _, lot := range lots { for _, lot := range lots {
level := pricingLevels{} level := pricingLevels{}
if p, ok := estimatePrices[lot]; ok { if p, ok := estimatePrices[lot]; ok {
level.Estimate = floatPtr(p) 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 { if p, ok := stockPrices[lot]; ok {
level.Stock = floatPtr(p) 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 { if p, ok := competitorPrices[lot]; ok {
level.Competitor = floatPtr(p) level.Competitor = floatPtr(p)
} else if p, ok := worldPrices[lot]; ok && p > 0 {
level.Competitor = floatPtr(p)
level.CompetitorWorld = true
} }
result[lot] = level result[lot] = level
} }
@@ -786,6 +816,7 @@ func pricingCSVHeaders(opts ProjectPricingExportOptions) []string {
if opts.ManualPrice != nil && *opts.ManualPrice > 0 { if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
headers = append(headers, "Ручная цена") headers = append(headers, "Ручная цена")
} }
headers = append(headers, "Заглушка (world)")
return headers return headers
} }
@@ -815,6 +846,21 @@ func pricingCSVRow(row ProjectPricingExportRow, opts ProjectPricingExportOptions
if opts.ManualPrice != nil && *opts.ManualPrice > 0 { if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
record = append(record, formatMoneyValue(row.ManualPrice)) 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 return record
} }
@@ -844,6 +890,7 @@ func pricingConfigSummaryRow(cfg ProjectPricingExportConfig, opts ProjectPricing
if opts.ManualPrice != nil && *opts.ManualPrice > 0 { if opts.ManualPrice != nil && *opts.ManualPrice > 0 {
record = append(record, formatMoneyValue(opts.ManualPrice)) record = append(record, formatMoneyValue(opts.ManualPrice))
} }
record = append(record, "")
return record return record
} }
+38
View File
@@ -89,6 +89,9 @@ type PriceLevelsItem struct {
DeltaCompEstimatePct *float64 `json:"delta_comp_estimate_pct"` DeltaCompEstimatePct *float64 `json:"delta_comp_estimate_pct"`
DeltaCompWhAbs *float64 `json:"delta_comp_wh_abs"` DeltaCompWhAbs *float64 `json:"delta_comp_wh_abs"`
DeltaCompWhPct *float64 `json:"delta_comp_wh_pct"` 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"` 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 { for _, reqItem := range req.Items {
responseLotName := originalLotNames[reqItem.LotName] responseLotName := originalLotNames[reqItem.LotName]
if responseLotName == "" { 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 { if p, ok := levelBySource[models.PricelistSourceEstimate].prices[reqItem.LotName]; ok && p > 0 {
price := p price := p
item.EstimatePrice = &price 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 { if p, ok := levelBySource[models.PricelistSourceWarehouse].prices[reqItem.LotName]; ok && p > 0 {
price := p price := p
item.WarehousePrice = &price 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 { if p, ok := levelBySource[models.PricelistSourceCompetitor].prices[reqItem.LotName]; ok && p > 0 {
price := p price := p
item.CompetitorPrice = &price 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 { if item.EstimatePrice == nil {
@@ -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 { func newPriceLevelsTestDB(t *testing.T) *gorm.DB {
t.Helper() t.Helper()
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{}) db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
+8 -8
View File
@@ -165,7 +165,7 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s
if len(imported.DirectItems) > 0 { if len(imported.DirectItems) > 0 {
items = imported.DirectItems items = imported.DirectItems
estimatePricelist, _ := s.localDB.GetLatestLocalPricelistBySource("estimate") estimatePricelist, _ := localdb.GetLatestLocalPricelistBySourceTx(tx, "estimate")
if estimatePricelist != nil { if estimatePricelist != nil {
estimatePricelistID = &estimatePricelist.ServerID estimatePricelistID = &estimatePricelist.ServerID
} }
@@ -173,7 +173,7 @@ func (s *LocalConfigurationService) ImportVendorWorkspaceToProject(projectUUID s
totalPrice = &val totalPrice = &val
} else { } else {
var prepErr error 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 { if prepErr != nil {
return fmt.Errorf("prepare imported configuration group %s: %w", imported.GroupID, prepErr) 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 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) resolver := NewVendorSpecResolver(bookRepo)
resolved, err := resolver.Resolve(append([]localdb.VendorSpecItem(nil), rows...)) resolved, err := resolver.Resolve(append([]localdb.VendorSpecItem(nil), rows...))
if err != nil { if err != nil {
@@ -242,13 +242,13 @@ func (s *LocalConfigurationService) prepareImportedConfiguration(rows []localdb.
canonical = append(canonical, row) canonical = append(canonical, row)
} }
estimatePricelist, _ := s.localDB.GetLatestLocalPricelistBySource("estimate") estimatePricelist, _ := localdb.GetLatestLocalPricelistBySourceTx(tx, "estimate")
var serverPricelistID *uint var serverPricelistID *uint
if estimatePricelist != nil { if estimatePricelist != nil {
serverPricelistID = &estimatePricelist.ServerID serverPricelistID = &estimatePricelist.ServerID
} }
items := aggregateVendorSpecToItems(canonical, estimatePricelist, s.localDB) items := aggregateVendorSpecToItemsTx(tx, canonical, estimatePricelist)
totalValue := items.Total() totalValue := items.Total()
if serverCount > 1 { if serverCount > 1 {
totalValue *= float64(serverCount) totalValue *= float64(serverCount)
@@ -257,7 +257,7 @@ func (s *LocalConfigurationService) prepareImportedConfiguration(rows []localdb.
return canonical, items, totalPrice, serverPricelistID, nil 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 { if len(spec) == 0 {
return localdb.LocalConfigItems{} return localdb.LocalConfigItems{}
} }
@@ -276,8 +276,8 @@ func aggregateVendorSpecToItems(spec localdb.VendorSpec, estimatePricelist *loca
sort.Strings(order) sort.Strings(order)
var priceMap map[string]float64 var priceMap map[string]float64
if estimatePricelist != nil && local != nil && len(order) > 0 { if estimatePricelist != nil && len(order) > 0 {
priceMap, _ = local.GetLocalPricesForLots(estimatePricelist.ID, order) priceMap, _ = localdb.GetLocalPricesForLotsTx(tx, estimatePricelist.ID, order)
} }
items := make(localdb.LocalConfigItems, 0, len(order)) items := make(localdb.LocalConfigItems, 0, len(order))
+18 -6
View File
@@ -4126,8 +4126,15 @@ async function renderPricingTab() {
estUnit: (pl && pl.estimate_price > 0) ? pl.estimate_price : 0, estUnit: (pl && pl.estimate_price > 0) ? pl.estimate_price : 0,
warehouseUnit: (pl && pl.warehouse_price > 0) ? pl.warehouse_price : null, warehouseUnit: (pl && pl.warehouse_price > 0) ? pl.warehouse_price : null,
competitorUnit: (pl && pl.competitor_price > 0) ? pl.competitor_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) ──── // ─── 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. // Each BOM row is exploded into per-LOT sub-rows; grouped by vendor PN via groupStart/groupSize.
const cartQtyMap = {}; const cartQtyMap = {};
@@ -4151,6 +4158,7 @@ async function renderPricingTab() {
competitor: u.competitorUnit != null ? u.competitorUnit * item.quantity : null, competitor: u.competitorUnit != null ? u.competitorUnit * item.quantity : null,
vendorOrig: null, vendorOrigUnit: null, isEstOnly, vendorOrig: null, vendorOrigUnit: null, isEstOnly,
groupStart: true, groupSize: 1, 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, est: u.estUnit > 0 ? u.estUnit * qty : 0,
warehouse: u.warehouseUnit != null ? u.warehouseUnit * qty : null, warehouse: u.warehouseUnit != null ? u.warehouseUnit * qty : null,
competitor: u.competitorUnit != null ? u.competitorUnit * qty : null, competitor: u.competitorUnit != null ? u.competitorUnit * qty : null,
estWorld: u.estWorld, whWorld: u.whWorld, compWorld: u.compWorld,
}); });
} }
allocs.forEach(a => { allocs.forEach(a => {
@@ -4201,6 +4210,7 @@ async function renderPricingTab() {
est: u.estUnit > 0 ? u.estUnit * qty : 0, est: u.estUnit > 0 ? u.estUnit * qty : 0,
warehouse: u.warehouseUnit != null ? u.warehouseUnit * qty : null, warehouse: u.warehouseUnit != null ? u.warehouseUnit * qty : null,
competitor: u.competitorUnit != null ? u.competitorUnit * 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, est: 0, warehouse: null, competitor: null,
vendorOrig, vendorOrigUnit, isEstOnly: false, vendorOrig, vendorOrigUnit, isEstOnly: false,
groupStart: true, groupSize: 1, groupStart: true, groupSize: 1,
estWorld: false, whWorld: false, compWorld: false,
}); });
return; return;
} }
@@ -4229,6 +4240,7 @@ async function renderPricingTab() {
isEstOnly: false, isEstOnly: false,
groupStart: idx === 0, groupStart: idx === 0,
groupSize: idx === 0 ? groupSize : 0, groupSize: idx === 0 ? groupSize : 0,
estWorld: sub.estWorld, whWorld: sub.whWorld, compWorld: sub.compWorld,
}); });
}); });
}); });
@@ -4280,9 +4292,9 @@ async function renderPricingTab() {
${pnDescHtml} ${pnDescHtml}
<td class="px-3 py-1.5 text-xs ${borderTop}">${r.lotCell}</td> <td class="px-3 py-1.5 text-xs ${borderTop}">${r.lotCell}</td>
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${r.qty}</td> <td class="px-3 py-1.5 text-right text-xs ${borderTop}">${r.qty}</td>
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${r.estUnit > 0 ? formatCurrency(r.estUnit) : '—'}</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.warehouseUnit != null ? formatCurrency(r.warehouseUnit) : '—'}</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.competitorUnit != null ? formatCurrency(r.competitorUnit) : '—'}</td> <td class="px-3 py-1.5 text-right text-xs ${borderTop} ${r.compWorld ? WORLD_CLS : ''}">${r.competitorUnit != null ? formatCurrency(r.competitorUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs pricing-vendor-price-buy ${borderTop} ${r.vendorOrigUnit == null ? 'text-gray-400' : ''}">${r.vendorOrigUnit != null ? formatCurrency(r.vendorOrigUnit) : '—'}</td> <td class="px-3 py-1.5 text-right text-xs pricing-vendor-price-buy ${borderTop} ${r.vendorOrigUnit == null ? 'text-gray-400' : ''}">${r.vendorOrigUnit != null ? formatCurrency(r.vendorOrigUnit) : '—'}</td>
`; `;
tbodyBuy.appendChild(tr); tbodyBuy.appendChild(tr);
@@ -4332,9 +4344,9 @@ async function renderPricingTab() {
${pnDescHtml} ${pnDescHtml}
<td class="px-3 py-1.5 text-xs ${borderTop}">${r.lotCell}</td> <td class="px-3 py-1.5 text-xs ${borderTop}">${r.lotCell}</td>
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${r.qty}</td> <td class="px-3 py-1.5 text-right text-xs ${borderTop}">${r.qty}</td>
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${saleEstUnit > 0 ? formatCurrency(saleEstUnit) : '—'}</td> <td class="px-3 py-1.5 text-right text-xs ${borderTop} ${r.estWorld ? WORLD_CLS : ''}">${saleEstUnit > 0 ? formatCurrency(saleEstUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${saleWhUnit != null ? formatCurrency(saleWhUnit) : '—'}</td> <td class="px-3 py-1.5 text-right text-xs ${borderTop} ${r.whWorld ? WORLD_CLS : ''}">${saleWhUnit != null ? formatCurrency(saleWhUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs ${borderTop}">${saleCompUnit != null ? formatCurrency(saleCompUnit) : '—'}</td> <td class="px-3 py-1.5 text-right text-xs ${borderTop} ${r.compWorld ? WORLD_CLS : ''}">${saleCompUnit != null ? formatCurrency(saleCompUnit) : '—'}</td>
<td class="px-3 py-1.5 text-right text-xs pricing-vendor-price-sale ${borderTop} text-gray-400">—</td> <td class="px-3 py-1.5 text-right text-xs pricing-vendor-price-sale ${borderTop} text-gray-400">—</td>
`; `;
tbodySale.appendChild(tr); tbodySale.appendChild(tr);