Compare commits

...

10 Commits
v2.22 ... v2.25

Author SHA1 Message Date
Mikhail Chusavitin
b23eb1d75a fix: регистронезависимое сопоставление BOM↔корзина (фронт + CSV)
LOT из BOM-маппинга мог быть в смешанном регистре, а корзина — в каноничном
UPPERCASE, из-за чего позиции дублировались (в таблице «Цена покупки» и в
экспорте CSV).

- localdb.NormalizeLotMappings: единая каноничная нормализация LOT-маппингов
  (UPPERCASE + схлопывание дублей с суммированием qty). Убраны две разошедшиеся
  копии normalizeLotMappings (handlers и services — последняя только тримила,
  что и было причиной бага в CSV).
- export.go: BOM-ветка использует общую функцию + канонизирует LOT корзины
  для coverage/lookup. Удалена мёртвая computeMappingTotal.
- index.html (renderPricingTab): сопоставление/дедуп LOT через каноничный ключ
  UPPERCASE; аксессоры _getRowBaseLot/_getRowAllocations возвращают канон.
- Добавлен регресс-тест TestNormalizeLotMappings_*.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-29 08:59:50 +03:00
Mikhail Chusavitin
cc72052c8a refactor: единый источник категории LOT — local_pricelist_items.lot_category
Удалён мёртвый серверный слой управления компонентами/категориями,
который дублировал источники категории LOT. В рантайме категория всегда
берётся из local_pricelist_items.lot_category (наполняется синком из
qt_pricelist_items.lot_category).

Удалено:
- repository: UnifiedRepo/DataSource, ComponentRepository, CategoryRepository
- services: старый ConfigurationService (заменён LocalConfigurationService),
  ComponentService, ComponentToLocal, ImportFromLot, ParsePartNumber
- quote.go: недостижимый online-блок (qt_categories) + componentRepo/
  pricingService/priceResolver

Сохранены живые типы: models.Category/DefaultCategories (для /api/categories),
ComponentView/ComponentListResult, CreateConfigRequest/ArticlePreviewRequest/
ConfigurationGetter/ErrConfig*.

bible-local/03-database.md: зафиксирован единственный источник категории LOT;
qt_categories/qt_lot_metadata перенесены в server-side only.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
2026-06-28 21:21:34 +03:00
Mikhail Chusavitin
687ab99d85 docs: release notes v2.24
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 12:06:22 +03:00
Mikhail Chusavitin
ce7c8551be fix: ReferenceError sectionCategories → section.categories в renderMultiSelectTabWithSections
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 10:49:35 +03:00
Mikhail Chusavitin
3788492089 docs: release notes v2.23
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 10:32:57 +03:00
Mikhail Chusavitin
f7d26a28f8 chore: обновление субмодуля bible
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 10:30:31 +03:00
Mikhail Chusavitin
bb742d2f38 fix: DKC/CTL/ENC попадали в Other при режиме server
ASSIGNED_CATEGORIES пересобирался из отфильтрованного tab.categories,
поэтому категории скрытые для текущего типа конфигурации переставали
считаться «назначенными» и уходили в Other. Теперь используется
tab._allCategories (полный статический список) — категория принадлежит
вкладке независимо от видимости в текущем режиме.

Также убрал лишний .toUpperCase() в updateTabVisibility.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 10:29:32 +03:00
Mikhail Chusavitin
f70cc680f7 fix: конфигуратор зависал на «Загрузка...», infinite retry при sync, UpsertByUUID
1. JS-конфигуратор: при загрузке сохранённой конфигурации item.category
   всегда undefined (в config.items хранится только lot_name/quantity/unit_price).
   Добавлено обогащение cart из allComponents после загрузки, все сравнения
   категорий переведены на ciStr() вместо .toUpperCase(), исправлены все 4
   точки построения ASSIGNED_CATEGORIES — устраняет TypeError и таб «Other»
   показывал компоненты с известными категориями.

2. RepairPendingChanges: repair-функции теперь возвращают (bool, error);
   attempts/last_error сбрасываются только при modified=true — устраняет
   бесконечный retry когда ошибка на стороне сервера, а не локальных данных.

3. UpsertByUUID: сброс project.ID=0 перед INSERT … ON DUPLICATE KEY UPDATE,
   чтобы конфликт шёл по уникальному uuid, а не по PK чужой строки —
   устраняет «record not found» при разрешении изменений проекта.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-26 10:27:29 +03:00
64c9c4e862 docs: bible-local — удаление local_components, правило регистра lot_name, категория из прайслиста 2026-06-26 08:56:32 +03:00
cc91ca10fc docs: release notes v2.22 2026-06-26 08:54:23 +03:00
22 changed files with 287 additions and 1740 deletions

2
bible

Submodule bible updated: 1977730d93...52444350c1

View File

@@ -40,14 +40,25 @@ Readiness guard:
## Pricing contract ## Pricing contract
Prices come only from `local_pricelist_items`. `local_pricelist_items` is the single source of truth for both prices and component catalog (lot_name + lot_category). There is no separate component catalog table.
Rules: Rules:
- `local_components` is metadata-only; - `local_components` table has been removed; do not recreate it;
- quote calculation must not read prices from components; - component list for the configurator autocomplete comes from `local_pricelist_items` via `ListComponents`;
- quote calculation reads prices from `local_pricelist_items` only;
- latest pricelist selection ignores snapshots without items; - latest pricelist selection ignores snapshots without items;
- auto pricelist mode stays auto and must not be persisted as an explicit resolved ID. - auto pricelist mode stays auto and must not be persisted as an explicit resolved ID.
## lot_name case handling
lot_names in `local_pricelist_items` may be stored in mixed case in databases synced before normalization was enforced. `NormalizeLotName` (uppercase + trim) is applied at sync time via `PricelistItemToLocal`, but existing rows are not retroactively updated.
Rules:
- all SQLite queries that filter by `lot_name` must use `UPPER(lot_name) IN ?` or `UPPER(lot_name) = ?` with an uppercased input — never a bare `=` or `IN` on a string that may have been sourced from user input or a legacy row;
- result map keys must preserve the original case passed by the caller (build a `uppercase → original` index before the query);
- `GetLocalPricesForLots` is the canonical pattern: it uppercases the input list, queries with `UPPER(lot_name) IN ?`, and returns keys that match the input lot_names;
- frontend JS must never infer a component category from the lot_name prefix; `lot_category` from `local_pricelist_items` is the only valid source; items without a category fall into the "Other" tab.
## Pricing tab layout ## Pricing tab layout
The Pricing tab (Ценообразование) has two tables: Buy (Цена покупки) and Sale (Цена продажи). The Pricing tab (Ценообразование) has two tables: Buy (Цена покупки) and Sale (Цена продажи).
@@ -133,7 +144,7 @@ full contract and JSON schemas.
| `required_categories` | Per-config-type badge on tabs with unfilled required categories | | `required_categories` | Per-config-type badge on tabs with unfilled required categories |
Rules: Rules:
- sync runs after `SyncComponents`; failure is non-fatal (Warn log only); - sync runs as part of the pricelist pull; failure is non-fatal (Warn log only);
- `local_qt_settings` is a read-only cache — never written by user actions; - `local_qt_settings` is a read-only cache — never written by user actions;
- absent or unparseable settings: QF uses hardcoded fallbacks for that key only; - absent or unparseable settings: QF uses hardcoded fallbacks for that key only;
- `config_types[].categories` is an allowlist: a category absent from all types is shown everywhere; - `config_types[].categories` is an allowlist: a category absent from all types is shown everywhere;

View File

@@ -8,9 +8,8 @@ Main tables:
| Table | Purpose | | Table | Purpose |
| --- | --- | | --- | --- |
| `local_components` | synced component metadata |
| `local_pricelists` | local pricelist headers | | `local_pricelists` | local pricelist headers |
| `local_pricelist_items` | local pricelist rows, the only runtime price source | | `local_pricelist_items` | pricelist rows; the only runtime source of prices and component catalog |
| `local_projects` | user projects | | `local_projects` | user projects |
| `local_configurations` | user configurations | | `local_configurations` | user configurations |
| `local_configuration_versions` | immutable revision snapshots | | `local_configuration_versions` | immutable revision snapshots |
@@ -25,8 +24,10 @@ Main tables:
Rules: Rules:
- cache tables may be rebuilt if local migration recovery requires it; - cache tables may be rebuilt if local migration recovery requires it;
- 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; - `local_pricelist_items` is the only valid runtime source of prices and component catalog; do not add a separate component cache table;
- configuration `items` and `vendor_spec` are stored as JSON payloads inside configuration rows. - `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`;
- 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.
## MariaDB ## MariaDB
@@ -35,8 +36,6 @@ MariaDB is the central sync database (`RFQ_LOG`). Final schema as of 2026-04-15.
### QuoteForge tables (qt_*) ### QuoteForge tables (qt_*)
Runtime read: Runtime read:
- `qt_categories` — pricelist categories (note: `name`/`name_ru` columns being removed; QF does not use them)
- `qt_lot_metadata` — component metadata, price settings
- `qt_pricelists` — pricelist headers (source: estimate / warehouse / competitor) - `qt_pricelists` — pricelist headers (source: estimate / warehouse / competitor)
- `qt_pricelist_items` — pricelist rows - `qt_pricelist_items` — pricelist rows
- `qt_partnumber_books` — partnumber book headers - `qt_partnumber_books` — partnumber book headers
@@ -53,6 +52,8 @@ Insert-only tracking:
- `qt_vendor_partnumber_seen` — vendor partnumbers encountered during sync; `lot_suggestion` column updated when user manually maps PN → LOT in vendor-spec UI - `qt_vendor_partnumber_seen` — vendor partnumbers encountered during sync; `lot_suggestion` column updated when user manually maps PN → LOT in vendor-spec UI
Server-side only (not queried by client runtime): Server-side only (not queried by client runtime):
- `qt_categories` — pricelist category registry; QF runtime serves category lists for the UI from `models.DefaultCategories` (Go) overlaid with categories present in `local_pricelist_items`, not from this table. `name`/`name_ru` columns being removed.
- `qt_lot_metadata` — component metadata / price settings; the Go server-side component/category management layer (`ComponentRepository`, `CategoryRepository`, `ComponentService`) was removed — no client code reads this table
- `qt_component_usage_stats` — aggregated component popularity stats (written by server jobs) - `qt_component_usage_stats` — aggregated component popularity stats (written by server jobs)
- `qt_pricing_alerts` — price anomaly alerts (models exist in Go; feature disabled in runtime) - `qt_pricing_alerts` — price anomaly alerts (models exist in Go; feature disabled in runtime)
- `qt_schema_migrations` — server migration history (applied via `go run ./cmd/qfs -migrate`) - `qt_schema_migrations` — server migration history (applied via `go run ./cmd/qfs -migrate`)

View File

@@ -677,8 +677,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
var projectService *services.ProjectService var projectService *services.ProjectService
syncService = sync.NewService(connMgr, local) syncService = sync.NewService(connMgr, local)
componentService := services.NewComponentService(nil, nil) quoteService := services.NewQuoteService(nil, local)
quoteService := services.NewQuoteService(nil, nil, local, nil)
exportService := services.NewExportService(cfg.Export, local) exportService := services.NewExportService(cfg.Export, local)
// isOnline function for local-first architecture // isOnline function for local-first architecture
@@ -775,7 +774,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
templatesPath := filepath.Join("web", "templates") templatesPath := filepath.Join("web", "templates")
// Handlers // Handlers
componentHandler := handlers.NewComponentHandler(componentService, local) componentHandler := handlers.NewComponentHandler(local)
quoteHandler := handlers.NewQuoteHandler(quoteService) quoteHandler := handlers.NewQuoteHandler(quoteService)
exportHandler := handlers.NewExportHandler(exportService, configService, projectService, dbUsername) exportHandler := handlers.NewExportHandler(exportService, configService, projectService, dbUsername)
pricelistHandler := handlers.NewPricelistHandler(local) pricelistHandler := handlers.NewPricelistHandler(local)

View File

@@ -7,19 +7,16 @@ import (
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services" "git.mchus.pro/mchus/quoteforge/internal/services"
"github.com/gin-gonic/gin" "github.com/gin-gonic/gin"
) )
type ComponentHandler struct { type ComponentHandler struct {
componentService *services.ComponentService
localDB *localdb.LocalDB localDB *localdb.LocalDB
} }
func NewComponentHandler(componentService *services.ComponentService, localDB *localdb.LocalDB) *ComponentHandler { func NewComponentHandler(localDB *localdb.LocalDB) *ComponentHandler {
return &ComponentHandler{ return &ComponentHandler{
componentService: componentService,
localDB: localDB, localDB: localDB,
} }
} }
@@ -34,17 +31,10 @@ func (h *ComponentHandler) List(c *gin.Context) {
perPage = 20 perPage = 20
} }
filter := repository.ComponentFilter{ localFilter := localdb.ComponentFilter{
Category: c.Query("category"), Category: c.Query("category"),
Search: c.Query("search"), Search: c.Query("search"),
HasPrice: c.Query("has_price") == "true", HasPrice: c.Query("has_price") == "true",
ExcludeHidden: c.Query("include_hidden") != "true", // По умолчанию скрытые не показываются
}
localFilter := localdb.ComponentFilter{
Category: filter.Category,
Search: filter.Search,
HasPrice: filter.HasPrice,
} }
offset := (page - 1) * perPage offset := (page - 1) * perPage
localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage) localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage)

View File

@@ -6,7 +6,6 @@ import (
"net/http" "net/http"
"git.mchus.pro/mchus/quoteforge/internal/localdb" "git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository" "git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services" "git.mchus.pro/mchus/quoteforge/internal/services"
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync" syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
@@ -100,7 +99,7 @@ func (h *VendorSpecHandler) PutVendorSpec(c *gin.Context) {
body.VendorSpec[i].SortOrder = (i + 1) * 10 body.VendorSpec[i].SortOrder = (i + 1) * 10
} }
// Persist canonical LOT mapping only. // Persist canonical LOT mapping only.
body.VendorSpec[i].LotMappings = normalizeLotMappings(body.VendorSpec[i].LotMappings) body.VendorSpec[i].LotMappings = localdb.NormalizeLotMappings(body.VendorSpec[i].LotMappings)
body.VendorSpec[i].ResolvedLotName = "" body.VendorSpec[i].ResolvedLotName = ""
body.VendorSpec[i].ResolutionSource = "" body.VendorSpec[i].ResolutionSource = ""
body.VendorSpec[i].ManualLotSuggestion = "" body.VendorSpec[i].ManualLotSuggestion = ""
@@ -165,39 +164,6 @@ func (h *VendorSpecHandler) pushLotSuggestions(spec []localdb.VendorSpecItem) {
} }
} }
func normalizeLotMappings(in []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
if len(in) == 0 {
return nil
}
merged := make(map[string]int, len(in))
order := make([]string, 0, len(in))
for _, m := range in {
lot := models.NormalizeLotName(m.LotName)
if lot == "" {
continue
}
qty := m.QuantityPerPN
if qty < 1 {
qty = 1
}
if _, exists := merged[lot]; !exists {
order = append(order, lot)
}
merged[lot] += qty
}
out := make([]localdb.VendorSpecLotMapping, 0, len(order))
for _, lot := range order {
out = append(out, localdb.VendorSpecLotMapping{
LotName: lot,
QuantityPerPN: merged[lot],
})
}
if len(out) == 0 {
return nil
}
return out
}
// ResolveVendorSpec resolves vendor PN → LOT without modifying the cart. // ResolveVendorSpec resolves vendor PN → LOT without modifying the cart.
// POST /api/configs/:uuid/vendor-spec/resolve // POST /api/configs/:uuid/vendor-spec/resolve
func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) { func (h *VendorSpecHandler) ResolveVendorSpec(c *gin.Context) {

View File

@@ -6,6 +6,42 @@ import (
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
) )
// NormalizeLotMappings is the single canonical normalizer for vendor BOM LOT
// mappings. LOT names are canonicalized to their uppercase form (see
// models.NormalizeLotName) so that all BOM↔cart matching is case-insensitive,
// duplicate LOTs are merged (summing quantity-per-PN), and quantities are at
// least 1. Returns nil for an empty result. Both the persistence path
// (handlers) and the CSV export path must use this — do not reimplement it.
func NormalizeLotMappings(in []VendorSpecLotMapping) []VendorSpecLotMapping {
if len(in) == 0 {
return nil
}
merged := make(map[string]int, len(in))
order := make([]string, 0, len(in))
for _, m := range in {
lot := models.NormalizeLotName(m.LotName)
if lot == "" {
continue
}
qty := m.QuantityPerPN
if qty < 1 {
qty = 1
}
if _, exists := merged[lot]; !exists {
order = append(order, lot)
}
merged[lot] += qty
}
if len(order) == 0 {
return nil
}
out := make([]VendorSpecLotMapping, 0, len(order))
for _, lot := range order {
out = append(out, VendorSpecLotMapping{LotName: lot, QuantityPerPN: merged[lot]})
}
return out
}
// ConfigurationToLocal converts models.Configuration to LocalConfiguration // ConfigurationToLocal converts models.Configuration to LocalConfiguration
func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration { func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
items := make(LocalConfigItems, len(cfg.Items)) items := make(LocalConfigItems, len(cfg.Items))
@@ -294,33 +330,6 @@ func LocalToPricelistItem(local *LocalPricelistItem, serverPricelistID uint) *mo
} }
} }
// ComponentToLocal converts models.LotMetadata to LocalComponent
func ComponentToLocal(meta *models.LotMetadata) *LocalComponent {
var lotDesc string
var category string
if meta.Lot != nil {
lotDesc = meta.Lot.LotDescription
}
// Extract category from lot_name (e.g., "CPU_AMD_9654" -> "CPU")
if len(meta.LotName) > 0 {
for i, ch := range meta.LotName {
if ch == '_' {
category = meta.LotName[:i]
break
}
}
}
return &LocalComponent{
LotName: meta.LotName,
LotDescription: lotDesc,
Category: category,
Model: meta.Model,
}
}
// LocalToComponent converts LocalComponent to models.LotMetadata // LocalToComponent converts LocalComponent to models.LotMetadata
func LocalToComponent(local *LocalComponent) *models.LotMetadata { func LocalToComponent(local *LocalComponent) *models.LotMetadata {
return &models.LotMetadata{ return &models.LotMetadata{

View File

@@ -6,6 +6,36 @@ import (
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
) )
func TestNormalizeLotMappings_CaseInsensitiveMerge(t *testing.T) {
in := []VendorSpecLotMapping{
{LotName: "cpu_intel_6960p", QuantityPerPN: 1},
{LotName: "CPU_INTEL_6960P", QuantityPerPN: 2},
{LotName: " ps_5200w_Titanium ", QuantityPerPN: 0},
{LotName: "", QuantityPerPN: 5},
}
out := NormalizeLotMappings(in)
if len(out) != 2 {
t.Fatalf("expected 2 merged mappings, got %d: %+v", len(out), out)
}
if out[0].LotName != "CPU_INTEL_6960P" || out[0].QuantityPerPN != 3 {
t.Fatalf("expected CPU_INTEL_6960P qty 3, got %+v", out[0])
}
if out[1].LotName != "PS_5200W_TITANIUM" || out[1].QuantityPerPN != 1 {
t.Fatalf("expected PS_5200W_TITANIUM qty 1 (clamped), got %+v", out[1])
}
}
func TestNormalizeLotMappings_Empty(t *testing.T) {
if NormalizeLotMappings(nil) != nil {
t.Fatal("expected nil for empty input")
}
if NormalizeLotMappings([]VendorSpecLotMapping{{LotName: " "}}) != nil {
t.Fatal("expected nil when all entries blank")
}
}
func TestPricelistItemToLocal_PreservesLotCategory(t *testing.T) { func TestPricelistItemToLocal_PreservesLotCategory(t *testing.T) {
item := &models.PricelistItem{ item := &models.PricelistItem{
LotName: "CPU_A", LotName: "CPU_A",

View File

@@ -1720,12 +1720,13 @@ func (l *LocalDB) RepairPendingChanges() (int, []string, error) {
var remainingErrors []string var remainingErrors []string
for _, change := range erroredChanges { for _, change := range erroredChanges {
var modified bool
var repairErr error var repairErr error
switch change.EntityType { switch change.EntityType {
case "project": case "project":
repairErr = l.repairProjectChange(&change) modified, repairErr = l.repairProjectChange(&change)
case "configuration": case "configuration":
repairErr = l.repairConfigurationChange(&change) modified, repairErr = l.repairConfigurationChange(&change)
default: default:
repairErr = fmt.Errorf("unknown entity type: %s", change.EntityType) repairErr = fmt.Errorf("unknown entity type: %s", change.EntityType)
} }
@@ -1736,7 +1737,13 @@ func (l *LocalDB) RepairPendingChanges() (int, []string, error) {
continue continue
} }
// Clear error and reset attempts // Only reset attempts when the repair actually changed local data.
// If nothing was modified, the error is server-side; leaving attempts
// intact lets maxPendingChangeAttempts eventually abandon the change.
if !modified {
continue
}
if err := l.db.Model(&PendingChange{}).Where("id = ?", change.ID).Updates(map[string]interface{}{ if err := l.db.Model(&PendingChange{}).Where("id = ?", change.ID).Updates(map[string]interface{}{
"last_error": "", "last_error": "",
"attempts": 0, "attempts": 0,
@@ -1752,12 +1759,13 @@ func (l *LocalDB) RepairPendingChanges() (int, []string, error) {
} }
// repairProjectChange validates and fixes project data. // repairProjectChange validates and fixes project data.
// Returns (modified, err): modified=true only when local data was actually changed.
// Note: This only validates local data. Server-side conflicts (like duplicate code+variant) // Note: This only validates local data. Server-side conflicts (like duplicate code+variant)
// are handled by sync service layer with deduplication logic. // are handled by sync service layer with deduplication logic.
func (l *LocalDB) repairProjectChange(change *PendingChange) error { func (l *LocalDB) repairProjectChange(change *PendingChange) (bool, error) {
project, err := l.GetProjectByUUID(change.EntityUUID) project, err := l.GetProjectByUUID(change.EntityUUID)
if err != nil { if err != nil {
return fmt.Errorf("project not found locally: %w", err) return false, fmt.Errorf("project not found locally: %w", err)
} }
modified := false modified := false
@@ -1783,7 +1791,7 @@ func (l *LocalDB) repairProjectChange(change *PendingChange) error {
if strings.TrimSpace(project.OwnerUsername) == "" { if strings.TrimSpace(project.OwnerUsername) == "" {
project.OwnerUsername = l.GetDBUser() project.OwnerUsername = l.GetDBUser()
if project.OwnerUsername == "" { if project.OwnerUsername == "" {
return fmt.Errorf("cannot determine owner username") return false, fmt.Errorf("cannot determine owner username")
} }
modified = true modified = true
} }
@@ -1804,18 +1812,19 @@ func (l *LocalDB) repairProjectChange(change *PendingChange) error {
if modified { if modified {
if err := l.SaveProject(project); err != nil { if err := l.SaveProject(project); err != nil {
return fmt.Errorf("saving repaired project: %w", err) return false, fmt.Errorf("saving repaired project: %w", err)
} }
} }
return nil return modified, nil
} }
// repairConfigurationChange validates and fixes configuration data // repairConfigurationChange validates and fixes configuration data.
func (l *LocalDB) repairConfigurationChange(change *PendingChange) error { // Returns (modified, err): modified=true only when local data was actually changed.
func (l *LocalDB) repairConfigurationChange(change *PendingChange) (bool, error) {
config, err := l.GetConfigurationByUUID(change.EntityUUID) config, err := l.GetConfigurationByUUID(change.EntityUUID)
if err != nil { if err != nil {
return fmt.Errorf("configuration not found locally: %w", err) return false, fmt.Errorf("configuration not found locally: %w", err)
} }
modified := false modified := false
@@ -1827,7 +1836,7 @@ func (l *LocalDB) repairConfigurationChange(change *PendingChange) error {
// Project doesn't exist locally - use default system project // Project doesn't exist locally - use default system project
systemProject, sysErr := l.EnsureDefaultProject(config.OriginalUsername) systemProject, sysErr := l.EnsureDefaultProject(config.OriginalUsername)
if sysErr != nil { if sysErr != nil {
return fmt.Errorf("getting system project: %w", sysErr) return false, fmt.Errorf("getting system project: %w", sysErr)
} }
config.ProjectUUID = &systemProject.UUID config.ProjectUUID = &systemProject.UUID
modified = true modified = true
@@ -1836,11 +1845,11 @@ func (l *LocalDB) repairConfigurationChange(change *PendingChange) error {
if modified { if modified {
if err := l.SaveConfiguration(config); err != nil { if err := l.SaveConfiguration(config); err != nil {
return fmt.Errorf("saving repaired configuration: %w", err) return false, fmt.Errorf("saving repaired configuration: %w", err)
} }
} }
return nil return modified, nil
} }
// GetSyncGuardState returns the latest readiness guard state. // GetSyncGuardState returns the latest readiness guard state.

View File

@@ -1,76 +0,0 @@
package repository
import (
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/gorm"
)
type CategoryRepository struct {
db *gorm.DB
}
func NewCategoryRepository(db *gorm.DB) *CategoryRepository {
return &CategoryRepository{db: db}
}
func (r *CategoryRepository) GetAll() ([]models.Category, error) {
var categories []models.Category
err := r.db.Order("display_order ASC").Find(&categories).Error
return categories, err
}
func (r *CategoryRepository) GetByCode(code string) (*models.Category, error) {
var category models.Category
err := r.db.Where("code = ?", code).First(&category).Error
if err != nil {
return nil, err
}
return &category, nil
}
func (r *CategoryRepository) GetByID(id uint) (*models.Category, error) {
var category models.Category
err := r.db.First(&category, id).Error
if err != nil {
return nil, err
}
return &category, nil
}
// CreateIfNotExists creates a new category if it doesn't exist, returns existing one if it does
func (r *CategoryRepository) CreateIfNotExists(code string) (*models.Category, error) {
// Try to find existing
existing, err := r.GetByCode(code)
if err == nil {
return existing, nil
}
// Get max display order to put new category at the end
var maxOrder int
r.db.Model(&models.Category{}).Select("COALESCE(MAX(display_order), 0)").Scan(&maxOrder)
// Create new category
newCat := &models.Category{
Code: code,
Name: code, // Use code as name initially
NameRu: code,
DisplayOrder: maxOrder + 1,
IsRequired: false,
}
if err := r.db.Create(newCat).Error; err != nil {
return nil, err
}
return newCat, nil
}
// Create creates a new category
func (r *CategoryRepository) Create(category *models.Category) error {
return r.db.Create(category).Error
}
// Update updates an existing category
func (r *CategoryRepository) Update(category *models.Category) error {
return r.db.Save(category).Error
}

View File

@@ -1,140 +0,0 @@
package repository
import (
"time"
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/gorm"
)
type ComponentRepository struct {
db *gorm.DB
}
func NewComponentRepository(db *gorm.DB) *ComponentRepository {
return &ComponentRepository{db: db}
}
type ComponentFilter struct {
Category string
Search string
HasPrice bool
ExcludeHidden bool
SortField string
SortDir string
}
func (r *ComponentRepository) List(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) {
var components []models.LotMetadata
var total int64
query := r.db.Model(&models.LotMetadata{}).
Preload("Lot").
Preload("Category")
if filter.Category != "" {
query = query.Joins("JOIN qt_categories ON qt_lot_metadata.category_id = qt_categories.id").
Where("qt_categories.code = ?", filter.Category)
}
if filter.Search != "" {
search := "%" + filter.Search + "%"
query = query.Where("lot_name LIKE ? OR model LIKE ?", search, search)
}
if filter.HasPrice {
query = query.Where("current_price IS NOT NULL AND current_price > 0")
}
if filter.ExcludeHidden {
query = query.Where("is_hidden = ? OR is_hidden IS NULL", false)
}
query.Count(&total)
// Apply sorting
sortDir := "ASC"
if filter.SortDir == "desc" {
sortDir = "DESC"
}
switch filter.SortField {
case "popularity_score":
query = query.Order("popularity_score " + sortDir)
case "current_price":
query = query.Order("CASE WHEN current_price IS NULL OR current_price = 0 THEN 1 ELSE 0 END").
Order("current_price " + sortDir)
case "lot_name":
query = query.Order("lot_name " + sortDir)
default:
// Default: sort by popularity, no price goes last
query = query.
Order("CASE WHEN current_price IS NULL OR current_price = 0 THEN 1 ELSE 0 END").
Order("popularity_score DESC")
}
err := query.
Offset(offset).
Limit(limit).
Find(&components).Error
return components, total, err
}
func (r *ComponentRepository) GetByLotName(lotName string) (*models.LotMetadata, error) {
var component models.LotMetadata
err := r.db.
Preload("Lot").
Preload("Category").
Where("lot_name = ?", lotName).
First(&component).Error
if err != nil {
return nil, err
}
return &component, nil
}
func (r *ComponentRepository) GetMultiple(lotNames []string) ([]models.LotMetadata, error) {
var components []models.LotMetadata
err := r.db.
Preload("Lot").
Preload("Category").
Where("lot_name IN ?", lotNames).
Find(&components).Error
return components, err
}
func (r *ComponentRepository) Update(component *models.LotMetadata) error {
return r.db.Save(component).Error
}
func (r *ComponentRepository) DB() *gorm.DB {
return r.db
}
func (r *ComponentRepository) Create(component *models.LotMetadata) error {
return r.db.Create(component).Error
}
func (r *ComponentRepository) IncrementRequestCount(lotName string) error {
now := time.Now()
return r.db.Model(&models.LotMetadata{}).
Where("lot_name = ?", lotName).
Updates(map[string]interface{}{
"request_count": gorm.Expr("request_count + 1"),
"last_request_date": now,
}).Error
}
// GetAllLots returns all lots from the existing lot table
func (r *ComponentRepository) GetAllLots() ([]models.Lot, error) {
var lots []models.Lot
err := r.db.Find(&lots).Error
return lots, err
}
// GetLotsWithoutMetadata returns lots that don't have qt_lot_metadata entries
func (r *ComponentRepository) GetLotsWithoutMetadata() ([]models.Lot, error) {
var lots []models.Lot
err := r.db.
Where("lot_name NOT IN (SELECT lot_name FROM qt_lot_metadata)").
Find(&lots).Error
return lots, err
}

View File

@@ -23,6 +23,11 @@ func (r *ProjectRepository) Update(project *models.Project) error {
} }
func (r *ProjectRepository) UpsertByUUID(project *models.Project) error { func (r *ProjectRepository) UpsertByUUID(project *models.Project) error {
// Clear the client-side primary key so the upsert is driven purely by the
// uuid unique constraint. Passing a non-zero ID can trigger ON DUPLICATE KEY
// on the primary key of an unrelated row, leaving uuid unchanged and causing
// the follow-up SELECT to return ErrRecordNotFound.
project.ID = 0
if err := r.db.Clauses(clause.OnConflict{ if err := r.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "uuid"}}, Columns: []clause.Column{{Name: "uuid"}},
DoUpdates: clause.AssignmentColumns([]string{ DoUpdates: clause.AssignmentColumns([]string{

View File

@@ -1,393 +0,0 @@
package repository
import (
"encoding/json"
"fmt"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/gorm"
)
// DataSource defines the unified interface for data access
// It abstracts whether data comes from MariaDB (online) or SQLite (offline)
type DataSource interface {
// Components
GetComponents(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error)
GetComponent(lotName string) (*models.LotMetadata, error)
// Configurations
SaveConfiguration(cfg *models.Configuration) error
GetConfigurations(ownerUsername string) ([]models.Configuration, error)
GetConfigurationByUUID(uuid string) (*models.Configuration, error)
DeleteConfiguration(uuid string) error
// Pricelists (read-only in offline mode)
GetPricelists() ([]models.PricelistSummary, error)
GetPricelistByID(id uint) (*models.Pricelist, error)
GetPricelistItems(pricelistID uint) ([]models.PricelistItem, error)
GetLatestPricelist() (*models.Pricelist, error)
}
// UnifiedRepo implements DataSource with automatic online/offline switching
type UnifiedRepo struct {
mariaDB *gorm.DB
localDB *localdb.LocalDB
isOnline bool
}
// NewUnifiedRepo creates a new unified repository
func NewUnifiedRepo(mariaDB *gorm.DB, localDB *localdb.LocalDB, isOnline bool) *UnifiedRepo {
return &UnifiedRepo{
mariaDB: mariaDB,
localDB: localDB,
isOnline: isOnline,
}
}
// SetOnlineStatus updates the online/offline status
func (r *UnifiedRepo) SetOnlineStatus(online bool) {
r.isOnline = online
}
// IsOnline returns the current online/offline status
func (r *UnifiedRepo) IsOnline() bool {
return r.isOnline
}
// Component methods
// GetComponents returns components from MariaDB (online) or local cache (offline)
func (r *UnifiedRepo) GetComponents(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) {
if r.isOnline {
return r.getComponentsOnline(filter, offset, limit)
}
return r.getComponentsOffline(filter, offset, limit)
}
func (r *UnifiedRepo) getComponentsOnline(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) {
repo := NewComponentRepository(r.mariaDB)
return repo.List(filter, offset, limit)
}
func (r *UnifiedRepo) getComponentsOffline(filter ComponentFilter, offset, limit int) ([]models.LotMetadata, int64, error) {
var components []localdb.LocalComponent
query := r.localDB.DB().Model(&localdb.LocalComponent{})
// Apply filters
if filter.Category != "" {
query = query.Where("category = ?", filter.Category)
}
if filter.Search != "" {
search := "%" + filter.Search + "%"
query = query.Where("lot_name LIKE ? OR lot_description LIKE ? OR model LIKE ?", search, search, search)
}
var total int64
query.Count(&total)
// Apply sorting
sortDir := "ASC"
if filter.SortDir == "desc" {
sortDir = "DESC"
}
switch filter.SortField {
case "lot_name":
query = query.Order("lot_name " + sortDir)
default:
query = query.Order("lot_name ASC")
}
if err := query.Offset(offset).Limit(limit).Find(&components).Error; err != nil {
return nil, 0, fmt.Errorf("fetching offline components: %w", err)
}
// Convert to models.LotMetadata
result := make([]models.LotMetadata, len(components))
for i, comp := range components {
result[i] = models.LotMetadata{
LotName: comp.LotName,
Model: comp.Model,
Lot: &models.Lot{
LotName: comp.LotName,
LotDescription: comp.LotDescription,
},
}
}
return result, total, nil
}
// GetComponent returns a single component by lot name
func (r *UnifiedRepo) GetComponent(lotName string) (*models.LotMetadata, error) {
if r.isOnline {
repo := NewComponentRepository(r.mariaDB)
return repo.GetByLotName(lotName)
}
var comp localdb.LocalComponent
if err := r.localDB.DB().Where("lot_name = ?", lotName).First(&comp).Error; err != nil {
return nil, fmt.Errorf("fetching offline component: %w", err)
}
return &models.LotMetadata{
LotName: comp.LotName,
Model: comp.Model,
Lot: &models.Lot{
LotName: comp.LotName,
LotDescription: comp.LotDescription,
},
}, nil
}
// Configuration methods
// SaveConfiguration saves a configuration (online: MariaDB, offline: SQLite + pending_changes)
func (r *UnifiedRepo) SaveConfiguration(cfg *models.Configuration) error {
if r.isOnline {
repo := NewConfigurationRepository(r.mariaDB)
return repo.Create(cfg)
}
// Offline: save to local SQLite and queue for sync
localCfg := &localdb.LocalConfiguration{
UUID: cfg.UUID,
Name: cfg.Name,
TotalPrice: cfg.TotalPrice,
CustomPrice: cfg.CustomPrice,
Notes: cfg.Notes,
IsTemplate: cfg.IsTemplate,
ServerCount: cfg.ServerCount,
CreatedAt: cfg.CreatedAt,
UpdatedAt: time.Now(),
SyncStatus: "pending",
OriginalUsername: cfg.OwnerUsername,
}
// Convert items
localItems := make(localdb.LocalConfigItems, len(cfg.Items))
for i, item := range cfg.Items {
localItems[i] = localdb.LocalConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: item.UnitPrice,
}
}
localCfg.Items = localItems
if err := r.localDB.SaveConfiguration(localCfg); err != nil {
return fmt.Errorf("saving local configuration: %w", err)
}
// Add to pending changes queue
payload, err := json.Marshal(cfg)
if err != nil {
return fmt.Errorf("marshaling configuration for sync: %w", err)
}
return r.localDB.AddPendingChange("configuration", cfg.UUID, "create", string(payload))
}
// GetConfigurations returns all configurations for a user
func (r *UnifiedRepo) GetConfigurations(ownerUsername string) ([]models.Configuration, error) {
if r.isOnline {
repo := NewConfigurationRepository(r.mariaDB)
configs, _, err := repo.ListByUser(ownerUsername, 0, 1000)
return configs, err
}
// Offline: get from local SQLite
localConfigs, err := r.localDB.GetConfigurations()
if err != nil {
return nil, fmt.Errorf("fetching local configurations: %w", err)
}
// Convert to models.Configuration
result := make([]models.Configuration, len(localConfigs))
for i, lc := range localConfigs {
items := make(models.ConfigItems, len(lc.Items))
for j, item := range lc.Items {
items[j] = models.ConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: item.UnitPrice,
}
}
result[i] = models.Configuration{
UUID: lc.UUID,
OwnerUsername: lc.OriginalUsername,
Name: lc.Name,
Items: items,
TotalPrice: lc.TotalPrice,
CustomPrice: lc.CustomPrice,
Notes: lc.Notes,
IsTemplate: lc.IsTemplate,
ServerCount: lc.ServerCount,
CreatedAt: lc.CreatedAt,
}
}
return result, nil
}
// GetConfigurationByUUID returns a configuration by UUID
func (r *UnifiedRepo) GetConfigurationByUUID(uuid string) (*models.Configuration, error) {
if r.isOnline {
repo := NewConfigurationRepository(r.mariaDB)
return repo.GetByUUID(uuid)
}
localCfg, err := r.localDB.GetConfigurationByUUID(uuid)
if err != nil {
return nil, fmt.Errorf("fetching local configuration: %w", err)
}
items := make(models.ConfigItems, len(localCfg.Items))
for i, item := range localCfg.Items {
items[i] = models.ConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: item.UnitPrice,
}
}
return &models.Configuration{
UUID: localCfg.UUID,
Name: localCfg.Name,
Items: items,
TotalPrice: localCfg.TotalPrice,
CustomPrice: localCfg.CustomPrice,
Notes: localCfg.Notes,
IsTemplate: localCfg.IsTemplate,
ServerCount: localCfg.ServerCount,
CreatedAt: localCfg.CreatedAt,
}, nil
}
// DeleteConfiguration deletes a configuration
func (r *UnifiedRepo) DeleteConfiguration(uuid string) error {
if r.isOnline {
// Get ID first
cfg, err := r.GetConfigurationByUUID(uuid)
if err != nil {
return err
}
repo := NewConfigurationRepository(r.mariaDB)
return repo.Delete(cfg.ID)
}
// Offline: delete from local and queue sync
if err := r.localDB.DeleteConfiguration(uuid); err != nil {
return fmt.Errorf("deleting local configuration: %w", err)
}
return r.localDB.AddPendingChange("configuration", uuid, "delete", "")
}
// Pricelist methods
// GetPricelists returns all pricelists
func (r *UnifiedRepo) GetPricelists() ([]models.PricelistSummary, error) {
if r.isOnline {
repo := NewPricelistRepository(r.mariaDB)
summaries, _, err := repo.List(0, 1000)
return summaries, err
}
// Offline: get from local cache
localPLs, err := r.localDB.GetLocalPricelists()
if err != nil {
return nil, fmt.Errorf("fetching local pricelists: %w", err)
}
summaries := make([]models.PricelistSummary, len(localPLs))
for i, pl := range localPLs {
itemCount := r.localDB.CountLocalPricelistItems(pl.ID)
summaries[i] = models.PricelistSummary{
ID: pl.ServerID,
Version: pl.Version,
CreatedAt: pl.CreatedAt,
ItemCount: itemCount,
}
}
return summaries, nil
}
// GetPricelistByID returns a pricelist by ID
func (r *UnifiedRepo) GetPricelistByID(id uint) (*models.Pricelist, error) {
if r.isOnline {
repo := NewPricelistRepository(r.mariaDB)
return repo.GetByID(id)
}
// Offline: get from local cache
localPL, err := r.localDB.GetLocalPricelistByServerID(id)
if err != nil {
return nil, fmt.Errorf("fetching local pricelist: %w", err)
}
itemCount := r.localDB.CountLocalPricelistItems(localPL.ID)
return &models.Pricelist{
ID: localPL.ServerID,
Version: localPL.Version,
CreatedAt: localPL.CreatedAt,
ItemCount: int(itemCount),
}, nil
}
// GetPricelistItems returns items for a pricelist
func (r *UnifiedRepo) GetPricelistItems(pricelistID uint) ([]models.PricelistItem, error) {
if r.isOnline {
repo := NewPricelistRepository(r.mariaDB)
items, _, err := repo.GetItems(pricelistID, 0, 100000, "")
return items, err
}
// Offline: get from local cache
// First find the local pricelist by server ID
localPL, err := r.localDB.GetLocalPricelistByServerID(pricelistID)
if err != nil {
return nil, fmt.Errorf("fetching local pricelist: %w", err)
}
localItems, err := r.localDB.GetLocalPricelistItems(localPL.ID)
if err != nil {
return nil, fmt.Errorf("fetching local pricelist items: %w", err)
}
items := make([]models.PricelistItem, len(localItems))
for i, item := range localItems {
items[i] = models.PricelistItem{
ID: item.ID,
PricelistID: pricelistID,
LotName: item.LotName,
Price: item.Price,
}
}
return items, nil
}
// GetLatestPricelist returns the latest pricelist
func (r *UnifiedRepo) GetLatestPricelist() (*models.Pricelist, error) {
if r.isOnline {
repo := NewPricelistRepository(r.mariaDB)
return repo.GetLatestActive()
}
// Offline: get from local cache
localPL, err := r.localDB.GetLatestLocalPricelist()
if err != nil {
return nil, fmt.Errorf("fetching latest local pricelist: %w", err)
}
itemCount := r.localDB.CountLocalPricelistItems(localPL.ID)
return &models.Pricelist{
ID: localPL.ServerID,
Version: localPL.Version,
CreatedAt: localPL.CreatedAt,
ItemCount: int(itemCount),
}, nil
}

View File

@@ -1,43 +1,9 @@
package services package services
import ( import (
"fmt"
"log/slog"
"strings"
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
) )
type ComponentService struct {
componentRepo *repository.ComponentRepository
categoryRepo *repository.CategoryRepository
}
func NewComponentService(
componentRepo *repository.ComponentRepository,
categoryRepo *repository.CategoryRepository,
) *ComponentService {
return &ComponentService{
componentRepo: componentRepo,
categoryRepo: categoryRepo,
}
}
// ParsePartNumber extracts category and model from lot_name
// "CPU_AMD_9654" → category="CPU", model="AMD_9654"
// "MB_INTEL_4.Sapphire_2S_32xDDR5" → category="MB", model="INTEL_4.Sapphire_2S_32xDDR5"
func ParsePartNumber(lotName string) (category, model string) {
parts := strings.SplitN(lotName, "_", 2)
if len(parts) >= 1 {
category = parts[0]
}
if len(parts) >= 2 {
model = parts[1]
}
return
}
type ComponentListResult struct { type ComponentListResult struct {
Items []ComponentView `json:"items"` Items []ComponentView `json:"items"`
TotalCount int64 `json:"total_count"` TotalCount int64 `json:"total_count"`
@@ -56,169 +22,3 @@ type ComponentView struct {
PopularityScore float64 `json:"popularity_score"` PopularityScore float64 `json:"popularity_score"`
Specs models.Specs `json:"specs,omitempty"` Specs models.Specs `json:"specs,omitempty"`
} }
func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage int) (*ComponentListResult, error) {
// If no database connection (offline mode), return empty list
// Components should be loaded via /api/sync/components first
if s.componentRepo == nil {
return &ComponentListResult{
Items: []ComponentView{},
TotalCount: 0,
Page: page,
PerPage: perPage,
TotalPages: 1,
}, nil
}
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 20
}
if perPage > 5000 {
perPage = 5000
}
offset := (page - 1) * perPage
components, total, err := s.componentRepo.List(filter, offset, perPage)
if err != nil {
return nil, err
}
views := make([]ComponentView, len(components))
for i, c := range components {
view := ComponentView{
LotName: c.LotName,
Model: c.Model,
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
PopularityScore: c.PopularityScore,
Specs: c.Specs,
}
if c.Lot != nil {
view.Description = c.Lot.LotDescription
}
if c.Category != nil {
view.Category = c.Category.Code
view.CategoryName = c.Category.Name
}
views[i] = view
}
totalPages := int((total + int64(perPage) - 1) / int64(perPage))
if totalPages < 1 {
totalPages = 1
}
return &ComponentListResult{
Items: views,
TotalCount: total,
Page: page,
PerPage: perPage,
TotalPages: totalPages,
}, nil
}
func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error) {
// If no database connection (offline mode), return error
if s.componentRepo == nil {
return nil, fmt.Errorf("offline mode: component data not available")
}
c, err := s.componentRepo.GetByLotName(lotName)
if err != nil {
return nil, err
}
// Track usage (best-effort)
if err := s.componentRepo.IncrementRequestCount(lotName); err != nil {
slog.Warn("component: could not increment request count", "lot", lotName, "err", err)
}
view := &ComponentView{
LotName: c.LotName,
Model: c.Model,
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
PopularityScore: c.PopularityScore,
Specs: c.Specs,
}
if c.Lot != nil {
view.Description = c.Lot.LotDescription
}
if c.Category != nil {
view.Category = c.Category.Code
view.CategoryName = c.Category.Name
}
return view, nil
}
func (s *ComponentService) GetCategories() ([]models.Category, error) {
// If no database connection (offline mode), return default categories
if s.categoryRepo == nil {
return models.DefaultCategories, nil
}
return s.categoryRepo.GetAll()
}
// ImportFromLot creates metadata entries for lots that don't have them
func (s *ComponentService) ImportFromLot() (int, error) {
// If no database connection (offline mode), return error
if s.componentRepo == nil || s.categoryRepo == nil {
return 0, fmt.Errorf("offline mode: import not available")
}
lots, err := s.componentRepo.GetLotsWithoutMetadata()
if err != nil {
return 0, err
}
categories, err := s.categoryRepo.GetAll()
if err != nil {
return 0, err
}
categoryMap := make(map[string]uint)
for _, cat := range categories {
categoryMap[strings.ToUpper(cat.Code)] = cat.ID
}
imported := 0
for _, lot := range lots {
// Use lot_category from database if available, otherwise parse from lot_name
var category string
if lot.LotCategory != nil && *lot.LotCategory != "" {
category = strings.ToUpper(*lot.LotCategory)
} else {
category, _ = ParsePartNumber(lot.LotName)
category = strings.ToUpper(category)
}
_, model := ParsePartNumber(lot.LotName)
metadata := &models.LotMetadata{
LotName: lot.LotName,
Model: model,
Specs: make(models.Specs),
}
if catID, ok := categoryMap[category]; ok {
metadata.CategoryID = &catID
} else {
// Create new category if it doesn't exist
newCat, err := s.categoryRepo.CreateIfNotExists(category)
if err == nil && newCat != nil {
metadata.CategoryID = &newCat.ID
}
}
if err := s.componentRepo.Create(metadata); err != nil {
continue
}
imported++
}
return imported, nil
}

View File

@@ -2,11 +2,8 @@ package services
import ( import (
"errors" "errors"
"time"
"git.mchus.pro/mchus/quoteforge/internal/models" "git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"github.com/google/uuid"
) )
var ( var (
@@ -14,37 +11,13 @@ var (
ErrConfigForbidden = errors.New("access to configuration forbidden") ErrConfigForbidden = errors.New("access to configuration forbidden")
) )
// ConfigurationGetter is an interface for services that can retrieve configurations // ConfigurationGetter is an interface for services that can retrieve configurations.
// Used by handlers to work with both ConfigurationService and LocalConfigurationService // Used by handlers to work with LocalConfigurationService.
type ConfigurationGetter interface { type ConfigurationGetter interface {
GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error) GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error)
GetByUUIDNoAuth(uuid string) (*models.Configuration, error) GetByUUIDNoAuth(uuid string) (*models.Configuration, error)
} }
type ConfigurationService struct {
configRepo *repository.ConfigurationRepository
projectRepo *repository.ProjectRepository
componentRepo *repository.ComponentRepository
pricelistRepo *repository.PricelistRepository
quoteService *QuoteService
}
func NewConfigurationService(
configRepo *repository.ConfigurationRepository,
projectRepo *repository.ProjectRepository,
componentRepo *repository.ComponentRepository,
pricelistRepo *repository.PricelistRepository,
quoteService *QuoteService,
) *ConfigurationService {
return &ConfigurationService{
configRepo: configRepo,
projectRepo: projectRepo,
componentRepo: componentRepo,
pricelistRepo: pricelistRepo,
quoteService: quoteService,
}
}
type CreateConfigRequest struct { type CreateConfigRequest struct {
Name string `json:"name"` Name string `json:"name"`
Items models.ConfigItems `json:"items"` Items models.ConfigItems `json:"items"`
@@ -70,583 +43,3 @@ type ArticlePreviewRequest struct {
SupportCode string `json:"support_code,omitempty"` SupportCode string `json:"support_code,omitempty"`
PricelistID *uint `json:"pricelist_id,omitempty"` PricelistID *uint `json:"pricelist_id,omitempty"`
} }
func (s *ConfigurationService) Create(ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
if err != nil {
return nil, err
}
pricelistID, err := s.resolvePricelistID(req.PricelistID)
if err != nil {
return nil, err
}
total := req.Items.Total()
// If server count is greater than 1, multiply the total by server count
if req.ServerCount > 1 {
total *= float64(req.ServerCount)
}
config := &models.Configuration{
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
ProjectUUID: projectUUID,
Name: req.Name,
Items: req.Items,
TotalPrice: &total,
CustomPrice: req.CustomPrice,
Notes: req.Notes,
IsTemplate: req.IsTemplate,
ServerCount: req.ServerCount,
ServerModel: req.ServerModel,
SupportCode: req.SupportCode,
Article: req.Article,
PricelistID: pricelistID,
WarehousePricelistID: req.WarehousePricelistID,
CompetitorPricelistID: req.CompetitorPricelistID,
ConfigType: req.ConfigType,
DisablePriceRefresh: req.DisablePriceRefresh,
OnlyInStock: req.OnlyInStock,
}
if config.ConfigType == "" {
config.ConfigType = "server"
}
if err := s.configRepo.Create(config); err != nil {
return nil, err
}
return config, nil
}
func (s *ConfigurationService) GetByUUID(uuid string, ownerUsername string) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
// Allow access if user owns config or it's a template
if !s.isOwner(config, ownerUsername) && !config.IsTemplate {
return nil, ErrConfigForbidden
}
return config, nil
}
func (s *ConfigurationService) Update(uuid string, ownerUsername string, req *CreateConfigRequest) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
if !s.isOwner(config, ownerUsername) {
return nil, ErrConfigForbidden
}
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
if err != nil {
return nil, err
}
pricelistID, err := s.resolvePricelistID(req.PricelistID)
if err != nil {
return nil, err
}
total := req.Items.Total()
// If server count is greater than 1, multiply the total by server count
if req.ServerCount > 1 {
total *= float64(req.ServerCount)
}
config.Name = req.Name
config.ProjectUUID = projectUUID
config.Items = req.Items
config.TotalPrice = &total
config.CustomPrice = req.CustomPrice
config.Notes = req.Notes
config.IsTemplate = req.IsTemplate
config.ServerCount = req.ServerCount
config.ServerModel = req.ServerModel
config.SupportCode = req.SupportCode
config.Article = req.Article
config.PricelistID = pricelistID
config.WarehousePricelistID = req.WarehousePricelistID
config.CompetitorPricelistID = req.CompetitorPricelistID
config.DisablePriceRefresh = req.DisablePriceRefresh
config.OnlyInStock = req.OnlyInStock
if err := s.configRepo.Update(config); err != nil {
return nil, err
}
return config, nil
}
func (s *ConfigurationService) Delete(uuid string, ownerUsername string) error {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return ErrConfigNotFound
}
if !s.isOwner(config, ownerUsername) {
return ErrConfigForbidden
}
return s.configRepo.Delete(config.ID)
}
func (s *ConfigurationService) Rename(uuid string, ownerUsername string, newName string) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
if !s.isOwner(config, ownerUsername) {
return nil, ErrConfigForbidden
}
config.Name = newName
if err := s.configRepo.Update(config); err != nil {
return nil, err
}
return config, nil
}
func (s *ConfigurationService) Clone(configUUID string, ownerUsername string, newName string) (*models.Configuration, error) {
return s.CloneToProject(configUUID, ownerUsername, newName, nil)
}
func (s *ConfigurationService) CloneToProject(configUUID string, ownerUsername string, newName string, projectUUID *string) (*models.Configuration, error) {
original, err := s.GetByUUID(configUUID, ownerUsername)
if err != nil {
return nil, err
}
resolvedProjectUUID := original.ProjectUUID
if projectUUID != nil {
resolvedProjectUUID, err = s.resolveProjectUUID(ownerUsername, projectUUID)
if err != nil {
return nil, err
}
}
// Create copy with new UUID and name
total := original.Items.Total()
// If server count is greater than 1, multiply the total by server count
if original.ServerCount > 1 {
total *= float64(original.ServerCount)
}
clone := &models.Configuration{
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
ProjectUUID: resolvedProjectUUID,
Name: newName,
Items: original.Items,
TotalPrice: &total,
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false, // Clone is never a template
ServerCount: original.ServerCount,
ServerModel: original.ServerModel,
SupportCode: original.SupportCode,
Article: original.Article,
PricelistID: original.PricelistID,
WarehousePricelistID: original.WarehousePricelistID,
CompetitorPricelistID: original.CompetitorPricelistID,
DisablePriceRefresh: original.DisablePriceRefresh,
OnlyInStock: original.OnlyInStock,
}
if err := s.configRepo.Create(clone); err != nil {
return nil, err
}
return clone, nil
}
func (s *ConfigurationService) ListByUser(ownerUsername string, page, perPage int) ([]models.Configuration, int64, error) {
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
perPage = 20
}
offset := (page - 1) * perPage
return s.configRepo.ListByUser(ownerUsername, offset, perPage)
}
// ListAll returns all configurations without user filter (for use when auth is disabled)
func (s *ConfigurationService) ListAll(page, perPage int) ([]models.Configuration, int64, error) {
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
perPage = 20
}
offset := (page - 1) * perPage
return s.configRepo.ListAll(offset, perPage)
}
// GetByUUIDNoAuth returns configuration without ownership check (for use when auth is disabled)
func (s *ConfigurationService) GetByUUIDNoAuth(uuid string) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
return config, nil
}
// UpdateNoAuth updates configuration without ownership check
func (s *ConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigRequest) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
projectUUID, err := s.resolveProjectUUID(config.OwnerUsername, req.ProjectUUID)
if err != nil {
return nil, err
}
pricelistID, err := s.resolvePricelistID(req.PricelistID)
if err != nil {
return nil, err
}
total := req.Items.Total()
if req.ServerCount > 1 {
total *= float64(req.ServerCount)
}
config.Name = req.Name
config.ProjectUUID = projectUUID
config.Items = req.Items
config.TotalPrice = &total
config.CustomPrice = req.CustomPrice
config.Notes = req.Notes
config.IsTemplate = req.IsTemplate
config.ServerCount = req.ServerCount
config.ServerModel = req.ServerModel
config.SupportCode = req.SupportCode
config.Article = req.Article
config.PricelistID = pricelistID
config.WarehousePricelistID = req.WarehousePricelistID
config.CompetitorPricelistID = req.CompetitorPricelistID
config.DisablePriceRefresh = req.DisablePriceRefresh
config.OnlyInStock = req.OnlyInStock
if err := s.configRepo.Update(config); err != nil {
return nil, err
}
return config, nil
}
// DeleteNoAuth deletes configuration without ownership check
func (s *ConfigurationService) DeleteNoAuth(uuid string) error {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return ErrConfigNotFound
}
return s.configRepo.Delete(config.ID)
}
// RenameNoAuth renames configuration without ownership check
func (s *ConfigurationService) RenameNoAuth(uuid string, newName string) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
config.Name = newName
if err := s.configRepo.Update(config); err != nil {
return nil, err
}
return config, nil
}
// CloneNoAuth clones configuration without ownership check
func (s *ConfigurationService) CloneNoAuth(configUUID string, newName string, ownerUsername string) (*models.Configuration, error) {
return s.CloneNoAuthToProject(configUUID, newName, ownerUsername, nil)
}
func (s *ConfigurationService) CloneNoAuthToProject(configUUID string, newName string, ownerUsername string, projectUUID *string) (*models.Configuration, error) {
original, err := s.configRepo.GetByUUID(configUUID)
if err != nil {
return nil, ErrConfigNotFound
}
resolvedProjectUUID := original.ProjectUUID
if projectUUID != nil {
resolvedProjectUUID, err = s.resolveProjectUUID(ownerUsername, projectUUID)
if err != nil {
return nil, err
}
}
total := original.Items.Total()
if original.ServerCount > 1 {
total *= float64(original.ServerCount)
}
clone := &models.Configuration{
UUID: uuid.New().String(),
OwnerUsername: ownerUsername,
ProjectUUID: resolvedProjectUUID,
Name: newName,
Items: original.Items,
TotalPrice: &total,
CustomPrice: original.CustomPrice,
Notes: original.Notes,
IsTemplate: false,
ServerCount: original.ServerCount,
PricelistID: original.PricelistID,
OnlyInStock: original.OnlyInStock,
}
if err := s.configRepo.Create(clone); err != nil {
return nil, err
}
return clone, nil
}
func (s *ConfigurationService) resolveProjectUUID(ownerUsername string, projectUUID *string) (*string, error) {
_ = ownerUsername
if s.projectRepo == nil {
return projectUUID, nil
}
if projectUUID == nil || *projectUUID == "" {
return nil, nil
}
project, err := s.projectRepo.GetByUUID(*projectUUID)
if err != nil {
return nil, ErrProjectNotFound
}
if !project.IsActive {
return nil, errors.New("project is archived")
}
return &project.UUID, nil
}
func (s *ConfigurationService) resolvePricelistID(pricelistID *uint) (*uint, error) {
if s.pricelistRepo == nil {
return pricelistID, nil
}
if pricelistID != nil && *pricelistID > 0 {
if _, err := s.pricelistRepo.GetByID(*pricelistID); err != nil {
return nil, err
}
return pricelistID, nil
}
latest, err := s.pricelistRepo.GetLatestActive()
if err != nil {
return nil, nil
}
return &latest.ID, nil
}
// RefreshPricesNoAuth refreshes prices without ownership check
func (s *ConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
var latestPricelistID *uint
if s.pricelistRepo != nil {
if pl, err := s.pricelistRepo.GetLatestActive(); err == nil {
latestPricelistID = &pl.ID
}
}
updatedItems := make(models.ConfigItems, len(config.Items))
for i, item := range config.Items {
if latestPricelistID != nil {
if price, err := s.pricelistRepo.GetPriceForLot(*latestPricelistID, item.LotName); err == nil && price > 0 {
updatedItems[i] = models.ConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: price,
}
continue
}
}
if s.componentRepo == nil {
updatedItems[i] = item
continue
}
metadata, err := s.componentRepo.GetByLotName(item.LotName)
if err != nil || metadata.CurrentPrice == nil {
updatedItems[i] = item
continue
}
updatedItems[i] = models.ConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: *metadata.CurrentPrice,
}
}
config.Items = updatedItems
total := updatedItems.Total()
if config.ServerCount > 1 {
total *= float64(config.ServerCount)
}
config.TotalPrice = &total
if latestPricelistID != nil {
config.PricelistID = latestPricelistID
}
now := time.Now()
config.PriceUpdatedAt = &now
if err := s.configRepo.Update(config); err != nil {
return nil, err
}
return config, nil
}
func (s *ConfigurationService) ListTemplates(page, perPage int) ([]models.Configuration, int64, error) {
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
perPage = 20
}
offset := (page - 1) * perPage
return s.configRepo.ListTemplates(offset, perPage)
}
// RefreshPrices updates all component prices in the configuration with current prices
func (s *ConfigurationService) RefreshPrices(uuid string, ownerUsername string) (*models.Configuration, error) {
config, err := s.configRepo.GetByUUID(uuid)
if err != nil {
return nil, ErrConfigNotFound
}
if !s.isOwner(config, ownerUsername) {
return nil, ErrConfigForbidden
}
var latestPricelistID *uint
if s.pricelistRepo != nil {
if pl, err := s.pricelistRepo.GetLatestActive(); err == nil {
latestPricelistID = &pl.ID
}
}
// Update prices for all items
updatedItems := make(models.ConfigItems, len(config.Items))
for i, item := range config.Items {
if latestPricelistID != nil {
if price, err := s.pricelistRepo.GetPriceForLot(*latestPricelistID, item.LotName); err == nil && price > 0 {
updatedItems[i] = models.ConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: price,
}
continue
}
}
// Get current component price
if s.componentRepo == nil {
updatedItems[i] = item
continue
}
metadata, err := s.componentRepo.GetByLotName(item.LotName)
if err != nil || metadata.CurrentPrice == nil {
// Keep original item if component not found or no price available
updatedItems[i] = item
continue
}
// Update item with current price
updatedItems[i] = models.ConfigItem{
LotName: item.LotName,
Quantity: item.Quantity,
UnitPrice: *metadata.CurrentPrice,
}
}
// Update configuration
config.Items = updatedItems
total := updatedItems.Total()
// If server count is greater than 1, multiply the total by server count
if config.ServerCount > 1 {
total *= float64(config.ServerCount)
}
config.TotalPrice = &total
if latestPricelistID != nil {
config.PricelistID = latestPricelistID
}
// Set price update timestamp
now := time.Now()
config.PriceUpdatedAt = &now
if err := s.configRepo.Update(config); err != nil {
return nil, err
}
return config, nil
}
func (s *ConfigurationService) isOwner(config *models.Configuration, ownerUsername string) bool {
if config == nil || ownerUsername == "" {
return false
}
return config.OwnerUsername == ownerUsername
}
// // Export configuration as JSON
// type ConfigExport struct {
// Name string `json:"name"`
// Notes string `json:"notes"`
// Items models.ConfigItems `json:"items"`
// }
//
// func (s *ConfigurationService) ExportJSON(uuid string, userID uint) ([]byte, error) {
// config, err := s.GetByUUID(uuid, userID)
// if err != nil {
// return nil, err
// }
//
// export := ConfigExport{
// Name: config.Name,
// Notes: config.Notes,
// Items: config.Items,
// }
//
// return json.MarshalIndent(export, "", " ")
// }
//
// func (s *ConfigurationService) ImportJSON(userID uint, data []byte) (*models.Configuration, error) {
// var export ConfigExport
// if err := json.Unmarshal(data, &export); err != nil {
// return nil, err
// }
//
// req := &CreateConfigRequest{
// Name: export.Name,
// Notes: export.Notes,
// Items: export.Items,
// }
//
// return s.Create(userID, req)
// }

View File

@@ -380,7 +380,7 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
if opts.IncludeBOM && localCfg != nil && len(localCfg.VendorSpec) > 0 { if opts.IncludeBOM && localCfg != nil && len(localCfg.VendorSpec) > 0 {
coveredLots := make(map[string]struct{}) coveredLots := make(map[string]struct{})
for _, row := range localCfg.VendorSpec { for _, row := range localCfg.VendorSpec {
rowMappings := normalizeLotMappings(row.LotMappings) rowMappings := localdb.NormalizeLotMappings(row.LotMappings)
for _, mapping := range rowMappings { for _, mapping := range rowMappings {
coveredLots[mapping.LotName] = struct{}{} coveredLots[mapping.LotName] = struct{}{}
} }
@@ -424,21 +424,22 @@ func (s *ExportService) buildPricingExportBlock(cfg *models.Configuration, opts
} }
for _, item := range cfg.Items { for _, item := range cfg.Items {
if item.LotName == "" { lot := models.NormalizeLotName(item.LotName)
if lot == "" {
continue continue
} }
if _, ok := coveredLots[item.LotName]; ok { if _, ok := coveredLots[lot]; ok {
continue continue
} }
estimate := estimateOnlyTotal(priceMap[item.LotName].Estimate, item.UnitPrice, item.Quantity) estimate := estimateOnlyTotal(priceMap[lot].Estimate, item.UnitPrice, item.Quantity)
block.Rows = append(block.Rows, ProjectPricingExportRow{ block.Rows = append(block.Rows, ProjectPricingExportRow{
LotDisplay: item.LotName, LotDisplay: lot,
VendorPN: "—", VendorPN: "—",
Description: componentDescriptions[item.LotName], Description: componentDescriptions[lot],
Quantity: exportPositiveInt(item.Quantity, 1), Quantity: exportPositiveInt(item.Quantity, 1),
Estimate: estimate, Estimate: estimate,
Stock: totalForUnitPrice(priceMap[item.LotName].Stock, item.Quantity), Stock: totalForUnitPrice(priceMap[lot].Stock, item.Quantity),
Competitor: totalForUnitPrice(priceMap[item.LotName].Competitor, item.Quantity), Competitor: totalForUnitPrice(priceMap[lot].Competitor, item.Quantity),
}) })
} }
if opts.isDDP() { if opts.isDDP() {
@@ -665,7 +666,7 @@ func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfig
out := make([]string, 0) out := make([]string, 0)
if includeBOM && localCfg != nil { if includeBOM && localCfg != nil {
for _, row := range localCfg.VendorSpec { for _, row := range localCfg.VendorSpec {
for _, mapping := range normalizeLotMappings(row.LotMappings) { for _, mapping := range localdb.NormalizeLotMappings(row.LotMappings) {
if _, ok := seen[mapping.LotName]; ok { if _, ok := seen[mapping.LotName]; ok {
continue continue
} }
@@ -688,28 +689,6 @@ func collectPricingLots(cfg *models.Configuration, localCfg *localdb.LocalConfig
return out return out
} }
func normalizeLotMappings(mappings []localdb.VendorSpecLotMapping) []localdb.VendorSpecLotMapping {
if len(mappings) == 0 {
return nil
}
out := make([]localdb.VendorSpecLotMapping, 0, len(mappings))
for _, mapping := range mappings {
lot := strings.TrimSpace(mapping.LotName)
if lot == "" {
continue
}
qty := mapping.QuantityPerPN
if qty < 1 {
qty = 1
}
out = append(out, localdb.VendorSpecLotMapping{
LotName: lot,
QuantityPerPN: qty,
})
}
return out
}
func vendorRowTotal(row localdb.VendorSpecItem) *float64 { func vendorRowTotal(row localdb.VendorSpecItem) *float64 {
if row.TotalPrice != nil { if row.TotalPrice != nil {
return floatPtr(*row.TotalPrice) return floatPtr(*row.TotalPrice)
@@ -720,27 +699,6 @@ func vendorRowTotal(row localdb.VendorSpecItem) *float64 {
return floatPtr(*row.UnitPrice * float64(exportPositiveInt(row.Quantity, 1))) return floatPtr(*row.UnitPrice * float64(exportPositiveInt(row.Quantity, 1)))
} }
func computeMappingTotal(priceMap map[string]pricingLevels, mappings []localdb.VendorSpecLotMapping, pnQty int, selector func(pricingLevels) *float64) *float64 {
if len(mappings) == 0 {
return nil
}
total := 0.0
hasValue := false
qty := exportPositiveInt(pnQty, 1)
for _, mapping := range mappings {
price := selector(priceMap[mapping.LotName])
if price == nil || *price <= 0 {
continue
}
total += *price * float64(qty*mapping.QuantityPerPN)
hasValue = true
}
if !hasValue {
return nil
}
return floatPtr(total)
}
// distributeManualPrice sets ManualPrice on each row proportionally based on the // distributeManualPrice sets ManualPrice on each row proportionally based on the
// row's Estimate share. The last row with a price absorbs rounding remainder so // row's Estimate share. The last row with a price absorbs rounding remainder so
// the sum of ManualPrice values always equals manualPrice exactly. // the sum of ManualPrice values always equals manualPrice exactly.

View File

@@ -18,30 +18,20 @@ var (
) )
type QuoteService struct { type QuoteService struct {
componentRepo *repository.ComponentRepository
pricelistRepo *repository.PricelistRepository pricelistRepo *repository.PricelistRepository
localDB *localdb.LocalDB localDB *localdb.LocalDB
pricingService priceResolver
cacheMu sync.RWMutex cacheMu sync.RWMutex
priceCache map[string]cachedLotPrice priceCache map[string]cachedLotPrice
cacheTTL time.Duration cacheTTL time.Duration
} }
type priceResolver interface {
GetEffectivePrice(lotName string) (*float64, error)
}
func NewQuoteService( func NewQuoteService(
componentRepo *repository.ComponentRepository,
pricelistRepo *repository.PricelistRepository, pricelistRepo *repository.PricelistRepository,
localDB *localdb.LocalDB, localDB *localdb.LocalDB,
pricingService priceResolver,
) *QuoteService { ) *QuoteService {
return &QuoteService{ return &QuoteService{
componentRepo: componentRepo,
pricelistRepo: pricelistRepo, pricelistRepo: pricelistRepo,
localDB: localDB, localDB: localDB,
pricingService: pricingService,
priceCache: make(map[string]cachedLotPrice, 4096), priceCache: make(map[string]cachedLotPrice, 4096),
cacheTTL: 10 * time.Second, cacheTTL: 10 * time.Second,
} }
@@ -175,73 +165,7 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
return result, nil return result, nil
} }
if s.componentRepo == nil || s.pricingService == nil { return nil, errors.New("quote calculation requires local database")
return nil, errors.New("quote calculation not available")
}
result := &QuoteValidationResult{
Valid: true,
Items: make([]QuoteItem, 0, len(req.Items)),
Errors: make([]string, 0),
Warnings: make([]string, 0),
}
lotNames := make([]string, len(req.Items))
quantities := make(map[string]int)
for i, item := range req.Items {
lotNames[i] = item.LotName
quantities[item.LotName] = item.Quantity
}
components, err := s.componentRepo.GetMultiple(lotNames)
if err != nil {
return nil, err
}
componentMap := make(map[string]*models.LotMetadata)
for i := range components {
componentMap[components[i].LotName] = &components[i]
}
var total float64
for _, reqItem := range req.Items {
comp, exists := componentMap[reqItem.LotName]
if !exists {
result.Valid = false
result.Errors = append(result.Errors, "Component not found: "+reqItem.LotName)
continue
}
item := QuoteItem{
LotName: reqItem.LotName,
Quantity: reqItem.Quantity,
HasPrice: false,
}
if comp.Lot != nil {
item.Description = comp.Lot.LotDescription
}
if comp.Category != nil {
item.Category = comp.Category.Code
}
// Get effective price (override or calculated)
price, err := s.pricingService.GetEffectivePrice(reqItem.LotName)
if err == nil && price != nil && *price > 0 {
item.UnitPrice = *price
item.TotalPrice = *price * float64(reqItem.Quantity)
item.HasPrice = true
total += item.TotalPrice
} else {
result.Warnings = append(result.Warnings, "No price available for: "+reqItem.LotName)
}
result.Items = append(result.Items, item)
}
result.Total = total
return result, nil
} }
func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLevelsResult, error) { func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLevelsResult, error) {

View File

@@ -13,7 +13,7 @@ import (
func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) { func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) {
db := newPriceLevelsTestDB(t) db := newPriceLevelsTestDB(t)
repo := repository.NewPricelistRepository(db) repo := repository.NewPricelistRepository(db)
service := NewQuoteService(nil, repo, nil, nil) service := NewQuoteService(repo, nil)
estimate := seedPricelistWithItem(t, repo, "estimate", "CPU_X", 100) estimate := seedPricelistWithItem(t, repo, "estimate", "CPU_X", 100)
_ = estimate _ = estimate
@@ -57,7 +57,7 @@ func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) {
func TestCalculatePriceLevels_UsesExplicitPricelistIDs(t *testing.T) { func TestCalculatePriceLevels_UsesExplicitPricelistIDs(t *testing.T) {
db := newPriceLevelsTestDB(t) db := newPriceLevelsTestDB(t)
repo := repository.NewPricelistRepository(db) repo := repository.NewPricelistRepository(db)
service := NewQuoteService(nil, repo, nil, nil) service := NewQuoteService(repo, nil)
olderEstimate := seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 80) olderEstimate := seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 80)
seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 90) seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 90)

View File

@@ -0,0 +1,23 @@
# QuoteForge v2.22
Дата релиза: 2026-06-26
Тег: `v2.22`
## Что нового
### Исправления
- **MB-автокомплит в конфигураторе теперь работает в offline-режиме.** Корневая причина: прайслист мог быть синхронизирован до введения нормализации имён лотов, из-за чего SQLite хранил их в исходном регистре (`MB_AMD_2.Rome_...`). Запрос на поиск цены отправлял уже нормализованное имя (`MB_AMD_2.ROME_...`), `IN`-сравнение в SQLite регистрозависимо — совпадений не было, цена возвращалась как null, и автокомплит показывал пустой список. Все запросы к `local_pricelist_items` по `lot_name` переведены на `UPPER(lot_name)`.
- **Удалён мёртвый код инференса категории из имени лота.** Функция `getCategoryFromLotName` на фронтенде выводила категорию из префикса лота (`DKC_AFF_A1K``DKC`) как fallback. Категория всегда приходит из прайслиста; функция удалена. Позиции без категории корректно попадают во вкладку «Other».
- **Удалена таблица `local_components` и весь связанный с ней код.** Источник данных для компонентов — только `local_pricelist_items`. Убраны маршрут `POST /api/sync/components`, поля `ComponentsSynced` и `LastComponentSync` в ответах синхронизации.
- **Support bundle расширен диагностическими файлами:** `latest_pricelist_items.json` (все позиции активного estimate-прайслиста), `autocomplete_lots.json` (позиции по категориям с флагом `has_price`), `local.db` (полная копия SQLite-базы).
- **Регистронезависимые сравнения lot_name на фронтенде:** Set-коллекции для склада, добавленных позиций и корзины BOM теперь нормализуют ключи через `.toUpperCase()`.
## Запуск на macOS
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/qfs-darwin-arm64`
После этого бинарник запустится без предупреждения Gatekeeper.

View File

@@ -0,0 +1,23 @@
# QuoteForge v2.23
Дата релиза: 2026-06-26
Тег: `v2.23`
## Что нового
### Исправления
- **Конфигуратор больше не зависает на «Загрузка...».** При открытии сохранённой конфигурации поле `category` у позиций корзины было `undefined``config.items` хранятся только `lot_name/quantity/unit_price`), что приводило к `TypeError` в JS. Теперь после загрузки `allComponents` корзина обогащается категориями из справочника компонентов.
- **Регистронезависимые сравнения категорий в конфигураторе.** Все сравнения `category` переведены на хелпер `ciStr()` вместо принудительного `.toUpperCase()` — интерфейс показывает категории как есть, логика сравнения регистронезависима.
- **Вкладка Other показывает только компоненты без назначенной категории.** Исправлена ошибка при которой компоненты DKC/CTL/ENC попадали в Other при режиме «server»: `ASSIGNED_CATEGORIES` пересобирался из отфильтрованного списка, а не из полного статического. Теперь используется `_allCategories`.
- **Исправлена ошибка «record not found» при синхронизации проектов.** `UpsertByUUID` передавал ненулевой `ID` в `INSERT … ON DUPLICATE KEY UPDATE`, из-за чего MariaDB разрешала коллизию по первичному ключу чужой строки, не обновляя `uuid`, — последующий `SELECT` не находил запись. Теперь `project.ID` сбрасывается в `0` до вставки.
- **Устранён бесконечный retry при ошибках синхронизации на стороне сервера.** `RepairPendingChanges` сбрасывал счётчик попыток даже если локальные данные не менялись, что создавало бесконечный цикл при серверных ошибках. Repair-функции теперь возвращают `(bool, error)` и сброс происходит только при `modified=true`.
## Запуск на macOS
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/qfs-darwin-arm64`
После этого бинарник запустится без предупреждения Gatekeeper.

View File

@@ -0,0 +1,15 @@
# QuoteForge v2.24
Дата релиза: 2026-06-26
Тег: `v2.24`
## Что нового
### Исправления
- **Исправлен ReferenceError в конфигураторе при выборе компонента в секционированных вкладках (Storage, PCI).** Переменная `sectionCategories` не была определена — опечатка, должно быть `section.categories`. Ошибка возникала при клике на элемент автокомплита во вкладках с секциями.
## Запуск на macOS
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/qfs-darwin-arm64`
После этого бинарник запустится без предупреждения Gatekeeper.

View File

@@ -417,7 +417,7 @@ let TAB_CONFIG = {
let ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG) let ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
.flatMap(t => t.categories) .flatMap(t => t.categories)
.map(c => c.toUpperCase()); .map(c => ciStr(c));
// State // State
let configUUID = '{{.ConfigUUID}}'; let configUUID = '{{.ConfigUUID}}';
@@ -760,16 +760,16 @@ async function loadCategoriesFromAPI() {
// Build category order map // Build category order map
categoryOrderMap = {}; categoryOrderMap = {};
cats.forEach(cat => { cats.forEach(cat => {
categoryOrderMap[cat.code.toUpperCase()] = cat.display_order; categoryOrderMap[ciStr(cat.code)] = cat.display_order;
}); });
// Build list of unassigned categories // Build list of unassigned categories
const knownCodes = Object.values(TAB_CONFIG) const knownCodes = Object.values(TAB_CONFIG)
.flatMap(t => t.categories) .flatMap(t => t.categories)
.map(c => c.toUpperCase()); .map(c => ciStr(c));
const unassignedCategories = cats const unassignedCategories = cats
.filter(cat => !knownCodes.includes(cat.code.toUpperCase())) .filter(cat => !knownCodes.includes(ciStr(cat.code)))
.sort((a, b) => a.display_order - b.display_order) .sort((a, b) => a.display_order - b.display_order)
.map(cat => cat.code); .map(cat => cat.code);
@@ -779,7 +779,7 @@ async function loadCategoriesFromAPI() {
// Rebuild ASSIGNED_CATEGORIES // Rebuild ASSIGNED_CATEGORIES
ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG) ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
.flatMap(t => t.categories) .flatMap(t => t.categories)
.map(c => c.toUpperCase()); .map(c => ciStr(c));
} catch(e) { } catch(e) {
console.error('Failed to load categories, using defaults', e); console.error('Failed to load categories, using defaults', e);
// Will use default configuration if API fails // Will use default configuration if API fails
@@ -824,7 +824,7 @@ function applyServerSettings(settings) {
}; };
}); });
TAB_CONFIG.other = otherTab || { categories: [], singleSelect: false, label: 'Other' }; TAB_CONFIG.other = otherTab || { categories: [], singleSelect: false, label: 'Other' };
ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG).flatMap(t => t.categories).map(c => c.toUpperCase()); ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG).flatMap(t => t.categories).map(c => ciStr(c));
} }
// always_visible_tabs // always_visible_tabs
@@ -953,6 +953,10 @@ document.addEventListener('DOMContentLoaded', async function() {
loadAllComponents(), loadAllComponents(),
categoriesPromise, categoriesPromise,
]); ]);
cart = cart.map(item => ({
...item,
category: item.category || allComponents.find(c => c.lot_name.toUpperCase() === (item.lot_name || '').toUpperCase())?.category || ''
}));
syncPriceSettingsControls(); syncPriceSettingsControls();
renderPricelistSettingsSummary(); renderPricelistSettingsSummary();
updateRefreshPricesButtonState(); updateRefreshPricesButtonState();
@@ -1218,14 +1222,16 @@ function applyPriceSettings() {
schedulePriceLevelsRefresh({ delay: 0, rerender: true, autosave: true }); schedulePriceLevelsRefresh({ delay: 0, rerender: true, autosave: true });
} }
function ciStr(s) { return (s || '').toLowerCase(); }
function getComponentCategory(comp) { function getComponentCategory(comp) {
return (comp.category || '').toUpperCase(); return comp.category || '';
} }
function getTabForCategory(category) { function getTabForCategory(category) {
const cat = category.toUpperCase(); const cat = ciStr(category);
for (const [tabKey, tabConfig] of Object.entries(TAB_CONFIG)) { for (const [tabKey, tabConfig] of Object.entries(TAB_CONFIG)) {
if (tabConfig.categories.map(c => c.toUpperCase()).includes(cat)) { if (tabConfig.categories.some(c => ciStr(c) === cat)) {
return tabKey; return tabKey;
} }
} }
@@ -1303,10 +1309,11 @@ function applyConfigTypeToTabs() {
} }
}); });
// Rebuild assigned categories index // Rebuild assigned categories index using the full static list (_allCategories),
// not the filtered one — hidden categories still belong to their tab, not to Other.
ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG) ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
.flatMap(t => t.categories) .flatMap(t => t._allCategories || t.categories)
.map(c => c.toUpperCase()); .map(c => ciStr(c));
} }
function updateTabVisibility() { function updateTabVisibility() {
@@ -1317,8 +1324,7 @@ function updateTabVisibility() {
if (!btn) continue; if (!btn) continue;
const hasComponents = getComponentsForTab(tabId).length > 0; const hasComponents = getComponentsForTab(tabId).length > 0;
const hasCartItems = cart.some(item => { const hasCartItems = cart.some(item => {
const cat = (item.category || '').toUpperCase(); return getTabForCategory(item.category) === tabId;
return getTabForCategory(cat) === tabId;
}); });
const visible = hasComponents || hasCartItems; const visible = hasComponents || hasCartItems;
btn.classList.toggle('hidden', !visible); btn.classList.toggle('hidden', !visible);
@@ -1334,15 +1340,15 @@ function getComponentsForTab(tab) {
return allComponents.filter(comp => { return allComponents.filter(comp => {
const category = getComponentCategory(comp); const category = getComponentCategory(comp);
if (tab === 'other') { if (tab === 'other') {
return !ASSIGNED_CATEGORIES.includes(category); return !ASSIGNED_CATEGORIES.includes(ciStr(category));
} }
return config.categories.map(c => c.toUpperCase()).includes(category); return config.categories.some(c => ciStr(c) === ciStr(category));
}); });
} }
function getComponentsForCategory(category) { function getComponentsForCategory(category) {
return allComponents.filter(comp => { return allComponents.filter(comp => {
return getComponentCategory(comp) === category.toUpperCase(); return ciStr(getComponentCategory(comp)) === ciStr(category);
}); });
} }
@@ -1404,7 +1410,7 @@ function renderSingleSelectTab(categories) {
categories.forEach(cat => { categories.forEach(cat => {
const catLabel = cat === 'MB' ? 'MB' : cat === 'CPU' ? 'CPU' : cat === 'MEM' ? 'MEM' : cat; const catLabel = cat === 'MB' ? 'MB' : cat === 'CPU' ? 'CPU' : cat === 'MEM' ? 'MEM' : cat;
const selectedItem = cart.find(item => const selectedItem = cart.find(item =>
(item.category).toUpperCase() === cat.toUpperCase() ciStr(item.category) === ciStr(cat)
); );
const comp = selectedItem ? allComponents.find(c => c.lot_name.toUpperCase() === (selectedItem.lot_name || '').toUpperCase()) : null; const comp = selectedItem ? allComponents.find(c => c.lot_name.toUpperCase() === (selectedItem.lot_name || '').toUpperCase()) : null;
@@ -1457,9 +1463,7 @@ function renderSingleSelectTab(categories) {
function renderMultiSelectTab(components) { function renderMultiSelectTab(components) {
// Get cart items for this tab // Get cart items for this tab
const tabItems = cart.filter(item => { const tabItems = cart.filter(item => {
const cat = (item.category).toUpperCase(); return getTabForCategory(item.category) === currentTab;
const tab = getTabForCategory(cat);
return tab === currentTab;
}); });
let html = ` let html = `
@@ -1546,9 +1550,7 @@ function renderMultiSelectTab(components) {
function renderMultiSelectTabWithSections(sections) { function renderMultiSelectTabWithSections(sections) {
// Get cart items for this tab // Get cart items for this tab
const tabItems = cart.filter(item => { const tabItems = cart.filter(item => {
const cat = (item.category).toUpperCase(); return getTabForCategory(item.category) === currentTab;
const tab = getTabForCategory(cat);
return tab === currentTab;
}); });
let html = ''; let html = '';
@@ -1556,17 +1558,14 @@ function renderMultiSelectTabWithSections(sections) {
sections.forEach((section, sectionIdx) => { sections.forEach((section, sectionIdx) => {
// Get components for this section's categories // Get components for this section's categories
const sectionCategories = section.categories.map(c => c.toUpperCase());
const sectionComponents = allComponents.filter(comp => { const sectionComponents = allComponents.filter(comp => {
const category = getComponentCategory(comp); return section.categories.some(c => ciStr(c) === ciStr(getComponentCategory(comp)));
return sectionCategories.includes(category);
}); });
totalComponents += sectionComponents.length; totalComponents += sectionComponents.length;
// Get cart items for this section // Get cart items for this section
const sectionItems = tabItems.filter(item => { const sectionItems = tabItems.filter(item => {
const cat = (item.category).toUpperCase(); return section.categories.some(c => ciStr(c) === ciStr(item.category));
return sectionCategories.includes(cat);
}); });
// Section header // Section header
@@ -1806,7 +1805,7 @@ function selectAutocompleteItem(index) {
// Remove existing item of this category // Remove existing item of this category
cart = cart.filter(item => cart = cart.filter(item =>
(item.category).toUpperCase() !== autocompleteCategory.toUpperCase() ciStr(item.category) !== ciStr(autocompleteCategory)
); );
const qtyInput = document.getElementById('qty-' + autocompleteCategory); const qtyInput = document.getElementById('qty-' + autocompleteCategory);
@@ -2189,7 +2188,7 @@ function selectAutocompleteItemBOM(index, rowIdx) {
function clearSingleSelect(category) { function clearSingleSelect(category) {
cart = cart.filter(item => cart = cart.filter(item =>
(item.category).toUpperCase() !== category.toUpperCase() ciStr(item.category) !== ciStr(category)
); );
renderTab(); renderTab();
updateCartUI(); updateCartUI();
@@ -2199,7 +2198,7 @@ function clearSingleSelect(category) {
function updateSingleQuantity(category, value) { function updateSingleQuantity(category, value) {
const qty = parseInt(value) || 1; const qty = parseInt(value) || 1;
const item = cart.find(i => const item = cart.find(i =>
(i.category).toUpperCase() === category.toUpperCase() ciStr(i.category) === ciStr(category)
); );
if (item) { if (item) {
@@ -2258,8 +2257,8 @@ function updateCartUI() {
// Sort cart items by category display order // Sort cart items by category display order
const sortedCart = [...cart].sort((a, b) => { const sortedCart = [...cart].sort((a, b) => {
const catA = (a.category).toUpperCase(); const catA = ciStr(a.category);
const catB = (b.category).toUpperCase(); const catB = ciStr(b.category);
const orderA = categoryOrderMap[catA] || 9999; const orderA = categoryOrderMap[catA] || 9999;
const orderB = categoryOrderMap[catB] || 9999; const orderB = categoryOrderMap[catB] || 9999;
return orderA - orderB; return orderA - orderB;
@@ -2267,8 +2266,7 @@ function updateCartUI() {
const grouped = {}; const grouped = {};
sortedCart.forEach(item => { sortedCart.forEach(item => {
const cat = item.category; const tab = getTabForCategory(item.category);
const tab = getTabForCategory(cat);
if (!grouped[tab]) grouped[tab] = []; if (!grouped[tab]) grouped[tab] = [];
grouped[tab].push(item); grouped[tab].push(item);
}); });
@@ -2276,11 +2274,11 @@ function updateCartUI() {
// Sort tabs by minimum display order of their categories // Sort tabs by minimum display order of their categories
const sortedTabs = Object.entries(grouped).sort((a, b) => { const sortedTabs = Object.entries(grouped).sort((a, b) => {
const minOrderA = Math.min(...a[1].map(item => { const minOrderA = Math.min(...a[1].map(item => {
const cat = (item.category).toUpperCase(); const cat = ciStr(item.category);
return categoryOrderMap[cat] || 9999; return categoryOrderMap[cat] || 9999;
})); }));
const minOrderB = Math.min(...b[1].map(item => { const minOrderB = Math.min(...b[1].map(item => {
const cat = (item.category).toUpperCase(); const cat = ciStr(item.category);
return categoryOrderMap[cat] || 9999; return categoryOrderMap[cat] || 9999;
})); }));
return minOrderA - minOrderB; return minOrderA - minOrderB;
@@ -2731,8 +2729,8 @@ function renderSalePriceTable() {
} }
const sortedCart = [...cart].sort((a, b) => { const sortedCart = [...cart].sort((a, b) => {
const catA = (a.category).toUpperCase(); const catA = ciStr(a.category);
const catB = (b.category).toUpperCase(); const catB = ciStr(b.category);
const orderA = categoryOrderMap[catA] || 9999; const orderA = categoryOrderMap[catA] || 9999;
const orderB = categoryOrderMap[catB] || 9999; const orderB = categoryOrderMap[catB] || 9999;
return orderA - orderB; return orderA - orderB;
@@ -2835,8 +2833,8 @@ function calculateCustomPrice() {
// Build adjusted prices table // Build adjusted prices table
// Sort cart items by category display order // Sort cart items by category display order
const sortedCart = [...cart].sort((a, b) => { const sortedCart = [...cart].sort((a, b) => {
const catA = (a.category).toUpperCase(); const catA = ciStr(a.category);
const catB = (b.category).toUpperCase(); const catB = ciStr(b.category);
const orderA = categoryOrderMap[catA] || 9999; const orderA = categoryOrderMap[catA] || 9999;
const orderB = categoryOrderMap[catB] || 9999; const orderB = categoryOrderMap[catB] || 9999;
return orderA - orderB; return orderA - orderB;
@@ -3381,8 +3379,9 @@ function setBOMManualLotDraft(rowIdx, value, el) {
function _getRowAllocations(row) { function _getRowAllocations(row) {
const list = Array.isArray(row?.lot_allocations) ? row.lot_allocations : []; const list = Array.isArray(row?.lot_allocations) ? row.lot_allocations : [];
// Canonical LOT identity is UPPERCASE (see NormalizeLotName on the backend).
return list.map(a => ({ return list.map(a => ({
lot_name: (a?.lot_name || '').trim(), lot_name: (a?.lot_name || '').trim().toUpperCase(),
quantity: Math.max(1, parseInt(a?.quantity, 10) || 1) quantity: Math.max(1, parseInt(a?.quantity, 10) || 1)
})); }));
} }
@@ -3391,9 +3390,11 @@ function _getRowLotQtyPerPN(row) {
return (Number.isFinite(q) && q > 0) ? q : 1; return (Number.isFinite(q) && q > 0) ? q : 1;
} }
function _getRowBaseLot(row) { function _getRowBaseLot(row) {
if (row?.resolved_lot) return row.resolved_lot; // Canonical LOT identity is UPPERCASE (see NormalizeLotName on the backend).
const resolved = (row?.resolved_lot || '').trim();
if (resolved) return resolved.toUpperCase();
const manual = (row?.manual_lot || '').trim(); const manual = (row?.manual_lot || '').trim();
if (manual && _bomLotValid(manual)) return manual; if (manual && _bomLotValid(manual)) return manual.toUpperCase();
return ''; return '';
} }
function _getRowCanonicalLotMappings(row) { function _getRowCanonicalLotMappings(row) {
@@ -4041,40 +4042,39 @@ async function renderPricingTab() {
const tfootSale = document.getElementById('pricing-foot-sale'); const tfootSale = document.getElementById('pricing-foot-sale');
const cart = window._currentCart || []; const cart = window._currentCart || [];
// Canonical LOT key: matching/dedup must be case-insensitive (cart is UPPERCASE,
// BOM mappings may be mixed-case). See NormalizeLotName on the backend.
const U = s => (s || '').toUpperCase();
const compMap = {}; const compMap = {};
(window._bomAllComponents || allComponents).forEach(c => { compMap[c.lot_name] = c; }); (window._bomAllComponents || allComponents).forEach(c => { compMap[U(c.lot_name)] = c; });
const rowBaseLot = (row) => { const rowBaseLot = (row) => _getRowBaseLot(row);
if (row?.resolved_lot) return row.resolved_lot;
if (row?.manual_lot && _bomLotValid(row.manual_lot)) return row.manual_lot;
return '';
};
// Collect LOTs to price: from BOM rows (resolved) or from cart // Collect LOTs to price: from BOM rows (resolved) or from cart
// Use cart quantity when available (source of truth); fall back to BOM-computed quantity. // Use cart quantity when available (source of truth); fall back to BOM-computed quantity.
const _cartQtyMap = {}; const _cartQtyMap = {};
cart.forEach(item => { if (item?.lot_name) _cartQtyMap[item.lot_name] = item.quantity; }); cart.forEach(item => { if (item?.lot_name) _cartQtyMap[U(item.lot_name)] = item.quantity; });
let itemsForPriceLevels = []; let itemsForPriceLevels = [];
if (bomRows.length) { if (bomRows.length) {
const seen = new Set(); const seen = new Set();
bomRows.forEach(row => { bomRows.forEach(row => {
const baseLot = rowBaseLot(row); const baseLot = rowBaseLot(row);
const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1); const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1);
if (baseLot && !seen.has(baseLot)) { if (baseLot && !seen.has(U(baseLot))) {
seen.add(baseLot); seen.add(U(baseLot));
itemsForPriceLevels.push({ lot_name: baseLot, quantity: _cartQtyMap[baseLot] ?? (row.quantity * _getRowLotQtyPerPN(row)) }); itemsForPriceLevels.push({ lot_name: baseLot, quantity: _cartQtyMap[U(baseLot)] ?? (row.quantity * _getRowLotQtyPerPN(row)) });
} }
if (allocs.length) { if (allocs.length) {
allocs.forEach(a => { allocs.forEach(a => {
if (!seen.has(a.lot_name)) { if (!seen.has(U(a.lot_name))) {
seen.add(a.lot_name); seen.add(U(a.lot_name));
itemsForPriceLevels.push({ lot_name: a.lot_name, quantity: _cartQtyMap[a.lot_name] ?? (row.quantity * a.quantity) }); itemsForPriceLevels.push({ lot_name: a.lot_name, quantity: _cartQtyMap[U(a.lot_name)] ?? (row.quantity * a.quantity) });
} }
}); });
} }
}); });
cart.forEach(item => { cart.forEach(item => {
if (!item?.lot_name || seen.has(item.lot_name)) return; if (!item?.lot_name || seen.has(U(item.lot_name))) return;
seen.add(item.lot_name); seen.add(U(item.lot_name));
itemsForPriceLevels.push({ lot_name: item.lot_name, quantity: item.quantity }); itemsForPriceLevels.push({ lot_name: item.lot_name, quantity: item.quantity });
}); });
} else { } else {
@@ -4099,7 +4099,7 @@ async function renderPricingTab() {
}); });
if (resp.ok) { if (resp.ok) {
const data = await resp.json(); const data = await resp.json();
(data.items || []).forEach(i => { priceMap[i.lot_name] = i; }); (data.items || []).forEach(i => { priceMap[U(i.lot_name)] = i; });
} }
} catch(e) { /* silent */ } } catch(e) { /* silent */ }
} }
@@ -4121,19 +4121,19 @@ async function renderPricingTab() {
// ─── Build shared row data (unit prices for display, totals for math) ──── // ─── Build shared row data (unit prices for display, totals for math) ────
// Each BOM row is exploded into per-LOT sub-rows; grouped by vendor PN via groupStart/groupSize. // Each BOM row is exploded into per-LOT sub-rows; grouped by vendor PN via groupStart/groupSize.
const cartQtyMap = {}; const cartQtyMap = {};
cart.forEach(item => { if (item?.lot_name) cartQtyMap[item.lot_name] = item.quantity; }); cart.forEach(item => { if (item?.lot_name) cartQtyMap[U(item.lot_name)] = item.quantity; });
const _buildRows = () => { const _buildRows = () => {
const result = []; const result = [];
const coveredLots = new Set(); const coveredLots = new Set();
const _pushCartRow = (item, isEstOnly) => { const _pushCartRow = (item, isEstOnly) => {
const pl = priceMap[item.lot_name]; const pl = priceMap[U(item.lot_name)];
const u = _getUnitPrices(pl); const u = _getUnitPrices(pl);
const estUnit = u.estUnit > 0 ? u.estUnit : (item.unit_price || 0); const estUnit = u.estUnit > 0 ? u.estUnit : (item.unit_price || 0);
result.push({ result.push({
lotCell: escapeHtml(item.lot_name), lotText: item.lot_name, lotCell: escapeHtml(item.lot_name), lotText: item.lot_name,
vendorPN: null, vendorPN: null,
desc: (compMap[item.lot_name] || {}).description || '', desc: (compMap[U(item.lot_name)] || {}).description || '',
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,
@@ -4146,32 +4146,32 @@ async function renderPricingTab() {
if (!bomRows.length) { if (!bomRows.length) {
const sortedByCategory = [...cart].sort((a, b) => { const sortedByCategory = [...cart].sort((a, b) => {
const catA = (a.category).toUpperCase(); const catA = ciStr(a.category);
const catB = (b.category).toUpperCase(); const catB = ciStr(b.category);
return (categoryOrderMap[catA] || 9999) - (categoryOrderMap[catB] || 9999); return (categoryOrderMap[catA] || 9999) - (categoryOrderMap[catB] || 9999);
}); });
sortedByCategory.forEach(item => { _pushCartRow(item, false); coveredLots.add(item.lot_name); }); sortedByCategory.forEach(item => { _pushCartRow(item, false); coveredLots.add(U(item.lot_name)); });
return { result, coveredLots }; return { result, coveredLots };
} }
bomRows.forEach(row => { bomRows.forEach(row => {
const baseLot = rowBaseLot(row); const baseLot = rowBaseLot(row);
const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1); const allocs = _getRowAllocations(row).filter(a => a.lot_name && _bomLotValid(a.lot_name) && a.quantity >= 1);
if (baseLot) coveredLots.add(baseLot); if (baseLot) coveredLots.add(U(baseLot));
allocs.forEach(a => coveredLots.add(a.lot_name)); allocs.forEach(a => coveredLots.add(U(a.lot_name)));
const vendorOrigUnit = row.unit_price != null ? row.unit_price const vendorOrigUnit = row.unit_price != null ? row.unit_price
: (row.total_price != null && row.quantity > 0 ? row.total_price / row.quantity : null); : (row.total_price != null && row.quantity > 0 ? row.total_price / row.quantity : null);
const vendorOrig = row.total_price != null ? row.total_price const vendorOrig = row.total_price != null ? row.total_price
: (row.unit_price != null ? row.unit_price * row.quantity : null); : (row.unit_price != null ? row.unit_price * row.quantity : null);
const desc = row.description || (baseLot ? ((compMap[baseLot] || {}).description || '') : ''); const desc = row.description || (baseLot ? ((compMap[U(baseLot)] || {}).description || '') : '');
// Build per-LOT sub-rows // Build per-LOT sub-rows
const subRows = []; const subRows = [];
if (baseLot) { if (baseLot) {
const u = _getUnitPrices(priceMap[baseLot]); const u = _getUnitPrices(priceMap[U(baseLot)]);
const lotQty = _getRowLotQtyPerPN(row); const lotQty = _getRowLotQtyPerPN(row);
const qty = cartQtyMap[baseLot] ?? (row.quantity * lotQty); const qty = cartQtyMap[U(baseLot)] ?? (row.quantity * lotQty);
subRows.push({ subRows.push({
lotCell: escapeHtml(baseLot), lotText: baseLot, qty, lotCell: escapeHtml(baseLot), lotText: baseLot, qty,
estUnit: u.estUnit > 0 ? u.estUnit : 0, estUnit: u.estUnit > 0 ? u.estUnit : 0,
@@ -4182,8 +4182,8 @@ async function renderPricingTab() {
}); });
} }
allocs.forEach(a => { allocs.forEach(a => {
const u = _getUnitPrices(priceMap[a.lot_name]); const u = _getUnitPrices(priceMap[U(a.lot_name)]);
const qty = cartQtyMap[a.lot_name] ?? (row.quantity * a.quantity); const qty = cartQtyMap[U(a.lot_name)] ?? (row.quantity * a.quantity);
subRows.push({ subRows.push({
lotCell: escapeHtml(a.lot_name), lotText: a.lot_name, qty, lotCell: escapeHtml(a.lot_name), lotText: a.lot_name, qty,
estUnit: u.estUnit > 0 ? u.estUnit : 0, estUnit: u.estUnit > 0 ? u.estUnit : 0,
@@ -4225,9 +4225,9 @@ async function renderPricingTab() {
// Estimate-only LOTs (cart items not covered by BOM) // Estimate-only LOTs (cart items not covered by BOM)
cart.forEach(item => { cart.forEach(item => {
if (!item?.lot_name || coveredLots.has(item.lot_name)) return; if (!item?.lot_name || coveredLots.has(U(item.lot_name))) return;
_pushCartRow(item, true); _pushCartRow(item, true);
coveredLots.add(item.lot_name); coveredLots.add(U(item.lot_name));
}); });
return { result, coveredLots }; return { result, coveredLots };