diff --git a/internal/article/categories.go b/internal/article/categories.go index f611b58..9f22d93 100644 --- a/internal/article/categories.go +++ b/internal/article/categories.go @@ -1,31 +1,12 @@ package article import ( - "errors" "fmt" "strings" "git.mchus.pro/mchus/quoteforge/internal/localdb" ) -// ErrMissingCategoryForLot is returned when a lot has no category in local_pricelist_items.lot_category. -var ErrMissingCategoryForLot = errors.New("missing_category_for_lot") - -type MissingCategoryForLotError struct { - LotName string -} - -func (e *MissingCategoryForLotError) Error() string { - if e == nil || strings.TrimSpace(e.LotName) == "" { - return ErrMissingCategoryForLot.Error() - } - return fmt.Sprintf("%s: %s", ErrMissingCategoryForLot.Error(), e.LotName) -} - -func (e *MissingCategoryForLotError) Unwrap() error { - return ErrMissingCategoryForLot -} - type Group string const ( @@ -61,9 +42,10 @@ func GroupForLotCategory(cat string) (group Group, ok bool) { } } -// ResolveLotCategoriesStrict resolves categories for lotNames using local_pricelist_items.lot_category -// for a given server pricelist id. If any lot is missing or has empty category, returns an error. -func ResolveLotCategoriesStrict(local *localdb.LocalDB, serverPricelistID uint, lotNames []string) (map[string]string, error) { +// ResolveLotCategories returns lot_category for each lotName found in local_pricelist_items +// for the given server pricelist. Lots not found in the pricelist are omitted from the result — +// callers must treat a missing key as "no category" and skip that lot. +func ResolveLotCategories(local *localdb.LocalDB, serverPricelistID uint, lotNames []string) (map[string]string, error) { if local == nil { return nil, fmt.Errorf("local db is nil") } @@ -71,30 +53,8 @@ func ResolveLotCategoriesStrict(local *localdb.LocalDB, serverPricelistID uint, if err != nil { return nil, err } - missing := make([]string, 0) - for _, lot := range lotNames { - cat := strings.TrimSpace(cats[lot]) - if cat == "" { - missing = append(missing, lot) - continue - } - cats[lot] = cat - } - if len(missing) > 0 { - fallback, err := local.GetLocalComponentCategoriesByLotNames(missing) - if err != nil { - return nil, err - } - for _, lot := range missing { - if cat := strings.TrimSpace(fallback[lot]); cat != "" { - cats[lot] = cat - } - } - for _, lot := range missing { - if strings.TrimSpace(cats[lot]) == "" { - return nil, &MissingCategoryForLotError{LotName: lot} - } - } + for lot, cat := range cats { + cats[lot] = strings.TrimSpace(cat) } return cats, nil } diff --git a/internal/article/categories_test.go b/internal/article/categories_test.go index 1df668c..9d21cd2 100644 --- a/internal/article/categories_test.go +++ b/internal/article/categories_test.go @@ -1,7 +1,6 @@ package article import ( - "errors" "path/filepath" "testing" "time" @@ -9,7 +8,7 @@ import ( "git.mchus.pro/mchus/quoteforge/internal/localdb" ) -func TestResolveLotCategoriesStrict_MissingCategoryReturnsError(t *testing.T) { +func TestResolveLotCategories_MissingLotOmitted(t *testing.T) { local, err := localdb.New(filepath.Join(t.TempDir(), "local.db")) if err != nil { t.Fatalf("init local db: %v", err) @@ -36,73 +35,60 @@ func TestResolveLotCategoriesStrict_MissingCategoryReturnsError(t *testing.T) { t.Fatalf("save local items: %v", err) } - _, err = ResolveLotCategoriesStrict(local, 1, []string{"CPU_A"}) - if err == nil { - t.Fatalf("expected error") + cats, err := ResolveLotCategories(local, 1, []string{"CPU_A", "UNKNOWN"}) + if err != nil { + t.Fatalf("unexpected error: %v", err) } - if !errors.Is(err, ErrMissingCategoryForLot) { - t.Fatalf("expected ErrMissingCategoryForLot, got %v", err) + if cats["CPU_A"] != "" { + t.Fatalf("expected empty category for lot with blank lot_category, got %q", cats["CPU_A"]) + } + if _, ok := cats["UNKNOWN"]; ok { + t.Fatalf("expected UNKNOWN lot to be omitted from result") } } -func TestResolveLotCategoriesStrict_FallbackToLatestPricelist(t *testing.T) { +func TestResolveLotCategories_ReturnsKnownCategories(t *testing.T) { local, err := localdb.New(filepath.Join(t.TempDir(), "local.db")) if err != nil { t.Fatalf("init local db: %v", err) } t.Cleanup(func() { _ = local.Close() }) - // Older pricelist used by the configuration — CPU_B has no category here if err := local.SaveLocalPricelist(&localdb.LocalPricelist{ - ServerID: 2, + ServerID: 1, Source: "estimate", - Version: "S-2026-02-11-002", - Name: "old", - IsActive: false, - CreatedAt: time.Now().Add(-time.Hour), - SyncedAt: time.Now().Add(-time.Hour), - }); err != nil { - t.Fatalf("save old pricelist: %v", err) - } - oldPL, err := local.GetLocalPricelistByServerID(2) - if err != nil { - t.Fatalf("get old pricelist: %v", err) - } - if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{ - {PricelistID: oldPL.ID, LotName: "CPU_B", LotCategory: "", Price: 10}, - }); err != nil { - t.Fatalf("save old pricelist items: %v", err) - } - - // Newer active pricelist — CPU_B has category set - if err := local.SaveLocalPricelist(&localdb.LocalPricelist{ - ServerID: 3, - Source: "estimate", - Version: "S-2026-02-11-003", - Name: "latest", + Version: "S-2026-02-11-001", + Name: "test", IsActive: true, CreatedAt: time.Now(), SyncedAt: time.Now(), }); err != nil { - t.Fatalf("save latest pricelist: %v", err) + t.Fatalf("save pricelist: %v", err) } - latestPL, err := local.GetLocalPricelistByServerID(3) + pl, err := local.GetLocalPricelistByServerID(1) if err != nil { - t.Fatalf("get latest pricelist: %v", err) + t.Fatalf("get pricelist: %v", err) } if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{ - {PricelistID: latestPL.ID, LotName: "CPU_B", LotCategory: "CPU", Price: 10}, + {PricelistID: pl.ID, LotName: "CPU_B", LotCategory: "CPU", Price: 10}, + {PricelistID: pl.ID, LotName: "MB_X", LotCategory: "MB", Price: 5}, }); err != nil { - t.Fatalf("save latest pricelist items: %v", err) + t.Fatalf("save items: %v", err) } - cats, err := ResolveLotCategoriesStrict(local, 2, []string{"CPU_B"}) + cats, err := ResolveLotCategories(local, 1, []string{"CPU_B", "MB_X", "NOT_IN_PL"}) if err != nil { - t.Fatalf("expected fallback, got error: %v", err) + t.Fatalf("unexpected error: %v", err) } if cats["CPU_B"] != "CPU" { t.Fatalf("expected CPU, got %q", cats["CPU_B"]) } + if cats["MB_X"] != "MB" { + t.Fatalf("expected MB, got %q", cats["MB_X"]) + } + if _, ok := cats["NOT_IN_PL"]; ok { + t.Fatalf("expected NOT_IN_PL to be omitted") + } } func TestGroupForLotCategory(t *testing.T) { diff --git a/internal/article/generator.go b/internal/article/generator.go index 1a63742..897eb59 100644 --- a/internal/article/generator.go +++ b/internal/article/generator.go @@ -55,7 +55,7 @@ func Build(local *localdb.LocalDB, items []models.ConfigItem, opts BuildOptions) return BuildResult{}, fmt.Errorf("pricelist_id required for article") } - cats, err := ResolveLotCategoriesStrict(local, *opts.ServerPricelist, lotNames) + cats, err := ResolveLotCategories(local, *opts.ServerPricelist, lotNames) if err != nil { return BuildResult{}, err } diff --git a/internal/services/local_configuration.go b/internal/services/local_configuration.go index 7cbe966..bf02b56 100644 --- a/internal/services/local_configuration.go +++ b/internal/services/local_configuration.go @@ -1813,7 +1813,8 @@ func (s *LocalConfigurationService) resolvePricelistID(pricelistID *uint) (*uint } } } - return nil, fmt.Errorf("pricelist %d not available locally", *pricelistID) + // Pricelist not found even after sync — fall back to the latest active one. + slog.Warn("pricelist not available locally, falling back to latest active", "server_pricelist_id", *pricelistID) } latest, err := s.localDB.GetLatestLocalPricelist()