feat: PN code в импорте BOM, объединённый список LOT (world ∪ estimate)
Колонка «PN code» в таблице импорта BOM: HPE-спецификации хранят партномер в двух столбцах, канонический вид — P52534-B21#B19. Склейка попадает в vendor_partnumber и сравнивается с книгой партномеров как есть; при загрузке разбирается обратно на две колонки. Схему БД менять не пришлось. Список допустимых LOT собирается из world ∪ estimate (componentUniverse), а не только из estimate. Прежняя область видимости молча теряла lot_mappings: резолвер сопоставлял PN по книге, которая про прайслисты не знает, а фронт отбрасывал LOT, которого нет в estimate — при этом в корзину он всё равно попадал. World-only позиции отдают price_quality 0. Кнопка «Пересопоставить» — перерешать BOM по актуальной книге, так как при открытии конфигурации сопоставления остаются замороженными. Итоги на вкладке «Ценообразование»: звёздочки убраны (переносились на новую строку), все три суммы красные, при наведении — попап с долей цен из прайслиста WORLD. Колонка «PN вендора» больше не переносится. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
0fa7b0b1b6
commit
905f9a4952
@@ -261,7 +261,7 @@ PK: lot_name
|
|||||||
| price | decimal(12,2) NOT NULL | |
|
| price | decimal(12,2) NOT NULL | |
|
||||||
| price_quality | tinyint unsigned, nullable | added by migration 033. Set by the external pricelist-building tool, 0-9, based on quote recency/count per its own per-lot pricing-period settings — QF only reads and displays it, never computes it. |
|
| price_quality | tinyint unsigned, nullable | added by migration 033. Set by the external pricelist-building tool, 0-9, based on quote recency/count per its own per-lot pricing-period settings — QF only reads and displays it, never computes it. |
|
||||||
|
|
||||||
`price_quality` is synced through `LocalPricelistItem` (pricelist detail page) and, separately, through `LocalComponent`/`services.ComponentView` (`/api/components`, used by the configurator's search dropdown and item table) — both read paths go through `pricelistItemRow`/`toLocalComponent()` in `internal/localdb/components.go`, still scoped to the currently selected pricelist. The color scale (red 0 → yellow 5 → green 9, gradient) lives in **one shared JS module**, `web/static/price-quality.js` (loaded by `base.html` for every page) — `priceQualityColor`/`qualityDotHtml`/`qualityBadgeHtml`/`qualityRowStyle`. Do not reimplement the color scale locally in a template; add a new helper to that module instead. Used in: pricelist detail "Качество" column, configurator search dropdown (colored dot), configurator table (leftmost quality-dot column), pricing tab (row background tint).
|
`price_quality` is synced through `LocalPricelistItem` (pricelist detail page) and, separately, through `LocalComponent`/`services.ComponentView` (`/api/components`, used by the configurator's search dropdown and item table) — both read paths go through `pricelistItemRow`/`toLocalComponent()` in `internal/localdb/components.go`. The `/api/components` path reads the component universe (`world` ∪ `estimate`, see [decisions/2026-07-24-component-universe-world-union.md](decisions/2026-07-24-component-universe-world-union.md)), where a world-only LOT is forced to `price_quality = 0` — the only place QF writes this field rather than reading it. The color scale (red 0 → yellow 5 → green 9, gradient) lives in **one shared JS module**, `web/static/price-quality.js` (loaded by `base.html` for every page) — `priceQualityColor`/`qualityDotHtml`/`qualityBadgeHtml`/`qualityRowStyle`. Do not reimplement the color scale locally in a template; add a new helper to that module instead. Used in: pricelist detail "Качество" column, configurator search dropdown (colored dot), configurator table (leftmost quality-dot column), pricing tab (row background tint).
|
||||||
|
|
||||||
The real table also has `price_method`, `price_period_days`, `price_coefficient`, `manual_price`, `meta_prices` and `lead_time_weeks` columns, owned and written by the external pricing engine that maintains `qt_pricelist_items` — **keep them in the table for backward compatibility with that system; QF must never drop, rename, or write to them.** QF itself does not model, sync, or display any of them (tried once, removed as unused/dead in the client). Do not re-add them to `models.PricelistItem`/`LocalPricelistItem` without a concrete UI need.
|
The real table also has `price_method`, `price_period_days`, `price_coefficient`, `manual_price`, `meta_prices` and `lead_time_weeks` columns, owned and written by the external pricing engine that maintains `qt_pricelist_items` — **keep them in the table for backward compatibility with that system; QF must never drop, rename, or write to them.** QF itself does not model, sync, or display any of them (tried once, removed as unused/dead in the client). Do not re-add them to `models.PricelistItem`/`LocalPricelistItem` without a concrete UI need.
|
||||||
|
|
||||||
|
|||||||
@@ -25,6 +25,33 @@ Rules:
|
|||||||
- QuoteForge does not use legacy BOM tables;
|
- QuoteForge does not use legacy BOM tables;
|
||||||
- apply flow rebuilds cart rows from `lot_mappings[]`.
|
- apply flow rebuilds cart rows from `lot_mappings[]`.
|
||||||
|
|
||||||
|
## Split partnumbers (HPE option codes)
|
||||||
|
|
||||||
|
HPE specs carry the partnumber in two columns: base PN plus an option code
|
||||||
|
(`P52534-B21` + `B19`). The canonical partnumber is the concatenation
|
||||||
|
`P52534-B21#B19`, and it is what gets compared against the partnumber book.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
- the import grid has a `PN code` column type in addition to `P/N`; it is optional
|
||||||
|
and at most one column may carry it;
|
||||||
|
- an empty code cell keeps the bare PN — no trailing `#`;
|
||||||
|
- the composed value is stored in `vendor_partnumber`. There is **no separate
|
||||||
|
`pn_code` field**: no DDL and no new JSON key, and the stored string is exactly
|
||||||
|
the string the resolver looks up;
|
||||||
|
- the column layout of the import grid is not persisted. On load the grid is
|
||||||
|
re-derived from `vendor_spec`, and the `PN code` column reappears only when at
|
||||||
|
least one stored partnumber contains `#`, split on the first `#`.
|
||||||
|
|
||||||
|
## Re-resolving an existing BOM
|
||||||
|
|
||||||
|
Opening a configuration does **not** re-resolve its BOM: `loadVendorSpec` renders the
|
||||||
|
`lot_mappings[]` frozen at save time, so a configuration keeps the mapping it was
|
||||||
|
saved with even after the partnumber book changes.
|
||||||
|
|
||||||
|
Book entries added later are picked up only through the `Пересопоставить` button,
|
||||||
|
which calls the resolve endpoint for every row. Book matches win over the stored
|
||||||
|
mapping (resolver step 1 beats step 2); rows the book does not know keep theirs.
|
||||||
|
|
||||||
## Partnumber books
|
## Partnumber books
|
||||||
|
|
||||||
Partnumber books are pull-only snapshots from PriceForge.
|
Partnumber books are pull-only snapshots from PriceForge.
|
||||||
|
|||||||
@@ -0,0 +1,57 @@
|
|||||||
|
# Decision: the component universe is `world` ∪ `estimate`, not `estimate` alone
|
||||||
|
|
||||||
|
**Date:** 2026-07-24
|
||||||
|
**Status:** active
|
||||||
|
|
||||||
|
## Context
|
||||||
|
|
||||||
|
Every component read path in `internal/localdb/components.go` was scoped to the latest
|
||||||
|
active `estimate` pricelist. That pricelist is therefore what defined the set of LOTs the
|
||||||
|
configurator considers to exist, and the frontend used it as its validation list
|
||||||
|
(`_bomLotValid` in `index.html`, fed by `/api/components`).
|
||||||
|
|
||||||
|
That silently broke BOM mappings. The resolver matches a vendor partnumber against the
|
||||||
|
partnumber book, which knows nothing about pricelists, so it legitimately returns LOTs the
|
||||||
|
estimate pricelist does not carry. The frontend then displayed such a row as mapped —
|
||||||
|
`_getRowBaseLot` does not validate `resolved_lot` — and `applyBOMToEstimate` pushed the LOT
|
||||||
|
into the cart, but `_getRowCanonicalLotMappings` filtered it out through `_bomLotValid`, so
|
||||||
|
the mapping vanished on save. Result: the LOT sat in the cart while its BOM row showed
|
||||||
|
"н/д", and the pricing tab listed it at the bottom as an orphan with an empty vendor PN.
|
||||||
|
|
||||||
|
Prices for such LOTs were already available — see
|
||||||
|
[2026-07-10-world-pricelist-fallback.md](2026-07-10-world-pricelist-fallback.md) — only
|
||||||
|
membership was missing.
|
||||||
|
|
||||||
|
## Decision
|
||||||
|
|
||||||
|
`componentUniverse()` in `internal/localdb/components.go` is the single source of the LOT
|
||||||
|
set, and every component read path goes through it: `ListComponents`,
|
||||||
|
`SearchLocalComponents`, `SearchLocalComponentsByCategory`, `GetLocalComponent`,
|
||||||
|
`GetLocalComponentCategories`, `GetLocalComponentCategoriesByLotNames`, `CountComponents`.
|
||||||
|
|
||||||
|
- The universe is the latest active `world` pricelist — the widest list available — plus
|
||||||
|
the latest active `estimate` pricelist as a safety net.
|
||||||
|
- Deduplication is on `UPPER(lot_name)`. The estimate row wins a collision, so its
|
||||||
|
category, description and `price_quality` stay authoritative and nothing changes for
|
||||||
|
LOTs that were already visible.
|
||||||
|
- A world-only row reports `price_quality = 0`, rendering at the red end of the shared
|
||||||
|
scale in `web/static/price-quality.js`. This is the **one** place QF sets that field
|
||||||
|
instead of only reading it (contrast `bible-local/03-database.md`), and it deliberately
|
||||||
|
overrides whatever the external pricing tool wrote on the world row.
|
||||||
|
- The helper always returns a derived table (`(?) AS c`), so callers apply their own
|
||||||
|
`Select`/`Count`/`Where` without knowing which pricelists exist.
|
||||||
|
- Either pricelist may be absent; only both missing is an error.
|
||||||
|
|
||||||
|
On the frontend, `_bomLotPersistable` additionally lets a LOT with
|
||||||
|
`resolution_source === 'book'` be persisted into `lot_mappings[]` even when it is in
|
||||||
|
neither pricelist. Hand-typed LOTs stay validated against the universe.
|
||||||
|
|
||||||
|
## Consequences
|
||||||
|
|
||||||
|
- The configurator's pickers, category list and search now surface world-only LOTs. They
|
||||||
|
are selectable and appear in the reddest quality colour.
|
||||||
|
- `CountComponents` — the "is there data" check — counts the union.
|
||||||
|
- A LOT present in neither pricelist (nothing to price it with) still resolves from the
|
||||||
|
book and now survives a save, instead of being dropped without a message.
|
||||||
|
- Covered by `internal/localdb/component_universe_test.go`: union and dedup, estimate
|
||||||
|
winning a collision, forced quality 0, and each single-source / empty case.
|
||||||
@@ -0,0 +1,176 @@
|
|||||||
|
package localdb
|
||||||
|
|
||||||
|
import (
|
||||||
|
"path/filepath"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// seedUniversePricelist stores one active pricelist of the given source with the
|
||||||
|
// supplied items and returns its local id.
|
||||||
|
func seedUniversePricelist(t *testing.T, local *LocalDB, serverID uint, source, version string, created time.Time, items []LocalPricelistItem) uint {
|
||||||
|
t.Helper()
|
||||||
|
pl := &LocalPricelist{
|
||||||
|
ServerID: serverID,
|
||||||
|
Source: source,
|
||||||
|
Version: version,
|
||||||
|
Name: version,
|
||||||
|
CreatedAt: created,
|
||||||
|
SyncedAt: created,
|
||||||
|
IsActive: true,
|
||||||
|
}
|
||||||
|
if err := local.SaveLocalPricelist(pl); err != nil {
|
||||||
|
t.Fatalf("save %s pricelist: %v", source, err)
|
||||||
|
}
|
||||||
|
stored, err := local.GetLocalPricelistByServerID(serverID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("load %s pricelist: %v", source, err)
|
||||||
|
}
|
||||||
|
for i := range items {
|
||||||
|
items[i].PricelistID = stored.ID
|
||||||
|
}
|
||||||
|
if err := local.SaveLocalPricelistItems(items); err != nil {
|
||||||
|
t.Fatalf("save %s items: %v", source, err)
|
||||||
|
}
|
||||||
|
return stored.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
func quality(v int) *int { return &v }
|
||||||
|
|
||||||
|
func newUniverseDB(t *testing.T, name string) *LocalDB {
|
||||||
|
t.Helper()
|
||||||
|
local, err := New(filepath.Join(t.TempDir(), name))
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("open localdb: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() { _ = local.Close() })
|
||||||
|
return local
|
||||||
|
}
|
||||||
|
|
||||||
|
// The component universe is world ∪ estimate: a LOT the estimate pricelist does not
|
||||||
|
// carry must still be selectable, otherwise BOM lot_mappings resolved from the
|
||||||
|
// partnumber book get silently dropped on save.
|
||||||
|
func TestComponentUniverseUnionsWorldAndEstimate(t *testing.T) {
|
||||||
|
local := newUniverseDB(t, "universe_union.db")
|
||||||
|
base := time.Now().Add(-time.Minute)
|
||||||
|
|
||||||
|
seedUniversePricelist(t, local, 1, "estimate", "E-1", base, []LocalPricelistItem{
|
||||||
|
{LotName: "CPU_A", LotCategory: "CPU", LotDescription: "from estimate", Price: 100, PriceQuality: quality(8)},
|
||||||
|
{LotName: "SHARED_LOT", LotCategory: "MEM", LotDescription: "from estimate", Price: 50, PriceQuality: quality(9)},
|
||||||
|
})
|
||||||
|
seedUniversePricelist(t, local, 2, "world", "W-1", base, []LocalPricelistItem{
|
||||||
|
{LotName: "SHARED_LOT", LotCategory: "WRONG", LotDescription: "from world", Price: 55, PriceQuality: quality(1)},
|
||||||
|
{LotName: "NIC_WORLD_ONLY", LotCategory: "NIC", LotDescription: "from world", Price: 70, PriceQuality: quality(7)},
|
||||||
|
})
|
||||||
|
|
||||||
|
components, total, err := local.ListComponents(ComponentFilter{}, 0, 100)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListComponents: %v", err)
|
||||||
|
}
|
||||||
|
if total != 3 {
|
||||||
|
t.Fatalf("expected 3 components (dedup on SHARED_LOT), got %d", total)
|
||||||
|
}
|
||||||
|
|
||||||
|
byLot := make(map[string]LocalComponent, len(components))
|
||||||
|
for _, c := range components {
|
||||||
|
if _, dup := byLot[c.LotName]; dup {
|
||||||
|
t.Fatalf("duplicate lot in universe: %s", c.LotName)
|
||||||
|
}
|
||||||
|
byLot[c.LotName] = c
|
||||||
|
}
|
||||||
|
|
||||||
|
worldOnly, ok := byLot["NIC_WORLD_ONLY"]
|
||||||
|
if !ok {
|
||||||
|
t.Fatal("world-only LOT missing from the component universe")
|
||||||
|
}
|
||||||
|
if worldOnly.PriceQuality == nil || *worldOnly.PriceQuality != 0 {
|
||||||
|
t.Fatalf("world-only LOT must report price_quality 0, got %v", worldOnly.PriceQuality)
|
||||||
|
}
|
||||||
|
|
||||||
|
// The estimate row wins a collision, so its metadata stays authoritative.
|
||||||
|
shared := byLot["SHARED_LOT"]
|
||||||
|
if shared.Category != "MEM" || shared.LotDescription != "from estimate" {
|
||||||
|
t.Fatalf("estimate row must win a collision, got category=%q description=%q", shared.Category, shared.LotDescription)
|
||||||
|
}
|
||||||
|
if shared.PriceQuality == nil || *shared.PriceQuality != 9 {
|
||||||
|
t.Fatalf("estimate price_quality must survive a collision, got %v", shared.PriceQuality)
|
||||||
|
}
|
||||||
|
|
||||||
|
if got := local.CountComponents(); got != 3 {
|
||||||
|
t.Fatalf("CountComponents: expected 3, got %d", got)
|
||||||
|
}
|
||||||
|
|
||||||
|
single, err := local.GetLocalComponent("nic_world_only")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLocalComponent for a world-only LOT: %v", err)
|
||||||
|
}
|
||||||
|
if single.LotName != "NIC_WORLD_ONLY" {
|
||||||
|
t.Fatalf("GetLocalComponent returned %q", single.LotName)
|
||||||
|
}
|
||||||
|
|
||||||
|
categories, err := local.GetLocalComponentCategories()
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLocalComponentCategories: %v", err)
|
||||||
|
}
|
||||||
|
if len(categories) != 3 {
|
||||||
|
t.Fatalf("expected CPU/MEM/NIC, got %v", categories)
|
||||||
|
}
|
||||||
|
|
||||||
|
cats, err := local.GetLocalComponentCategoriesByLotNames([]string{"NIC_WORLD_ONLY", "SHARED_LOT"})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("GetLocalComponentCategoriesByLotNames: %v", err)
|
||||||
|
}
|
||||||
|
if cats["NIC_WORLD_ONLY"] != "NIC" || cats["SHARED_LOT"] != "MEM" {
|
||||||
|
t.Fatalf("unexpected categories: %v", cats)
|
||||||
|
}
|
||||||
|
|
||||||
|
found, err := local.SearchLocalComponents("world_only", 10)
|
||||||
|
if err != nil || len(found) != 1 {
|
||||||
|
t.Fatalf("SearchLocalComponents: got %d rows, err=%v", len(found), err)
|
||||||
|
}
|
||||||
|
|
||||||
|
inCategory, err := local.SearchLocalComponentsByCategory("NIC", "", 10)
|
||||||
|
if err != nil || len(inCategory) != 1 {
|
||||||
|
t.Fatalf("SearchLocalComponentsByCategory: got %d rows, err=%v", len(inCategory), err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Only one of the two pricelists existing must not break the universe.
|
||||||
|
func TestComponentUniverseWithSingleSource(t *testing.T) {
|
||||||
|
t.Run("estimate only", func(t *testing.T) {
|
||||||
|
local := newUniverseDB(t, "universe_estimate_only.db")
|
||||||
|
seedUniversePricelist(t, local, 1, "estimate", "E-1", time.Now(), []LocalPricelistItem{
|
||||||
|
{LotName: "CPU_A", LotCategory: "CPU", Price: 100, PriceQuality: quality(8)},
|
||||||
|
})
|
||||||
|
if got := local.CountComponents(); got != 1 {
|
||||||
|
t.Fatalf("expected 1 component, got %d", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("world only", func(t *testing.T) {
|
||||||
|
local := newUniverseDB(t, "universe_world_only.db")
|
||||||
|
seedUniversePricelist(t, local, 2, "world", "W-1", time.Now(), []LocalPricelistItem{
|
||||||
|
{LotName: "CPU_A", LotCategory: "CPU", Price: 100, PriceQuality: quality(8)},
|
||||||
|
})
|
||||||
|
components, _, err := local.ListComponents(ComponentFilter{}, 0, 10)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("ListComponents: %v", err)
|
||||||
|
}
|
||||||
|
if len(components) != 1 {
|
||||||
|
t.Fatalf("expected 1 component, got %d", len(components))
|
||||||
|
}
|
||||||
|
if components[0].PriceQuality == nil || *components[0].PriceQuality != 0 {
|
||||||
|
t.Fatalf("world-only LOT must report price_quality 0, got %v", components[0].PriceQuality)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("neither", func(t *testing.T) {
|
||||||
|
local := newUniverseDB(t, "universe_empty.db")
|
||||||
|
if _, _, err := local.ListComponents(ComponentFilter{}, 0, 10); err == nil {
|
||||||
|
t.Fatal("expected an error when no estimate and no world pricelist exists")
|
||||||
|
}
|
||||||
|
if got := local.CountComponents(); got != 0 {
|
||||||
|
t.Fatalf("expected 0, got %d", got)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -4,6 +4,8 @@ import (
|
|||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"gorm.io/gorm"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ComponentFilter for searching with filters
|
// ComponentFilter for searching with filters
|
||||||
@@ -40,6 +42,60 @@ func (l *LocalDB) latestActivePricelistID(source string) (uint, error) {
|
|||||||
return id, nil
|
return id, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// componentUniverse returns a query over the set of LOTs the configurator is allowed
|
||||||
|
// to choose from. That set is the latest active `world` pricelist — the widest list we
|
||||||
|
// have — plus, as a safety net, the latest active `estimate` pricelist, deduplicated by
|
||||||
|
// UPPER(lot_name).
|
||||||
|
//
|
||||||
|
// Scoping this to `estimate` alone used to silently drop BOM lot_mappings: the resolver
|
||||||
|
// matches a partnumber against the book, which knows nothing about pricelists, and the
|
||||||
|
// frontend then rejected any LOT the estimate pricelist did not carry. Prices for
|
||||||
|
// world-only LOTs already come from the world fallback (see
|
||||||
|
// decisions/2026-07-10-world-pricelist-fallback.md).
|
||||||
|
//
|
||||||
|
// The estimate row wins a collision, so its category/description/price_quality stay
|
||||||
|
// authoritative. A world-only row reports price_quality 0 so it renders at the red end of
|
||||||
|
// the quality scale — the one place QF sets this field rather than only reading it.
|
||||||
|
// Either pricelist may be absent; only both missing is an error.
|
||||||
|
func (l *LocalDB) componentUniverse() (*gorm.DB, error) {
|
||||||
|
estimateID, estimateErr := l.latestActivePricelistID("estimate")
|
||||||
|
worldID, worldErr := l.latestActivePricelistID("world")
|
||||||
|
if estimateErr != nil && worldErr != nil {
|
||||||
|
return nil, fmt.Errorf("no active estimate or world pricelist")
|
||||||
|
}
|
||||||
|
|
||||||
|
// Always a derived table, so every caller can apply its own Select/Count/Where
|
||||||
|
// uniformly and none of them has to know which pricelists actually exist.
|
||||||
|
const estimateSelect = `
|
||||||
|
SELECT lot_name, lot_category, lot_description, price_quality
|
||||||
|
FROM local_pricelist_items
|
||||||
|
WHERE pricelist_id = ?`
|
||||||
|
const worldSelect = `
|
||||||
|
SELECT w.lot_name, w.lot_category, w.lot_description, 0 AS price_quality
|
||||||
|
FROM local_pricelist_items w
|
||||||
|
WHERE w.pricelist_id = ?`
|
||||||
|
const worldOnlyTail = `
|
||||||
|
AND NOT EXISTS (
|
||||||
|
SELECT 1 FROM local_pricelist_items e
|
||||||
|
WHERE e.pricelist_id = ?
|
||||||
|
AND UPPER(e.lot_name) = UPPER(w.lot_name)
|
||||||
|
)`
|
||||||
|
|
||||||
|
var inner *gorm.DB
|
||||||
|
switch {
|
||||||
|
case worldErr != nil:
|
||||||
|
inner = l.db.Raw(estimateSelect, estimateID)
|
||||||
|
case estimateErr != nil:
|
||||||
|
inner = l.db.Raw(worldSelect, worldID)
|
||||||
|
default:
|
||||||
|
inner = l.db.Raw(
|
||||||
|
estimateSelect+"\nUNION ALL"+worldSelect+worldOnlyTail,
|
||||||
|
estimateID, worldID, estimateID)
|
||||||
|
}
|
||||||
|
|
||||||
|
return l.db.Table("(?) AS c", inner), nil
|
||||||
|
}
|
||||||
|
|
||||||
// pricelistItemRow is used for scanning rows from local_pricelist_items.
|
// pricelistItemRow is used for scanning rows from local_pricelist_items.
|
||||||
type pricelistItemRow struct {
|
type pricelistItemRow struct {
|
||||||
LotName string `gorm:"column:lot_name"`
|
LotName string `gorm:"column:lot_name"`
|
||||||
@@ -57,25 +113,23 @@ func (r pricelistItemRow) toLocalComponent() LocalComponent {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchLocalComponents searches components in the latest active estimate
|
// SearchLocalComponents searches the component universe by lot_name.
|
||||||
// pricelist by lot_name.
|
|
||||||
func (l *LocalDB) SearchLocalComponents(query string, limit int) ([]LocalComponent, error) {
|
func (l *LocalDB) SearchLocalComponents(query string, limit int) ([]LocalComponent, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 50
|
limit = 50
|
||||||
}
|
}
|
||||||
pricelistID, err := l.latestActivePricelistID("estimate")
|
db, err := l.componentUniverse()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
db := l.db.Table("local_pricelist_items").
|
|
||||||
Where("pricelist_id = ?", pricelistID)
|
|
||||||
if query != "" {
|
if query != "" {
|
||||||
db = db.Where("LOWER(lot_name) LIKE ?", "%"+strings.ToLower(query)+"%")
|
db = db.Where("LOWER(lot_name) LIKE ?", "%"+strings.ToLower(query)+"%")
|
||||||
}
|
}
|
||||||
|
|
||||||
var rows []pricelistItemRow
|
var rows []pricelistItemRow
|
||||||
if err := db.Select("lot_name, lot_category, lot_description, price_quality").Order("lot_name").Limit(limit).Scan(&rows).Error; err != nil {
|
if err := db.Select("lot_name, lot_category, lot_description, price_quality").
|
||||||
|
Order("lot_name").Limit(limit).Scan(&rows).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
components := make([]LocalComponent, len(rows))
|
components := make([]LocalComponent, len(rows))
|
||||||
@@ -85,25 +139,24 @@ func (l *LocalDB) SearchLocalComponents(query string, limit int) ([]LocalCompone
|
|||||||
return components, nil
|
return components, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// SearchLocalComponentsByCategory searches components in the latest active
|
// SearchLocalComponentsByCategory searches the component universe filtered by category.
|
||||||
// estimate pricelist filtered by category.
|
|
||||||
func (l *LocalDB) SearchLocalComponentsByCategory(category, query string, limit int) ([]LocalComponent, error) {
|
func (l *LocalDB) SearchLocalComponentsByCategory(category, query string, limit int) ([]LocalComponent, error) {
|
||||||
if limit <= 0 {
|
if limit <= 0 {
|
||||||
limit = 50
|
limit = 50
|
||||||
}
|
}
|
||||||
pricelistID, err := l.latestActivePricelistID("estimate")
|
db, err := l.componentUniverse()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
db := l.db.Table("local_pricelist_items").
|
db = db.Where("UPPER(lot_category) = ?", strings.ToUpper(category))
|
||||||
Where("pricelist_id = ? AND UPPER(lot_category) = ?", pricelistID, strings.ToUpper(category))
|
|
||||||
if query != "" {
|
if query != "" {
|
||||||
db = db.Where("LOWER(lot_name) LIKE ?", "%"+strings.ToLower(query)+"%")
|
db = db.Where("LOWER(lot_name) LIKE ?", "%"+strings.ToLower(query)+"%")
|
||||||
}
|
}
|
||||||
|
|
||||||
var rows []pricelistItemRow
|
var rows []pricelistItemRow
|
||||||
if err := db.Select("lot_name, lot_category, lot_description, price_quality").Order("lot_name").Limit(limit).Scan(&rows).Error; err != nil {
|
if err := db.Select("lot_name, lot_category, lot_description, price_quality").
|
||||||
|
Order("lot_name").Limit(limit).Scan(&rows).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
components := make([]LocalComponent, len(rows))
|
components := make([]LocalComponent, len(rows))
|
||||||
@@ -113,17 +166,14 @@ func (l *LocalDB) SearchLocalComponentsByCategory(category, query string, limit
|
|||||||
return components, nil
|
return components, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListComponents returns components from the latest active estimate pricelist
|
// ListComponents returns components from the component universe with optional
|
||||||
// with optional category/search filtering and pagination.
|
// category/search filtering and pagination.
|
||||||
func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]LocalComponent, int64, error) {
|
func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]LocalComponent, int64, error) {
|
||||||
pricelistID, err := l.latestActivePricelistID("estimate")
|
db, err := l.componentUniverse()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
|
|
||||||
db := l.db.Table("local_pricelist_items").
|
|
||||||
Where("pricelist_id = ?", pricelistID)
|
|
||||||
|
|
||||||
if filter.Category != "" {
|
if filter.Category != "" {
|
||||||
db = db.Where("UPPER(lot_category) = ?", strings.ToUpper(filter.Category))
|
db = db.Where("UPPER(lot_category) = ?", strings.ToUpper(filter.Category))
|
||||||
}
|
}
|
||||||
@@ -147,18 +197,17 @@ func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]L
|
|||||||
return components, total, nil
|
return components, total, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLocalComponent returns a single component by lot_name from the latest
|
// GetLocalComponent returns a single component by lot_name from the component universe.
|
||||||
// active estimate pricelist.
|
|
||||||
func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
|
func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
|
||||||
pricelistID, err := l.latestActivePricelistID("estimate")
|
db, err := l.componentUniverse()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var row pricelistItemRow
|
var row pricelistItemRow
|
||||||
if err := l.db.Table("local_pricelist_items").
|
if err := db.
|
||||||
Select("lot_name, lot_category, lot_description, price_quality").
|
Select("lot_name, lot_category, lot_description, price_quality").
|
||||||
Where("pricelist_id = ? AND UPPER(lot_name) = ?", pricelistID, strings.ToUpper(lotName)).
|
Where("UPPER(lot_name) = ?", strings.ToUpper(lotName)).
|
||||||
First(&row).Error; err != nil {
|
First(&row).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -167,13 +216,13 @@ func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// GetLocalComponentCategoriesByLotNames returns category for each lot_name
|
// GetLocalComponentCategoriesByLotNames returns category for each lot_name
|
||||||
// from the latest active estimate pricelist.
|
// from the component universe.
|
||||||
func (l *LocalDB) GetLocalComponentCategoriesByLotNames(lotNames []string) (map[string]string, error) {
|
func (l *LocalDB) GetLocalComponentCategoriesByLotNames(lotNames []string) (map[string]string, error) {
|
||||||
result := make(map[string]string, len(lotNames))
|
result := make(map[string]string, len(lotNames))
|
||||||
if len(lotNames) == 0 {
|
if len(lotNames) == 0 {
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
pricelistID, err := l.latestActivePricelistID("estimate")
|
db, err := l.componentUniverse()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
@@ -187,9 +236,9 @@ func (l *LocalDB) GetLocalComponentCategoriesByLotNames(lotNames []string) (map[
|
|||||||
upperToOrig[u] = n
|
upperToOrig[u] = n
|
||||||
}
|
}
|
||||||
var rows []pricelistItemRow
|
var rows []pricelistItemRow
|
||||||
if err := l.db.Table("local_pricelist_items").
|
if err := db.
|
||||||
Select("lot_name, lot_category").
|
Select("lot_name, lot_category").
|
||||||
Where("pricelist_id = ? AND UPPER(lot_name) IN ?", pricelistID, upper).
|
Where("UPPER(lot_name) IN ?", upper).
|
||||||
Scan(&rows).Error; err != nil {
|
Scan(&rows).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -203,17 +252,16 @@ func (l *LocalDB) GetLocalComponentCategoriesByLotNames(lotNames []string) (map[
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetLocalComponentCategories returns distinct categories from the latest
|
// GetLocalComponentCategories returns distinct categories from the component universe.
|
||||||
// active estimate pricelist.
|
|
||||||
func (l *LocalDB) GetLocalComponentCategories() ([]string, error) {
|
func (l *LocalDB) GetLocalComponentCategories() ([]string, error) {
|
||||||
pricelistID, err := l.latestActivePricelistID("estimate")
|
db, err := l.componentUniverse()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
var categories []string
|
var categories []string
|
||||||
if err := l.db.Table("local_pricelist_items").
|
if err := db.
|
||||||
Where("pricelist_id = ? AND lot_category != ''", pricelistID).
|
Where("lot_category != ''").
|
||||||
Distinct("lot_category").
|
Distinct("lot_category").
|
||||||
Order("lot_category").
|
Order("lot_category").
|
||||||
Pluck("lot_category", &categories).Error; err != nil {
|
Pluck("lot_category", &categories).Error; err != nil {
|
||||||
@@ -222,14 +270,14 @@ func (l *LocalDB) GetLocalComponentCategories() ([]string, error) {
|
|||||||
return categories, nil
|
return categories, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// CountComponents returns the number of distinct lot names in the latest
|
// CountComponents returns the number of lot names in the component universe
|
||||||
// active estimate pricelist (used to check if data is available).
|
// (used to check if data is available).
|
||||||
func (l *LocalDB) CountComponents() int64 {
|
func (l *LocalDB) CountComponents() int64 {
|
||||||
pricelistID, err := l.latestActivePricelistID("estimate")
|
db, err := l.componentUniverse()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return 0
|
return 0
|
||||||
}
|
}
|
||||||
var count int64
|
var count int64
|
||||||
l.db.Table("local_pricelist_items").Where("pricelist_id = ?", pricelistID).Count(&count)
|
db.Count(&count)
|
||||||
return count
|
return count
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -7,3 +7,40 @@
|
|||||||
-webkit-box-orient: vertical;
|
-webkit-box-orient: vertical;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Pricing footer totals: instant hover popup explaining how much of the sum rests on
|
||||||
|
world-pricelist stand-in prices. A native title= tooltip was too slow and too easy to
|
||||||
|
miss on a number the user is meant to distrust. */
|
||||||
|
.pricing-total-tip {
|
||||||
|
position: relative;
|
||||||
|
cursor: help;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-total-tip > .pricing-total-tip__body {
|
||||||
|
visibility: hidden;
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
bottom: calc(100% + 6px);
|
||||||
|
z-index: 30;
|
||||||
|
width: max-content;
|
||||||
|
max-width: 20rem;
|
||||||
|
padding: 0.5rem 0.625rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background: #1f2937;
|
||||||
|
color: #f9fafb;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 400;
|
||||||
|
line-height: 1.35;
|
||||||
|
text-align: left;
|
||||||
|
white-space: normal;
|
||||||
|
box-shadow: 0 4px 12px rgb(0 0 0 / 0.18);
|
||||||
|
transition: opacity 0.1s ease-in;
|
||||||
|
pointer-events: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pricing-total-tip:hover > .pricing-total-tip__body,
|
||||||
|
.pricing-total-tip:focus-visible > .pricing-total-tip__body {
|
||||||
|
visibility: visible;
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|||||||
+165
-82
@@ -194,6 +194,9 @@
|
|||||||
<button onclick="clearBOM()" class="px-3 py-1 bg-gray-100 text-gray-600 rounded hover:bg-gray-200 border border-gray-300">
|
<button onclick="clearBOM()" class="px-3 py-1 bg-gray-100 text-gray-600 rounded hover:bg-gray-200 border border-gray-300">
|
||||||
Очистить
|
Очистить
|
||||||
</button>
|
</button>
|
||||||
|
<button onclick="reresolveBOM()" title="Заново сопоставить строки BOM по актуальной книге партномеров" class="px-3 py-1 bg-gray-100 text-gray-600 rounded hover:bg-gray-200 border border-gray-300">
|
||||||
|
Пересопоставить
|
||||||
|
</button>
|
||||||
<button onclick="saveBOM()" class="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">
|
<button onclick="saveBOM()" class="px-3 py-1 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||||
Сохранить BOM
|
Сохранить BOM
|
||||||
</button>
|
</button>
|
||||||
@@ -219,26 +222,26 @@
|
|||||||
<table class="w-full text-sm border-collapse">
|
<table class="w-full text-sm border-collapse">
|
||||||
<thead class="bg-gray-50 text-gray-700">
|
<thead class="bg-gray-50 text-gray-700">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-3 py-2 text-left border-b">PN вендора</th>
|
<th class="px-2 py-2 text-left border-b whitespace-nowrap">PN вендора</th>
|
||||||
<th class="px-3 py-2 text-left border-b">Описание</th>
|
<th class="px-2 py-2 text-left border-b">Описание</th>
|
||||||
<th class="px-3 py-2 text-left border-b">LOT</th>
|
<th class="px-2 py-2 text-left border-b">LOT</th>
|
||||||
<th class="px-3 py-2 text-right border-b">Кол-во</th>
|
<th class="px-2 py-2 text-right border-b">Кол-во</th>
|
||||||
<th class="px-3 py-2 text-right border-b">Estimate</th>
|
<th class="px-2 py-2 text-right border-b">Estimate</th>
|
||||||
<th class="px-3 py-2 text-right border-b">Склад</th>
|
<th class="px-2 py-2 text-right border-b">Склад</th>
|
||||||
<th class="px-3 py-2 text-right border-b">Конкуренты</th>
|
<th class="px-2 py-2 text-right border-b">Конкуренты</th>
|
||||||
<th class="px-3 py-2 text-right border-b">Ручная цена</th>
|
<th class="px-2 py-2 text-right border-b">Ручная цена</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="pricing-body-buy">
|
<tbody id="pricing-body-buy">
|
||||||
<tr><td colspan="8" class="px-3 py-8 text-center text-gray-400">Загрузите BOM во вкладке «BOM»</td></tr>
|
<tr><td colspan="8" class="px-2 py-8 text-center text-gray-400">Загрузите BOM во вкладке «BOM»</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot id="pricing-foot-buy" class="hidden bg-gray-50 font-semibold">
|
<tfoot id="pricing-foot-buy" class="hidden bg-gray-50 font-semibold">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="4" class="px-3 py-2 text-right">Итого:</td>
|
<td colspan="4" class="px-2 py-2 text-right">Итого:</td>
|
||||||
<td class="px-3 py-2 text-right" id="pricing-total-buy-estimate">—</td>
|
<td class="px-2 py-2 text-right" id="pricing-total-buy-estimate">—</td>
|
||||||
<td class="px-3 py-2 text-right" id="pricing-total-buy-warehouse">—</td>
|
<td class="px-2 py-2 text-right" id="pricing-total-buy-warehouse">—</td>
|
||||||
<td class="px-3 py-2 text-right" id="pricing-total-buy-competitor">—</td>
|
<td class="px-2 py-2 text-right" id="pricing-total-buy-competitor">—</td>
|
||||||
<td class="px-3 py-2 text-right font-bold" id="pricing-total-buy-vendor">—</td>
|
<td class="px-2 py-2 text-right font-bold" id="pricing-total-buy-vendor">—</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
@@ -268,26 +271,26 @@
|
|||||||
<table class="w-full text-sm border-collapse">
|
<table class="w-full text-sm border-collapse">
|
||||||
<thead class="bg-gray-50 text-gray-700">
|
<thead class="bg-gray-50 text-gray-700">
|
||||||
<tr>
|
<tr>
|
||||||
<th class="px-3 py-2 text-left border-b">PN вендора</th>
|
<th class="px-2 py-2 text-left border-b whitespace-nowrap">PN вендора</th>
|
||||||
<th class="px-3 py-2 text-left border-b">Описание</th>
|
<th class="px-2 py-2 text-left border-b">Описание</th>
|
||||||
<th class="px-3 py-2 text-left border-b">LOT</th>
|
<th class="px-2 py-2 text-left border-b">LOT</th>
|
||||||
<th class="px-3 py-2 text-right border-b">Кол-во</th>
|
<th class="px-2 py-2 text-right border-b">Кол-во</th>
|
||||||
<th class="px-3 py-2 text-right border-b">Estimate</th>
|
<th class="px-2 py-2 text-right border-b">Estimate</th>
|
||||||
<th class="px-3 py-2 text-right border-b">Склад</th>
|
<th class="px-2 py-2 text-right border-b">Склад</th>
|
||||||
<th class="px-3 py-2 text-right border-b">Конкуренты</th>
|
<th class="px-2 py-2 text-right border-b">Конкуренты</th>
|
||||||
<th class="px-3 py-2 text-right border-b">Ручная цена</th>
|
<th class="px-2 py-2 text-right border-b">Ручная цена</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="pricing-body-sale">
|
<tbody id="pricing-body-sale">
|
||||||
<tr><td colspan="8" class="px-3 py-8 text-center text-gray-400">Загрузите BOM во вкладке «BOM»</td></tr>
|
<tr><td colspan="8" class="px-2 py-8 text-center text-gray-400">Загрузите BOM во вкладке «BOM»</td></tr>
|
||||||
</tbody>
|
</tbody>
|
||||||
<tfoot id="pricing-foot-sale" class="hidden bg-gray-50 font-semibold">
|
<tfoot id="pricing-foot-sale" class="hidden bg-gray-50 font-semibold">
|
||||||
<tr>
|
<tr>
|
||||||
<td colspan="4" class="px-3 py-2 text-right">Итого:</td>
|
<td colspan="4" class="px-2 py-2 text-right">Итого:</td>
|
||||||
<td class="px-3 py-2 text-right" id="pricing-total-sale-estimate">—</td>
|
<td class="px-2 py-2 text-right" id="pricing-total-sale-estimate">—</td>
|
||||||
<td class="px-3 py-2 text-right" id="pricing-total-sale-warehouse">—</td>
|
<td class="px-2 py-2 text-right" id="pricing-total-sale-warehouse">—</td>
|
||||||
<td class="px-3 py-2 text-right" id="pricing-total-sale-competitor">—</td>
|
<td class="px-2 py-2 text-right" id="pricing-total-sale-competitor">—</td>
|
||||||
<td class="px-3 py-2 text-right font-bold" id="pricing-total-sale-vendor">—</td>
|
<td class="px-2 py-2 text-right font-bold" id="pricing-total-sale-vendor">—</td>
|
||||||
</tr>
|
</tr>
|
||||||
</tfoot>
|
</tfoot>
|
||||||
</table>
|
</table>
|
||||||
@@ -3480,6 +3483,7 @@ let bomImportRaw = null; // { mode:'raw'|'parsed', rows, columnTypes, ignoredRow
|
|||||||
const BOM_COL_TYPES = [
|
const BOM_COL_TYPES = [
|
||||||
{ value: 'ignore', label: 'Не использовать' },
|
{ value: 'ignore', label: 'Не использовать' },
|
||||||
{ value: 'pn', label: 'P/N' },
|
{ value: 'pn', label: 'P/N' },
|
||||||
|
{ value: 'pn_code', label: 'PN code' },
|
||||||
{ value: 'qty', label: 'Кол-во' },
|
{ value: 'qty', label: 'Кол-во' },
|
||||||
{ value: 'price', label: 'Цена' },
|
{ value: 'price', label: 'Цена' },
|
||||||
{ value: 'description', label: 'Описание' }
|
{ value: 'description', label: 'Описание' }
|
||||||
@@ -3490,6 +3494,7 @@ function _bomRawHeaderWidthClass(colType) {
|
|||||||
case 'qty': return 'w-24 min-w-24';
|
case 'qty': return 'w-24 min-w-24';
|
||||||
case 'price': return 'w-32 min-w-32';
|
case 'price': return 'w-32 min-w-32';
|
||||||
case 'pn': return 'min-w-40';
|
case 'pn': return 'min-w-40';
|
||||||
|
case 'pn_code': return 'w-28 min-w-28';
|
||||||
case 'description': return 'min-w-48';
|
case 'description': return 'min-w-48';
|
||||||
default: return 'min-w-28';
|
default: return 'min-w-28';
|
||||||
}
|
}
|
||||||
@@ -3499,10 +3504,32 @@ function _bomRawCellWidthClass(colType) {
|
|||||||
switch (colType) {
|
switch (colType) {
|
||||||
case 'qty': return 'w-24 min-w-24';
|
case 'qty': return 'w-24 min-w-24';
|
||||||
case 'price': return 'w-32 min-w-32';
|
case 'price': return 'w-32 min-w-32';
|
||||||
|
case 'pn_code': return 'w-28 min-w-28';
|
||||||
default: return '';
|
default: return '';
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// HPE specs split the partnumber across two columns (PN + option CODE): the
|
||||||
|
// canonical partnumber is "P52534-B21#B19". A row with an empty code column
|
||||||
|
// keeps the bare PN.
|
||||||
|
function _bomComposePN(cols, idx) {
|
||||||
|
const pn = ((cols[idx.pn[0]] || '') + '').trim();
|
||||||
|
if (!pn) return '';
|
||||||
|
const codeCol = idx.pn_code.length ? idx.pn_code[0] : -1;
|
||||||
|
if (codeCol === -1) return pn;
|
||||||
|
const code = ((cols[codeCol] || '') + '').trim();
|
||||||
|
return code ? `${pn}#${code}` : pn;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Inverse of _bomComposePN: splits a stored partnumber back into [pn, code].
|
||||||
|
// Only the first '#' separates the option code, so a code containing '#' round-trips.
|
||||||
|
function _bomSplitPN(vendorPN) {
|
||||||
|
const s = (vendorPN || '').trim();
|
||||||
|
const at = s.indexOf('#');
|
||||||
|
if (at === -1) return [s, ''];
|
||||||
|
return [s.slice(0, at), s.slice(at + 1)];
|
||||||
|
}
|
||||||
|
|
||||||
function parsePastePrice(s) {
|
function parsePastePrice(s) {
|
||||||
if (!s) return null;
|
if (!s) return null;
|
||||||
let v = String(s).replace(/[$\s]/g, '');
|
let v = String(s).replace(/[$\s]/g, '');
|
||||||
@@ -3649,7 +3676,7 @@ async function handleBOMPaste(event) {
|
|||||||
|
|
||||||
function _getBomColumnTypeIndexes() {
|
function _getBomColumnTypeIndexes() {
|
||||||
if (!bomImportRaw || !Array.isArray(bomImportRaw.columnTypes)) return null;
|
if (!bomImportRaw || !Array.isArray(bomImportRaw.columnTypes)) return null;
|
||||||
const idx = { ignore: [], pn: [], qty: [], price: [], description: [] };
|
const idx = { ignore: [], pn: [], pn_code: [], qty: [], price: [], description: [] };
|
||||||
bomImportRaw.columnTypes.forEach((t, i) => { (idx[t] || idx.ignore).push(i); });
|
bomImportRaw.columnTypes.forEach((t, i) => { (idx[t] || idx.ignore).push(i); });
|
||||||
return idx;
|
return idx;
|
||||||
}
|
}
|
||||||
@@ -3659,6 +3686,7 @@ function _validateBomColumnTypes() {
|
|||||||
const idx = _getBomColumnTypeIndexes();
|
const idx = _getBomColumnTypeIndexes();
|
||||||
if (!idx) return { ok: false, message: 'Нет данных BOM' };
|
if (!idx) return { ok: false, message: 'Нет данных BOM' };
|
||||||
if (idx.pn.length !== 1) return { ok: false, message: 'Выберите ровно один столбец P/N.' };
|
if (idx.pn.length !== 1) return { ok: false, message: 'Выберите ровно один столбец P/N.' };
|
||||||
|
if (idx.pn_code.length > 1) return { ok: false, message: 'Можно выбрать только один столбец PN code.' };
|
||||||
if (idx.qty.length !== 1) return { ok: false, message: 'Выберите ровно один столбец Кол-во.' };
|
if (idx.qty.length !== 1) return { ok: false, message: 'Выберите ровно один столбец Кол-во.' };
|
||||||
if (idx.price.length > 1) return { ok: false, message: 'Можно выбрать только один столбец Цена.' };
|
if (idx.price.length > 1) return { ok: false, message: 'Можно выбрать только один столбец Цена.' };
|
||||||
if (idx.description.length > 1) return { ok: false, message: 'Можно выбрать только один столбец Описание.' };
|
if (idx.description.length > 1) return { ok: false, message: 'Можно выбрать только один столбец Описание.' };
|
||||||
@@ -3801,10 +3829,20 @@ function _getRowBaseLot(row) {
|
|||||||
if (manual && _bomLotValid(manual)) return manual.toUpperCase();
|
if (manual && _bomLotValid(manual)) return manual.toUpperCase();
|
||||||
return '';
|
return '';
|
||||||
}
|
}
|
||||||
|
// A LOT resolved from the partnumber book is authoritative even when it is absent
|
||||||
|
// from the component list: that list is the active estimate pricelist only
|
||||||
|
// (localdb.ListComponents), so a LOT the pricelist does not carry would otherwise be
|
||||||
|
// dropped from lot_mappings on save while still reaching the cart via _getRowBaseLot.
|
||||||
|
// Hand-typed LOTs stay validated.
|
||||||
|
function _bomLotPersistable(row, lot) {
|
||||||
|
if (!lot) return false;
|
||||||
|
return row?.resolution_source === 'book' || _bomLotValid(lot);
|
||||||
|
}
|
||||||
|
|
||||||
function _getRowCanonicalLotMappings(row) {
|
function _getRowCanonicalLotMappings(row) {
|
||||||
const out = [];
|
const out = [];
|
||||||
const baseLot = _getRowBaseLot(row);
|
const baseLot = _getRowBaseLot(row);
|
||||||
if (baseLot && _bomLotValid(baseLot)) {
|
if (baseLot && _bomLotPersistable(row, baseLot)) {
|
||||||
out.push({
|
out.push({
|
||||||
lot_name: baseLot,
|
lot_name: baseLot,
|
||||||
quantity_per_pn: _getRowLotQtyPerPN(row)
|
quantity_per_pn: _getRowLotQtyPerPN(row)
|
||||||
@@ -3980,7 +4018,6 @@ function rebuildBOMRowsFromRaw() {
|
|||||||
_setBomUIError('');
|
_setBomUIError('');
|
||||||
|
|
||||||
const idx = validation.idx;
|
const idx = validation.idx;
|
||||||
const pnCol = idx.pn[0];
|
|
||||||
const qtyCol = idx.qty[0];
|
const qtyCol = idx.qty[0];
|
||||||
const priceCol = idx.price.length ? idx.price[0] : -1;
|
const priceCol = idx.price.length ? idx.price[0] : -1;
|
||||||
const descCol = idx.description.length ? idx.description[0] : -1;
|
const descCol = idx.description.length ? idx.description[0] : -1;
|
||||||
@@ -3988,7 +4025,7 @@ function rebuildBOMRowsFromRaw() {
|
|||||||
const nextRows = [];
|
const nextRows = [];
|
||||||
bomImportRaw.rows.forEach((cols, rowIdx) => {
|
bomImportRaw.rows.forEach((cols, rowIdx) => {
|
||||||
if (bomImportRaw.ignoredRows?.[rowIdx]) return;
|
if (bomImportRaw.ignoredRows?.[rowIdx]) return;
|
||||||
const pn = ((cols[pnCol] || '') + '').trim();
|
const pn = _bomComposePN(cols, idx);
|
||||||
if (!pn) return;
|
if (!pn) return;
|
||||||
const qtyRaw = ((cols[qtyCol] || '') + '').trim();
|
const qtyRaw = ((cols[qtyCol] || '') + '').trim();
|
||||||
if (!/^\d+$/.test(qtyRaw) || parseInt(qtyRaw, 10) < 1) {
|
if (!/^\d+$/.test(qtyRaw) || parseInt(qtyRaw, 10) < 1) {
|
||||||
@@ -4067,13 +4104,12 @@ async function resolveBOM() {
|
|||||||
if (bomImportRaw && bomImportRaw.mode === 'raw') {
|
if (bomImportRaw && bomImportRaw.mode === 'raw') {
|
||||||
const validation = _validateBomColumnTypes();
|
const validation = _validateBomColumnTypes();
|
||||||
if (validation.ok) {
|
if (validation.ok) {
|
||||||
const pnCol = validation.idx.pn[0];
|
|
||||||
const descCol = validation.idx.description.length ? validation.idx.description[0] : -1;
|
const descCol = validation.idx.description.length ? validation.idx.description[0] : -1;
|
||||||
ignoredSeen = (bomImportRaw.rows || [])
|
ignoredSeen = (bomImportRaw.rows || [])
|
||||||
.map((cols, rowIdx) => ({ cols, rowIdx }))
|
.map((cols, rowIdx) => ({ cols, rowIdx }))
|
||||||
.filter(x => bomImportRaw.ignoredRows?.[x.rowIdx])
|
.filter(x => bomImportRaw.ignoredRows?.[x.rowIdx])
|
||||||
.map(({ cols }) => ({
|
.map(({ cols }) => ({
|
||||||
partnumber: ((cols[pnCol] || '') + '').trim(),
|
partnumber: _bomComposePN(cols, validation.idx),
|
||||||
description: descCol !== -1 ? ((cols[descCol] || '') + '').trim() : '',
|
description: descCol !== -1 ? ((cols[descCol] || '') + '').trim() : '',
|
||||||
ignored: true
|
ignored: true
|
||||||
}))
|
}))
|
||||||
@@ -4245,6 +4281,21 @@ function renderBOMTable() {
|
|||||||
if (currentTopTab === 'pricing') renderPricingTab();
|
if (currentTopTab === 'pricing') renderPricingTab();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Manual re-resolve against the current partnumber book. loadVendorSpec deliberately
|
||||||
|
// does not re-resolve on open — a configuration keeps the mappings frozen at save time —
|
||||||
|
// so this is the way to pick up book entries added since. Book matches win over the
|
||||||
|
// stored mapping (resolver step 1 > step 2); rows the book does not know keep theirs.
|
||||||
|
async function reresolveBOM() {
|
||||||
|
if (!bomRows.length) return;
|
||||||
|
const before = bomRows.map(r => _getRowBaseLot(r));
|
||||||
|
await resolveBOM();
|
||||||
|
const changed = bomRows.reduce((n, r, i) => n + (_getRowBaseLot(r) !== before[i] ? 1 : 0), 0);
|
||||||
|
const stillUnresolved = bomRows.filter(r => !_getRowBaseLot(r)).length;
|
||||||
|
showToast(changed
|
||||||
|
? `Пересопоставлено строк: ${changed}. Без LOT: ${stillUnresolved}`
|
||||||
|
: `Изменений нет. Без LOT: ${stillUnresolved}`, changed ? 'success' : 'info');
|
||||||
|
}
|
||||||
|
|
||||||
let _resolveBOMTimer = null;
|
let _resolveBOMTimer = null;
|
||||||
function debouncedResolveBOM() {
|
function debouncedResolveBOM() {
|
||||||
clearTimeout(_resolveBOMTimer);
|
clearTimeout(_resolveBOMTimer);
|
||||||
@@ -4416,16 +4467,22 @@ async function loadVendorSpec(configUUID) {
|
|||||||
});
|
});
|
||||||
|
|
||||||
// Reconstruct editable raw table from normalized vendor_spec (not original Excel paste).
|
// Reconstruct editable raw table from normalized vendor_spec (not original Excel paste).
|
||||||
// Columns: Qty | P/N | Description | Price
|
// Columns: Qty | P/N | Description | Price, plus PN code when any saved
|
||||||
|
// partnumber carries an HPE option code ("P52534-B21#B19"). The column layout
|
||||||
|
// itself is not persisted, so it is re-derived from the stored partnumbers.
|
||||||
|
const hasPNCode = bomRows.some(r => (r.vendor_pn || '').includes('#'));
|
||||||
bomImportRaw = {
|
bomImportRaw = {
|
||||||
mode: 'raw',
|
mode: 'raw',
|
||||||
rows: bomRows.map(r => ([
|
rows: bomRows.map(r => {
|
||||||
String(r.quantity ?? 1),
|
const [pn, code] = _bomSplitPN(r.vendor_pn || '');
|
||||||
r.vendor_pn || '',
|
const cells = [String(r.quantity ?? 1), pn];
|
||||||
r.description || '',
|
if (hasPNCode) cells.push(code);
|
||||||
r.unit_price != null ? String(r.unit_price) : ''
|
cells.push(r.description || '', r.unit_price != null ? String(r.unit_price) : '');
|
||||||
])),
|
return cells;
|
||||||
columnTypes: ['qty', 'pn', 'description', 'price'],
|
}),
|
||||||
|
columnTypes: hasPNCode
|
||||||
|
? ['qty', 'pn', 'pn_code', 'description', 'price']
|
||||||
|
: ['qty', 'pn', 'description', 'price'],
|
||||||
ignoredRows: {},
|
ignoredRows: {},
|
||||||
rowErrors: {},
|
rowErrors: {},
|
||||||
uiError: ''
|
uiError: ''
|
||||||
@@ -4659,12 +4716,13 @@ async function renderPricingTab() {
|
|||||||
// ─── Populate Buy table ──────────────────────────────────────────────────
|
// ─── Populate Buy table ──────────────────────────────────────────────────
|
||||||
tbodyBuy.innerHTML = '';
|
tbodyBuy.innerHTML = '';
|
||||||
if (!rowData.length) {
|
if (!rowData.length) {
|
||||||
tbodyBuy.innerHTML = '<tr><td colspan="8" class="px-3 py-8 text-center text-gray-400">Нет данных для отображения</td></tr>';
|
tbodyBuy.innerHTML = '<tr><td colspan="8" class="px-2 py-8 text-center text-gray-400">Нет данных для отображения</td></tr>';
|
||||||
tfootBuy.classList.add('hidden');
|
tfootBuy.classList.add('hidden');
|
||||||
} else {
|
} else {
|
||||||
let totEst = 0, totWh = 0, totComp = 0, totVendor = 0;
|
let totEst = 0, totWh = 0, totComp = 0, totVendor = 0;
|
||||||
let hasEst = false, hasWh = false, hasComp = false, hasVendor = false;
|
let hasEst = false, hasWh = false, hasComp = false, hasVendor = false;
|
||||||
let cntWh = 0, cntComp = 0;
|
let cntWh = 0, cntComp = 0;
|
||||||
|
let worldEst = 0, worldWh = 0, worldComp = 0;
|
||||||
rowData.forEach(r => {
|
rowData.forEach(r => {
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
tr.classList.add('pricing-row-buy');
|
tr.classList.add('pricing-row-buy');
|
||||||
@@ -4681,43 +4739,44 @@ async function renderPricingTab() {
|
|||||||
tr.dataset.vendorPn = r.vendorPN || '';
|
tr.dataset.vendorPn = r.vendorPN || '';
|
||||||
tr.dataset.desc = r.desc;
|
tr.dataset.desc = r.desc;
|
||||||
tr.dataset.lot = r.lotText;
|
tr.dataset.lot = r.lotText;
|
||||||
if (r.est > 0) { totEst += r.est; hasEst = true; }
|
if (r.est > 0) { totEst += r.est; hasEst = true; if (r.estWorld) worldEst += r.est; }
|
||||||
if (r.warehouse != null) { totWh += r.warehouse; hasWh = true; cntWh++; }
|
if (r.warehouse != null) { totWh += r.warehouse; hasWh = true; cntWh++; if (r.whWorld) worldWh += r.warehouse; }
|
||||||
if (r.competitor != null) { totComp += r.competitor; hasComp = true; cntComp++; }
|
if (r.competitor != null) { totComp += r.competitor; hasComp = true; cntComp++; if (r.compWorld) worldComp += r.competitor; }
|
||||||
if (r.vendorOrig != null) { totVendor += r.vendorOrig; hasVendor = true; }
|
if (r.vendorOrig != null) { totVendor += r.vendorOrig; hasVendor = true; }
|
||||||
const borderTop = r.groupStart ? 'border-t border-gray-200' : '';
|
const borderTop = r.groupStart ? 'border-t border-gray-200' : '';
|
||||||
const pnDescHtml = r.groupStart ? (() => {
|
const pnDescHtml = r.groupStart ? (() => {
|
||||||
const rs = r.groupSize > 1 ? ` rowspan="${r.groupSize}"` : '';
|
const rs = r.groupSize > 1 ? ` rowspan="${r.groupSize}"` : '';
|
||||||
return `<td${rs} class="px-3 py-1.5 font-mono text-xs border-t border-gray-200 align-top ${r.vendorPN == null ? 'text-gray-400' : ''}">${r.vendorPN != null ? escapeHtml(r.vendorPN) : '—'}</td>
|
return `<td${rs} class="px-2 py-1.5 font-mono text-xs border-t border-gray-200 align-top whitespace-nowrap ${r.vendorPN == null ? 'text-gray-400' : ''}">${r.vendorPN != null ? escapeHtml(r.vendorPN) : '—'}</td>
|
||||||
<td${rs} class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs border-t border-gray-200 align-top">${escapeHtml(r.desc)}</td>`;
|
<td${rs} class="px-2 py-1.5 text-xs text-gray-500 truncate max-w-xs border-t border-gray-200 align-top">${escapeHtml(r.desc)}</td>`;
|
||||||
})() : '';
|
})() : '';
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
${pnDescHtml}
|
${pnDescHtml}
|
||||||
<td class="px-3 py-1.5 text-xs ${borderTop}">${r.lotCell}</td>
|
<td class="px-2 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-2 py-1.5 text-right text-xs ${borderTop}">${r.qty}</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-2 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-2 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>
|
<td class="px-2 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-2 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);
|
||||||
});
|
});
|
||||||
document.getElementById('pricing-total-buy-estimate').textContent = hasEst ? formatCurrency(totEst) : '—';
|
document.getElementById('pricing-total-buy-vendor').textContent = hasVendor ? formatCurrency(totVendor) : '—';
|
||||||
document.getElementById('pricing-total-buy-vendor').textContent = hasVendor ? formatCurrency(totVendor) : '—';
|
_setPricingTotal('pricing-total-buy-estimate', hasEst, totEst, worldEst, rowData.length, rowData.length);
|
||||||
_setPartialTotal('pricing-total-buy-warehouse', hasWh, totWh, cntWh, rowData.length);
|
_setPricingTotal('pricing-total-buy-warehouse', hasWh, totWh, worldWh, cntWh, rowData.length);
|
||||||
_setPartialTotal('pricing-total-buy-competitor', hasComp, totComp, cntComp, rowData.length);
|
_setPricingTotal('pricing-total-buy-competitor', hasComp, totComp, worldComp, cntComp, rowData.length);
|
||||||
tfootBuy.classList.remove('hidden');
|
tfootBuy.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
// ─── Populate Sale table ─────────────────────────────────────────────────
|
// ─── Populate Sale table ─────────────────────────────────────────────────
|
||||||
tbodySale.innerHTML = '';
|
tbodySale.innerHTML = '';
|
||||||
if (!rowData.length) {
|
if (!rowData.length) {
|
||||||
tbodySale.innerHTML = '<tr><td colspan="8" class="px-3 py-8 text-center text-gray-400">Нет данных для отображения</td></tr>';
|
tbodySale.innerHTML = '<tr><td colspan="8" class="px-2 py-8 text-center text-gray-400">Нет данных для отображения</td></tr>';
|
||||||
tfootSale.classList.add('hidden');
|
tfootSale.classList.add('hidden');
|
||||||
} else {
|
} else {
|
||||||
let totEst = 0, totWh = 0, totComp = 0;
|
let totEst = 0, totWh = 0, totComp = 0;
|
||||||
let hasEst = false, hasWh = false, hasComp = false;
|
let hasEst = false, hasWh = false, hasComp = false;
|
||||||
let cntWh = 0, cntComp = 0;
|
let cntWh = 0, cntComp = 0;
|
||||||
|
let worldEst = 0, worldWh = 0, worldComp = 0;
|
||||||
rowData.forEach(r => {
|
rowData.forEach(r => {
|
||||||
const tr = document.createElement('tr');
|
const tr = document.createElement('tr');
|
||||||
tr.classList.add('pricing-row-sale');
|
tr.classList.add('pricing-row-sale');
|
||||||
@@ -4738,30 +4797,30 @@ async function renderPricingTab() {
|
|||||||
tr.dataset.vendorPn = r.vendorPN || '';
|
tr.dataset.vendorPn = r.vendorPN || '';
|
||||||
tr.dataset.desc = r.desc;
|
tr.dataset.desc = r.desc;
|
||||||
tr.dataset.lot = r.lotText;
|
tr.dataset.lot = r.lotText;
|
||||||
if (saleEstTotal > 0) { totEst += saleEstTotal; hasEst = true; }
|
if (saleEstTotal > 0) { totEst += saleEstTotal; hasEst = true; if (r.estWorld) worldEst += saleEstTotal; }
|
||||||
if (saleWhTotal != null) { totWh += saleWhTotal; hasWh = true; cntWh++; }
|
if (saleWhTotal != null) { totWh += saleWhTotal; hasWh = true; cntWh++; if (r.whWorld) worldWh += saleWhTotal; }
|
||||||
if (saleCompTotal != null) { totComp += saleCompTotal; hasComp = true; cntComp++; }
|
if (saleCompTotal != null) { totComp += saleCompTotal; hasComp = true; cntComp++; if (r.compWorld) worldComp += saleCompTotal; }
|
||||||
const borderTop = r.groupStart ? 'border-t border-gray-200' : '';
|
const borderTop = r.groupStart ? 'border-t border-gray-200' : '';
|
||||||
const pnDescHtml = r.groupStart ? (() => {
|
const pnDescHtml = r.groupStart ? (() => {
|
||||||
const rs = r.groupSize > 1 ? ` rowspan="${r.groupSize}"` : '';
|
const rs = r.groupSize > 1 ? ` rowspan="${r.groupSize}"` : '';
|
||||||
return `<td${rs} class="px-3 py-1.5 font-mono text-xs border-t border-gray-200 align-top ${r.vendorPN == null ? 'text-gray-400' : ''}">${r.vendorPN != null ? escapeHtml(r.vendorPN) : '—'}</td>
|
return `<td${rs} class="px-2 py-1.5 font-mono text-xs border-t border-gray-200 align-top whitespace-nowrap ${r.vendorPN == null ? 'text-gray-400' : ''}">${r.vendorPN != null ? escapeHtml(r.vendorPN) : '—'}</td>
|
||||||
<td${rs} class="px-3 py-1.5 text-xs text-gray-500 truncate max-w-xs border-t border-gray-200 align-top">${escapeHtml(r.desc)}</td>`;
|
<td${rs} class="px-2 py-1.5 text-xs text-gray-500 truncate max-w-xs border-t border-gray-200 align-top">${escapeHtml(r.desc)}</td>`;
|
||||||
})() : '';
|
})() : '';
|
||||||
tr.innerHTML = `
|
tr.innerHTML = `
|
||||||
${pnDescHtml}
|
${pnDescHtml}
|
||||||
<td class="px-3 py-1.5 text-xs ${borderTop}">${r.lotCell}</td>
|
<td class="px-2 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-2 py-1.5 text-right text-xs ${borderTop}">${r.qty}</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-2 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} ${r.whWorld ? WORLD_CLS : ''}">${saleWhUnit != null ? formatCurrency(saleWhUnit) : '—'}</td>
|
<td class="px-2 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} ${r.compWorld ? WORLD_CLS : ''}">${saleCompUnit != null ? formatCurrency(saleCompUnit) : '—'}</td>
|
<td class="px-2 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-2 py-1.5 text-right text-xs pricing-vendor-price-sale ${borderTop} text-gray-400">—</td>
|
||||||
`;
|
`;
|
||||||
tbodySale.appendChild(tr);
|
tbodySale.appendChild(tr);
|
||||||
});
|
});
|
||||||
document.getElementById('pricing-total-sale-estimate').textContent = hasEst ? formatCurrency(totEst) : '—';
|
document.getElementById('pricing-total-sale-vendor').textContent = '—';
|
||||||
document.getElementById('pricing-total-sale-vendor').textContent = '—';
|
_setPricingTotal('pricing-total-sale-estimate', hasEst, totEst, worldEst, rowData.length, rowData.length);
|
||||||
_setPartialTotal('pricing-total-sale-warehouse', hasWh, totWh, cntWh, rowData.length);
|
_setPricingTotal('pricing-total-sale-warehouse', hasWh, totWh, worldWh, cntWh, rowData.length);
|
||||||
_setPartialTotal('pricing-total-sale-competitor', hasComp, totComp, cntComp, rowData.length);
|
_setPricingTotal('pricing-total-sale-competitor', hasComp, totComp, worldComp, cntComp, rowData.length);
|
||||||
tfootSale.classList.remove('hidden');
|
tfootSale.classList.remove('hidden');
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -4772,17 +4831,41 @@ async function renderPricingTab() {
|
|||||||
|
|
||||||
// ─── Pricing helpers ─────────────────────────────────────────────────────────
|
// ─── Pricing helpers ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
// Sets a footer total cell. If has prices but coverage < totalRows, marks red with a hover asterisk.
|
// Sets a footer total cell. The total is always red — it is a sum the user must not read
|
||||||
function _setPartialTotal(elId, has, total, count, totalRows) {
|
// as a firm number — and hovering it pops up how much of that sum rests on world-pricelist
|
||||||
|
// stand-in prices (see decisions/2026-07-10-world-pricelist-fallback.md), plus the row
|
||||||
|
// coverage when some positions have no price at all. The popup is a styled element rather
|
||||||
|
// than title=, which appears too late to notice. No asterisk: it wrapped onto its own line
|
||||||
|
// and pushed the footer around.
|
||||||
|
function _setPricingTotal(elId, has, total, worldTotal, count, totalRows) {
|
||||||
const el = document.getElementById(elId);
|
const el = document.getElementById(elId);
|
||||||
if (!el) return;
|
if (!el) return;
|
||||||
el.className = el.className.replace(/\btext-red-\d+\b/g, '').trim();
|
el.className = el.className
|
||||||
if (!has) { el.textContent = '—'; return; }
|
.replace(/\btext-red-\d+\b/g, '')
|
||||||
if (count < totalRows) {
|
.replace(/\bpricing-total-tip\b/g, '')
|
||||||
el.innerHTML = `<span class="text-red-600">${formatCurrency(total)}</span> <span class="text-red-400 cursor-help" title="Цены указаны не для всех позиций (${count} из ${totalRows})">*</span>`;
|
.trim();
|
||||||
} else {
|
if (!has) {
|
||||||
el.textContent = formatCurrency(total);
|
el.textContent = '—';
|
||||||
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const share = total > 0 ? (worldTotal / total) * 100 : 0;
|
||||||
|
const parts = [
|
||||||
|
share > 0
|
||||||
|
? `<b>${formatPercent(share)}%</b> от этой суммы посчитано по ценам прайслиста WORLD — это заглушка, а не реальная цена`
|
||||||
|
: 'Цены прайслиста WORLD в этой сумме не использованы'
|
||||||
|
];
|
||||||
|
if (count < totalRows) {
|
||||||
|
parts.push(`Цены есть не для всех позиций: ${count} из ${totalRows}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
el.className = `${el.className} text-red-600 pricing-total-tip`.trim();
|
||||||
|
el.innerHTML = `${formatCurrency(total)}<span class="pricing-total-tip__body">${parts.join('<br>')}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// One decimal, comma separator, no trailing ",0".
|
||||||
|
function formatPercent(v) {
|
||||||
|
return v.toFixed(1).replace(/\.0$/, '').replace('.', ',');
|
||||||
}
|
}
|
||||||
|
|
||||||
function parseDecimalInput(raw) {
|
function parseDecimalInput(raw) {
|
||||||
|
|||||||
Reference in New Issue
Block a user