feat: price_quality в конфигураторе; фикс пустого lot_description в экспорте цен
price_quality теперь синхронизируется не только в прайслист, но и в LocalComponent/services.ComponentView (/api/components) — виден в выпадающем списке поиска (цветная точка), в таблице конфигуратора (левая колонка-пиктограмма) и на вкладке «Ценообразование» (подсветка строки). Цветовая шкала вынесена в один модуль web/static/price-quality.js (подключён в base.html), чтобы не дублировать расчёт цвета по шаблонам. Шкала — градиент 0 (красный) → 5 (жёлтый) → 9 (зелёный). Экспорт цен (internal/services/export.go): resolveLotDescriptions был заглушкой, всегда возвращавшей пустую карту — строки BOM/не-BOM в экспорте всегда оставались без описания LOT. Заменён на реальный запрос к local_pricelist_items текущего выбранного прайслиста (LocalDB.GetLocalDescriptionsForLots), по аналогии с уже существующим GetLocalLotCategoriesByServerPricelistID. Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Sonnet 5
parent
3348008198
commit
ee92c3b392
@@ -26,7 +26,7 @@ Rules:
|
|||||||
- user-authored tables must not be dropped as a recovery shortcut;
|
- user-authored tables must not be dropped as a recovery shortcut;
|
||||||
- `local_pricelist_items` is the only valid runtime source of prices and component catalog; do not add a separate component cache table;
|
- `local_pricelist_items` is the only valid runtime source of prices and component catalog; do not add a separate component cache table;
|
||||||
- `local_pricelist_items.lot_category` is the single source of a LOT's category at runtime (populated by sync from `qt_pricelist_items.lot_category`); do not derive category from a lot_name prefix or from `qt_categories`/`qt_lot_metadata`;
|
- `local_pricelist_items.lot_category` is the single source of a LOT's category at runtime (populated by sync from `qt_pricelist_items.lot_category`); do not derive category from a lot_name prefix or from `qt_categories`/`qt_lot_metadata`;
|
||||||
- `local_pricelist_items.lot_description` is the single source of a LOT's description at runtime (populated by sync from `qt_pricelist_items.lot_description`); do not join the legacy `lot` table for it;
|
- `local_pricelist_items.lot_description` is the single source of a LOT's description at runtime (populated by sync from `qt_pricelist_items.lot_description`); do not join the legacy `lot` table for it. Every read path resolves it through the currently selected pricelist (active estimate, or the configuration's own `PricelistID` where applicable) — see `LocalDB.GetLocalComponent`/`ListComponents` (component views, quote line descriptions) and `LocalDB.GetLocalDescriptionsForLots` (export pricing rows in `internal/services/export.go`). Do not add a second description source (e.g. `VendorSpecItem.Description` from BOM import is a distinct, separate field — it's only ever used as a display fallback in front of the LOT description, never merged back into it);
|
||||||
- configuration `items` and `vendor_spec` are stored as JSON payloads inside configuration rows;
|
- configuration `items` and `vendor_spec` are stored as JSON payloads inside configuration rows;
|
||||||
- `local_components` table has been removed; any reference to it is dead code.
|
- `local_components` table has been removed; any reference to it is dead code.
|
||||||
|
|
||||||
@@ -259,7 +259,9 @@ PK: lot_name
|
|||||||
| lot_category | varchar(50) | |
|
| lot_category | varchar(50) | |
|
||||||
| lot_description | varchar(10000) | added by migration 032; backfilled once from `lot.lot_description`. QF syncs this column directly — do not join `lot` per-row to get a description |
|
| lot_description | varchar(10000) | added by migration 032; backfilled once from `lot.lot_description`. QF syncs this column directly — do not join `lot` per-row to get a description |
|
||||||
| 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. UI: 7-9 = no warning, 4-6 = amber "!", 0-3 = red "!" (`formatPriceQualityWarning` in `pricelist_detail.html`) |
|
| 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).
|
||||||
|
|
||||||
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.
|
||||||
|
|
||||||
|
|||||||
@@ -51,6 +51,7 @@ func (h *ComponentHandler) List(c *gin.Context) {
|
|||||||
Category: lc.Category,
|
Category: lc.Category,
|
||||||
CategoryName: lc.Category,
|
CategoryName: lc.Category,
|
||||||
Model: lc.Model,
|
Model: lc.Model,
|
||||||
|
PriceQuality: lc.PriceQuality,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -81,6 +82,7 @@ func (h *ComponentHandler) Get(c *gin.Context) {
|
|||||||
Category: component.Category,
|
Category: component.Category,
|
||||||
CategoryName: component.Category,
|
CategoryName: component.Category,
|
||||||
Model: component.Model,
|
Model: component.Model,
|
||||||
|
PriceQuality: component.PriceQuality,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -45,6 +45,7 @@ type pricelistItemRow struct {
|
|||||||
LotName string `gorm:"column:lot_name"`
|
LotName string `gorm:"column:lot_name"`
|
||||||
Category string `gorm:"column:lot_category"`
|
Category string `gorm:"column:lot_category"`
|
||||||
Description string `gorm:"column:lot_description"`
|
Description string `gorm:"column:lot_description"`
|
||||||
|
PriceQuality *int `gorm:"column:price_quality"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (r pricelistItemRow) toLocalComponent() LocalComponent {
|
func (r pricelistItemRow) toLocalComponent() LocalComponent {
|
||||||
@@ -52,6 +53,7 @@ func (r pricelistItemRow) toLocalComponent() LocalComponent {
|
|||||||
LotName: r.LotName,
|
LotName: r.LotName,
|
||||||
Category: r.Category,
|
Category: r.Category,
|
||||||
LotDescription: r.Description,
|
LotDescription: r.Description,
|
||||||
|
PriceQuality: r.PriceQuality,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -73,7 +75,7 @@ func (l *LocalDB) SearchLocalComponents(query string, limit int) ([]LocalCompone
|
|||||||
}
|
}
|
||||||
|
|
||||||
var rows []pricelistItemRow
|
var rows []pricelistItemRow
|
||||||
if err := db.Select("lot_name, lot_category, lot_description").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))
|
||||||
@@ -101,7 +103,7 @@ func (l *LocalDB) SearchLocalComponentsByCategory(category, query string, limit
|
|||||||
}
|
}
|
||||||
|
|
||||||
var rows []pricelistItemRow
|
var rows []pricelistItemRow
|
||||||
if err := db.Select("lot_name, lot_category, lot_description").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))
|
||||||
@@ -135,7 +137,7 @@ func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]L
|
|||||||
}
|
}
|
||||||
|
|
||||||
var rows []pricelistItemRow
|
var rows []pricelistItemRow
|
||||||
if err := db.Select("lot_name, lot_category, lot_description").Order("lot_name").Offset(offset).Limit(limit).Scan(&rows).Error; err != nil {
|
if err := db.Select("lot_name, lot_category, lot_description, price_quality").Order("lot_name").Offset(offset).Limit(limit).Scan(&rows).Error; err != nil {
|
||||||
return nil, 0, err
|
return nil, 0, err
|
||||||
}
|
}
|
||||||
components := make([]LocalComponent, len(rows))
|
components := make([]LocalComponent, len(rows))
|
||||||
@@ -155,7 +157,7 @@ func (l *LocalDB) GetLocalComponent(lotName string) (*LocalComponent, error) {
|
|||||||
|
|
||||||
var row pricelistItemRow
|
var row pricelistItemRow
|
||||||
if err := l.db.Table("local_pricelist_items").
|
if err := l.db.Table("local_pricelist_items").
|
||||||
Select("lot_name, lot_category, lot_description").
|
Select("lot_name, lot_category, lot_description, price_quality").
|
||||||
Where("pricelist_id = ? AND UPPER(lot_name) = ?", pricelistID, strings.ToUpper(lotName)).
|
Where("pricelist_id = ? AND UPPER(lot_name) = ?", pricelistID, strings.ToUpper(lotName)).
|
||||||
First(&row).Error; err != nil {
|
First(&row).Error; err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
|
|||||||
@@ -1630,6 +1630,51 @@ func (l *LocalDB) GetLocalLotCategoriesByServerPricelistID(serverPricelistID uin
|
|||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// GetLocalDescriptionsForLots returns lot_description for each lot_name from a local
|
||||||
|
// pricelist resolved by server ID. Missing lots are not included in the map.
|
||||||
|
func (l *LocalDB) GetLocalDescriptionsForLots(serverPricelistID uint, lotNames []string) (map[string]string, error) {
|
||||||
|
result := make(map[string]string, len(lotNames))
|
||||||
|
if serverPricelistID == 0 || len(lotNames) == 0 {
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
localPL, err := l.GetLocalPricelistByServerID(serverPricelistID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
type row struct {
|
||||||
|
LotName string `gorm:"column:lot_name"`
|
||||||
|
LotDescription string `gorm:"column:lot_description"`
|
||||||
|
}
|
||||||
|
// Build uppercase → original mapping so result keys match what the caller passed.
|
||||||
|
upperToOrig := make(map[string]string, len(lotNames))
|
||||||
|
upper := make([]string, len(lotNames))
|
||||||
|
for i, n := range lotNames {
|
||||||
|
u := strings.ToUpper(n)
|
||||||
|
upper[i] = u
|
||||||
|
upperToOrig[u] = n
|
||||||
|
}
|
||||||
|
var rows []row
|
||||||
|
if err := l.db.Model(&LocalPricelistItem{}).
|
||||||
|
Select("lot_name, lot_description").
|
||||||
|
Where("pricelist_id = ? AND UPPER(lot_name) IN ?", localPL.ID, upper).
|
||||||
|
Find(&rows).Error; err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
for _, r := range rows {
|
||||||
|
if r.LotDescription == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
orig := upperToOrig[strings.ToUpper(r.LotName)]
|
||||||
|
if orig == "" {
|
||||||
|
orig = r.LotName
|
||||||
|
}
|
||||||
|
result[orig] = r.LotDescription
|
||||||
|
}
|
||||||
|
return result, nil
|
||||||
|
}
|
||||||
|
|
||||||
// MarkPricelistAsUsed marks a pricelist as used by a configuration
|
// MarkPricelistAsUsed marks a pricelist as used by a configuration
|
||||||
func (l *LocalDB) MarkPricelistAsUsed(pricelistID uint, isUsed bool) error {
|
func (l *LocalDB) MarkPricelistAsUsed(pricelistID uint, isUsed bool) error {
|
||||||
return l.db.Model(&LocalPricelist{}).Where("id = ?", pricelistID).
|
return l.db.Model(&LocalPricelist{}).Where("id = ?", pricelistID).
|
||||||
|
|||||||
@@ -213,6 +213,7 @@ type LocalComponent struct {
|
|||||||
LotDescription string `json:"lot_description"`
|
LotDescription string `json:"lot_description"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
|
PriceQuality *int `json:"price_quality,omitempty"`
|
||||||
}
|
}
|
||||||
|
|
||||||
func (LocalComponent) TableName() string {
|
func (LocalComponent) TableName() string {
|
||||||
|
|||||||
@@ -14,4 +14,5 @@ type ComponentView struct {
|
|||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
CategoryName string `json:"category_name"`
|
CategoryName string `json:"category_name"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
|
PriceQuality *int `json:"price_quality,omitempty"`
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -687,8 +687,35 @@ func (s *ExportService) batchLookupPrices(serverPricelistID *uint, lots []string
|
|||||||
return prices
|
return prices
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *ExportService) resolveLotDescriptions(_ *models.Configuration, _ *localdb.LocalConfiguration) map[string]string {
|
// resolveLotDescriptions returns each LOT's description from the configuration's
|
||||||
|
// currently selected estimate pricelist (falling back to the latest active one),
|
||||||
|
// mirroring the pricelist resolution in resolvePricingTotals. This is the single
|
||||||
|
// source of LOT description across the project — see bible-local/03-database.md.
|
||||||
|
func (s *ExportService) resolveLotDescriptions(cfg *models.Configuration, localCfg *localdb.LocalConfiguration) map[string]string {
|
||||||
|
if s.localDB == nil {
|
||||||
return map[string]string{}
|
return map[string]string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
lots := collectPricingLots(cfg, localCfg, true)
|
||||||
|
if len(lots) == 0 {
|
||||||
|
return map[string]string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
estimateID := cfg.PricelistID
|
||||||
|
if estimateID == nil || *estimateID == 0 {
|
||||||
|
if latest, err := s.localDB.GetLatestLocalPricelistBySource("estimate"); err == nil && latest != nil {
|
||||||
|
estimateID = &latest.ServerID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if estimateID == nil || *estimateID == 0 {
|
||||||
|
return map[string]string{}
|
||||||
|
}
|
||||||
|
|
||||||
|
descriptions, err := s.localDB.GetLocalDescriptionsForLots(*estimateID, lots)
|
||||||
|
if err != nil {
|
||||||
|
return map[string]string{}
|
||||||
|
}
|
||||||
|
return descriptions
|
||||||
}
|
}
|
||||||
|
|
||||||
func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, includeBOM bool) []string {
|
func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfiguration, includeBOM bool) []string {
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
// Shared LOT price-quality color scale, used everywhere price_quality is
|
||||||
|
// displayed (pricelist detail page, configurator search dropdown, configurator
|
||||||
|
// table, pricing tab). price_quality is a 0-9 score set by the external
|
||||||
|
// pricing tool (see bible-local/03-database.md, qt_pricelist_items.price_quality)
|
||||||
|
// — QF only displays it, never computes it. Single source of the color scale
|
||||||
|
// so every view stays visually consistent.
|
||||||
|
//
|
||||||
|
// Gradient: 0 = red, 5 = yellow, 9 = green.
|
||||||
|
(function () {
|
||||||
|
const RED = [220, 38, 38];
|
||||||
|
const YELLOW = [234, 179, 8];
|
||||||
|
const GREEN = [22, 163, 74];
|
||||||
|
|
||||||
|
function lerp(c1, c2, t) {
|
||||||
|
return c1.map((v, i) => Math.round(v + (c2[i] - v) * t));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Returns "r, g, b" (no rgb() wrapper) so callers can build rgb()/rgba() themselves.
|
||||||
|
function priceQualityRgb(quality) {
|
||||||
|
if (typeof quality !== 'number' || Number.isNaN(quality)) return null;
|
||||||
|
const q = Math.max(0, Math.min(9, quality));
|
||||||
|
const [r, g, b] = q <= 5 ? lerp(RED, YELLOW, q / 5) : lerp(YELLOW, GREEN, (q - 5) / 4);
|
||||||
|
return `${r}, ${g}, ${b}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function priceQualityColor(quality) {
|
||||||
|
const rgb = priceQualityRgb(quality);
|
||||||
|
return rgb ? `rgb(${rgb})` : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Small colored dot for compact contexts (search dropdown, configurator table cell).
|
||||||
|
function qualityDotHtml(quality) {
|
||||||
|
const color = priceQualityColor(quality);
|
||||||
|
if (!color) return '';
|
||||||
|
return `<span class="inline-block w-2 h-2 rounded-full" style="background-color: ${color}" title="Качество цены: ${quality}/9"></span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Colored number badge for a dedicated "quality" column/cell.
|
||||||
|
function qualityBadgeHtml(quality) {
|
||||||
|
if (typeof quality !== 'number') return '<span class="text-gray-400">-</span>';
|
||||||
|
const color = priceQualityColor(quality);
|
||||||
|
return `<span class="font-semibold" style="color: ${color}" title="Качество цены: ${quality}/9">${quality}</span>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Light row-tint background for table rows, e.g. the pricing tab.
|
||||||
|
function qualityRowStyle(quality) {
|
||||||
|
const rgb = priceQualityRgb(quality);
|
||||||
|
return rgb ? `background-color: rgba(${rgb}, 0.10)` : '';
|
||||||
|
}
|
||||||
|
|
||||||
|
window.priceQualityColor = priceQualityColor;
|
||||||
|
window.qualityDotHtml = qualityDotHtml;
|
||||||
|
window.qualityBadgeHtml = qualityBadgeHtml;
|
||||||
|
window.qualityRowStyle = qualityRowStyle;
|
||||||
|
})();
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
<link rel="stylesheet" href="/static/app.css">
|
<link rel="stylesheet" href="/static/app.css">
|
||||||
<script src="/static/vendor/tailwindcss.browser.js"></script>
|
<script src="/static/vendor/tailwindcss.browser.js"></script>
|
||||||
<script src="/static/vendor/htmx-1.9.10.min.js"></script>
|
<script src="/static/vendor/htmx-1.9.10.min.js"></script>
|
||||||
|
<script src="/static/price-quality.js"></script>
|
||||||
<style>
|
<style>
|
||||||
.htmx-request { opacity: 0.5; }
|
.htmx-request { opacity: 0.5; }
|
||||||
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
.line-clamp-2 { display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical; overflow: hidden; }
|
||||||
|
|||||||
@@ -1483,6 +1483,7 @@ function renderSingleSelectTab(categories) {
|
|||||||
<table class="w-full">
|
<table class="w-full">
|
||||||
<thead class="bg-gray-50">
|
<thead class="bg-gray-50">
|
||||||
<tr>
|
<tr>
|
||||||
|
<th class="px-2 py-2 w-6" title="Качество цены"></th>
|
||||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase w-24">Тип</th>
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase w-24">Тип</th>
|
||||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
|
||||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
|
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
|
||||||
@@ -1509,6 +1510,7 @@ function renderSingleSelectTab(categories) {
|
|||||||
|
|
||||||
html += `
|
html += `
|
||||||
<tr class="hover:bg-gray-50">
|
<tr class="hover:bg-gray-50">
|
||||||
|
<td class="px-2 py-2 text-center">${qualityDotHtml(comp?.price_quality)}</td>
|
||||||
<td class="px-3 py-2 text-sm font-medium text-gray-700">${catLabel}</td>
|
<td class="px-3 py-2 text-sm font-medium text-gray-700">${catLabel}</td>
|
||||||
<td class="px-3 py-2">
|
<td class="px-3 py-2">
|
||||||
<div class="autocomplete-wrapper relative">
|
<div class="autocomplete-wrapper relative">
|
||||||
@@ -1859,7 +1861,7 @@ function renderAutocomplete() {
|
|||||||
return `
|
return `
|
||||||
<div class="autocomplete-item ${idx === autocompleteIndex ? 'selected' : ''}"
|
<div class="autocomplete-item ${idx === autocompleteIndex ? 'selected' : ''}"
|
||||||
onmousedown="${onmousedown}">
|
onmousedown="${onmousedown}">
|
||||||
<div class="font-mono text-sm">${escapeHtml(comp.lot_name)}</div>
|
<div class="font-mono text-sm flex items-center gap-1.5">${qualityDotHtml(comp.price_quality)}${escapeHtml(comp.lot_name)}</div>
|
||||||
<div class="text-xs text-gray-500 truncate">${escapeHtml(comp.description || '')}</div>
|
<div class="text-xs text-gray-500 truncate">${escapeHtml(comp.description || '')}</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
@@ -4539,6 +4541,7 @@ async function renderPricingTab() {
|
|||||||
lotCell: escapeHtml(item.lot_name), lotText: item.lot_name,
|
lotCell: escapeHtml(item.lot_name), lotText: item.lot_name,
|
||||||
vendorPN: null,
|
vendorPN: null,
|
||||||
desc: (compMap[U(item.lot_name)] || {}).description || '',
|
desc: (compMap[U(item.lot_name)] || {}).description || '',
|
||||||
|
priceQuality: (compMap[U(item.lot_name)] || {}).price_quality,
|
||||||
qty: item.quantity,
|
qty: item.quantity,
|
||||||
estUnit, warehouseUnit: u.warehouseUnit, competitorUnit: u.competitorUnit,
|
estUnit, warehouseUnit: u.warehouseUnit, competitorUnit: u.competitorUnit,
|
||||||
est: estUnit * item.quantity,
|
est: estUnit * item.quantity,
|
||||||
@@ -4586,6 +4589,7 @@ async function renderPricingTab() {
|
|||||||
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,
|
estWorld: u.estWorld, whWorld: u.whWorld, compWorld: u.compWorld,
|
||||||
|
priceQuality: (compMap[U(baseLot)] || {}).price_quality,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
allocs.forEach(a => {
|
allocs.forEach(a => {
|
||||||
@@ -4599,6 +4603,7 @@ async function renderPricingTab() {
|
|||||||
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,
|
estWorld: u.estWorld, whWorld: u.whWorld, compWorld: u.compWorld,
|
||||||
|
priceQuality: (compMap[U(a.lot_name)] || {}).price_quality,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -4611,6 +4616,7 @@ async function renderPricingTab() {
|
|||||||
vendorOrig, vendorOrigUnit, isEstOnly: false,
|
vendorOrig, vendorOrigUnit, isEstOnly: false,
|
||||||
groupStart: true, groupSize: 1,
|
groupStart: true, groupSize: 1,
|
||||||
estWorld: false, whWorld: false, compWorld: false,
|
estWorld: false, whWorld: false, compWorld: false,
|
||||||
|
priceQuality: null,
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -4629,6 +4635,7 @@ async function renderPricingTab() {
|
|||||||
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,
|
estWorld: sub.estWorld, whWorld: sub.whWorld, compWorld: sub.compWorld,
|
||||||
|
priceQuality: sub.priceQuality,
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
@@ -4657,7 +4664,11 @@ async function renderPricingTab() {
|
|||||||
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');
|
||||||
if (r.isEstOnly) tr.classList.add('bg-blue-50');
|
if (typeof r.priceQuality === 'number') {
|
||||||
|
tr.setAttribute('style', qualityRowStyle(r.priceQuality));
|
||||||
|
} else if (r.isEstOnly) {
|
||||||
|
tr.classList.add('bg-blue-50');
|
||||||
|
}
|
||||||
tr.dataset.est = r.est;
|
tr.dataset.est = r.est;
|
||||||
tr.dataset.qty = r.qty;
|
tr.dataset.qty = r.qty;
|
||||||
tr.dataset.vendorOrig = r.vendorOrig != null ? r.vendorOrig : '';
|
tr.dataset.vendorOrig = r.vendorOrig != null ? r.vendorOrig : '';
|
||||||
@@ -4706,7 +4717,11 @@ async function renderPricingTab() {
|
|||||||
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');
|
||||||
if (r.isEstOnly) tr.classList.add('bg-blue-50');
|
if (typeof r.priceQuality === 'number') {
|
||||||
|
tr.setAttribute('style', qualityRowStyle(r.priceQuality));
|
||||||
|
} else if (r.isEstOnly) {
|
||||||
|
tr.classList.add('bg-blue-50');
|
||||||
|
}
|
||||||
const saleEstUnit = r.estUnit > 0 ? r.estUnit * saleUplift : 0;
|
const saleEstUnit = r.estUnit > 0 ? r.estUnit * saleUplift : 0;
|
||||||
const saleWhUnit = r.warehouseUnit != null ? r.warehouseUnit * SALE_FIXED_MULT : null;
|
const saleWhUnit = r.warehouseUnit != null ? r.warehouseUnit * SALE_FIXED_MULT : null;
|
||||||
const saleCompUnit = r.competitorUnit != null ? r.competitorUnit * SALE_FIXED_MULT : null;
|
const saleCompUnit = r.competitorUnit != null ? r.competitorUnit * SALE_FIXED_MULT : null;
|
||||||
|
|||||||
@@ -59,6 +59,7 @@
|
|||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Категория</th>
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Категория</th>
|
||||||
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
|
<th class="px-6 py-3 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
|
||||||
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Цена, $</th>
|
<th class="px-6 py-3 text-right text-xs font-medium text-gray-500 uppercase">Цена, $</th>
|
||||||
|
<th class="px-6 py-3 text-center text-xs font-medium text-gray-500 uppercase">Качество</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
<tbody id="items-body" class="bg-white divide-y divide-gray-200">
|
<tbody id="items-body" class="bg-white divide-y divide-gray-200">
|
||||||
@@ -155,14 +156,7 @@
|
|||||||
}
|
}
|
||||||
|
|
||||||
function itemsColspan() {
|
function itemsColspan() {
|
||||||
return 4;
|
return 5;
|
||||||
}
|
|
||||||
|
|
||||||
function formatPriceQualityWarning(quality) {
|
|
||||||
if (typeof quality !== 'number') return '';
|
|
||||||
if (quality >= 7) return '';
|
|
||||||
const color = quality >= 4 ? 'text-amber-500' : 'text-red-600';
|
|
||||||
return ` <span class="${color} font-bold" title="Качество цены: ${quality}/9">!</span>`;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function escapeHtml(text) {
|
function escapeHtml(text) {
|
||||||
@@ -198,12 +192,11 @@
|
|||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Price cell — add spread badge for competitor, plus a price-quality warning
|
// Price cell — add spread badge for competitor
|
||||||
let priceHtml = price;
|
let priceHtml = price;
|
||||||
if (!isWarehouseSource() && item.price_spread_pct > 0) {
|
if (!isWarehouseSource() && item.price_spread_pct > 0) {
|
||||||
priceHtml += ` <span class="text-xs text-amber-600 font-medium" title="Разброс цен конкурентов">±${item.price_spread_pct.toFixed(0)}%</span>`;
|
priceHtml += ` <span class="text-xs text-amber-600 font-medium" title="Разброс цен конкурентов">±${item.price_spread_pct.toFixed(0)}%</span>`;
|
||||||
}
|
}
|
||||||
priceHtml += formatPriceQualityWarning(item.price_quality);
|
|
||||||
|
|
||||||
return `
|
return `
|
||||||
<tr class="hover:bg-gray-50">
|
<tr class="hover:bg-gray-50">
|
||||||
@@ -215,6 +208,7 @@
|
|||||||
</td>
|
</td>
|
||||||
<td class="${p} text-sm text-gray-500" title="${escapeHtml(description)}">${escapeHtml(truncatedDesc)}</td>
|
<td class="${p} text-sm text-gray-500" title="${escapeHtml(description)}">${escapeHtml(truncatedDesc)}</td>
|
||||||
<td class="${p} whitespace-nowrap text-right font-mono">${priceHtml}</td>
|
<td class="${p} whitespace-nowrap text-right font-mono">${priceHtml}</td>
|
||||||
|
<td class="${p} whitespace-nowrap text-center">${qualityBadgeHtml(item.price_quality)}</td>
|
||||||
</tr>
|
</tr>
|
||||||
`;
|
`;
|
||||||
}).join('');
|
}).join('');
|
||||||
|
|||||||
Reference in New Issue
Block a user