Compare commits
7 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9601619d1b | ||
|
|
f24584f65c | ||
|
|
f6766ce6b8 | ||
|
|
464d2a48d7 | ||
|
|
b23eb1d75a | ||
|
|
cc72052c8a | ||
|
|
687ab99d85 |
@@ -25,6 +25,7 @@ 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 and component catalog; do not add a separate component cache table;
|
- `local_pricelist_items` is the only valid runtime source of prices and component catalog; do not add a separate component cache table;
|
||||||
|
- `local_pricelist_items.lot_category` is the single source of a LOT's category at runtime (populated by sync from `qt_pricelist_items.lot_category`); do not derive category from a lot_name prefix or from `qt_categories`/`qt_lot_metadata`;
|
||||||
- configuration `items` and `vendor_spec` are stored as JSON payloads inside configuration rows;
|
- configuration `items` and `vendor_spec` are stored as JSON payloads inside configuration rows;
|
||||||
- `local_components` table has been removed; any reference to it is dead code.
|
- `local_components` table has been removed; any reference to it is dead code.
|
||||||
|
|
||||||
@@ -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`)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -1,31 +1,12 @@
|
|||||||
package article
|
package article
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"fmt"
|
"fmt"
|
||||||
"strings"
|
"strings"
|
||||||
|
|
||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"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
|
type Group string
|
||||||
|
|
||||||
const (
|
const (
|
||||||
@@ -61,9 +42,10 @@ func GroupForLotCategory(cat string) (group Group, ok bool) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ResolveLotCategoriesStrict resolves categories for lotNames using local_pricelist_items.lot_category
|
// ResolveLotCategories returns lot_category for each lotName found in local_pricelist_items
|
||||||
// for a given server pricelist id. If any lot is missing or has empty category, returns an error.
|
// for the given server pricelist. Lots not found in the pricelist are omitted from the result —
|
||||||
func ResolveLotCategoriesStrict(local *localdb.LocalDB, serverPricelistID uint, lotNames []string) (map[string]string, error) {
|
// 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 {
|
if local == nil {
|
||||||
return nil, fmt.Errorf("local db is nil")
|
return nil, fmt.Errorf("local db is nil")
|
||||||
}
|
}
|
||||||
@@ -71,30 +53,8 @@ func ResolveLotCategoriesStrict(local *localdb.LocalDB, serverPricelistID uint,
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
missing := make([]string, 0)
|
for lot, cat := range cats {
|
||||||
for _, lot := range lotNames {
|
cats[lot] = strings.TrimSpace(cat)
|
||||||
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}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
return cats, nil
|
return cats, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
package article
|
package article
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"errors"
|
|
||||||
"path/filepath"
|
"path/filepath"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
@@ -9,7 +8,7 @@ import (
|
|||||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
"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"))
|
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("init local db: %v", err)
|
t.Fatalf("init local db: %v", err)
|
||||||
@@ -36,73 +35,60 @@ func TestResolveLotCategoriesStrict_MissingCategoryReturnsError(t *testing.T) {
|
|||||||
t.Fatalf("save local items: %v", err)
|
t.Fatalf("save local items: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
_, err = ResolveLotCategoriesStrict(local, 1, []string{"CPU_A"})
|
cats, err := ResolveLotCategories(local, 1, []string{"CPU_A", "UNKNOWN"})
|
||||||
if err == nil {
|
if err != nil {
|
||||||
t.Fatalf("expected error")
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
if !errors.Is(err, ErrMissingCategoryForLot) {
|
if cats["CPU_A"] != "" {
|
||||||
t.Fatalf("expected ErrMissingCategoryForLot, got %v", err)
|
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"))
|
local, err := localdb.New(filepath.Join(t.TempDir(), "local.db"))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Fatalf("init local db: %v", err)
|
t.Fatalf("init local db: %v", err)
|
||||||
}
|
}
|
||||||
t.Cleanup(func() { _ = local.Close() })
|
t.Cleanup(func() { _ = local.Close() })
|
||||||
|
|
||||||
// Older pricelist used by the configuration — CPU_B has no category here
|
|
||||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||||
ServerID: 2,
|
ServerID: 1,
|
||||||
Source: "estimate",
|
Source: "estimate",
|
||||||
Version: "S-2026-02-11-002",
|
Version: "S-2026-02-11-001",
|
||||||
Name: "old",
|
Name: "test",
|
||||||
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",
|
|
||||||
IsActive: true,
|
IsActive: true,
|
||||||
CreatedAt: time.Now(),
|
CreatedAt: time.Now(),
|
||||||
SyncedAt: time.Now(),
|
SyncedAt: time.Now(),
|
||||||
}); err != nil {
|
}); 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 {
|
if err != nil {
|
||||||
t.Fatalf("get latest pricelist: %v", err)
|
t.Fatalf("get pricelist: %v", err)
|
||||||
}
|
}
|
||||||
if err := local.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
|
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 {
|
}); 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 {
|
if err != nil {
|
||||||
t.Fatalf("expected fallback, got error: %v", err)
|
t.Fatalf("unexpected error: %v", err)
|
||||||
}
|
}
|
||||||
if cats["CPU_B"] != "CPU" {
|
if cats["CPU_B"] != "CPU" {
|
||||||
t.Fatalf("expected CPU, got %q", cats["CPU_B"])
|
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) {
|
func TestGroupForLotCategory(t *testing.T) {
|
||||||
|
|||||||
@@ -55,7 +55,7 @@ func Build(local *localdb.LocalDB, items []models.ConfigItem, opts BuildOptions)
|
|||||||
return BuildResult{}, fmt.Errorf("pricelist_id required for article")
|
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 {
|
if err != nil {
|
||||||
return BuildResult{}, err
|
return BuildResult{}, err
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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,41 +330,3 @@ 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
|
|
||||||
func LocalToComponent(local *LocalComponent) *models.LotMetadata {
|
|
||||||
return &models.LotMetadata{
|
|
||||||
LotName: local.LotName,
|
|
||||||
Model: local.Model,
|
|
||||||
Lot: &models.Lot{
|
|
||||||
LotName: local.LotName,
|
|
||||||
LotDescription: local.LotDescription,
|
|
||||||
},
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -1,92 +0,0 @@
|
|||||||
package models
|
|
||||||
|
|
||||||
import (
|
|
||||||
"database/sql/driver"
|
|
||||||
"encoding/json"
|
|
||||||
"errors"
|
|
||||||
"time"
|
|
||||||
)
|
|
||||||
|
|
||||||
type PriceMethod string
|
|
||||||
|
|
||||||
const (
|
|
||||||
PriceMethodManual PriceMethod = "manual"
|
|
||||||
PriceMethodMedian PriceMethod = "median"
|
|
||||||
PriceMethodAverage PriceMethod = "average"
|
|
||||||
PriceMethodWeightedMedian PriceMethod = "weighted_median"
|
|
||||||
)
|
|
||||||
|
|
||||||
type Specs map[string]interface{}
|
|
||||||
|
|
||||||
func (s Specs) Value() (driver.Value, error) {
|
|
||||||
return json.Marshal(s)
|
|
||||||
}
|
|
||||||
|
|
||||||
func (s *Specs) Scan(value interface{}) error {
|
|
||||||
if value == nil {
|
|
||||||
*s = make(Specs)
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
bytes, ok := value.([]byte)
|
|
||||||
if !ok {
|
|
||||||
return errors.New("type assertion to []byte failed")
|
|
||||||
}
|
|
||||||
return json.Unmarshal(bytes, s)
|
|
||||||
}
|
|
||||||
|
|
||||||
type LotMetadata struct {
|
|
||||||
LotName string `gorm:"column:lot_name;primaryKey;size:255" json:"lot_name"`
|
|
||||||
CategoryID *uint `gorm:"column:category_id" json:"category_id"`
|
|
||||||
Model string `gorm:"size:100" json:"model"`
|
|
||||||
Specs Specs `gorm:"type:json" json:"specs"`
|
|
||||||
CurrentPrice *float64 `gorm:"type:decimal(12,2)" json:"current_price"`
|
|
||||||
PriceMethod PriceMethod `gorm:"type:enum('manual','median','average','weighted_median');default:'median'" json:"price_method"`
|
|
||||||
PricePeriodDays int `gorm:"default:90" json:"price_period_days"`
|
|
||||||
PriceCoefficient float64 `gorm:"type:decimal(5,2);default:0" json:"price_coefficient"`
|
|
||||||
ManualPrice *float64 `gorm:"type:decimal(12,2)" json:"manual_price"`
|
|
||||||
PriceUpdatedAt *time.Time `json:"price_updated_at"`
|
|
||||||
RequestCount int `gorm:"default:0" json:"request_count"`
|
|
||||||
LastRequestDate *time.Time `gorm:"type:date" json:"last_request_date"`
|
|
||||||
PopularityScore float64 `gorm:"type:decimal(10,4);default:0" json:"popularity_score"`
|
|
||||||
MetaPrices string `gorm:"size:1000" json:"meta_prices"`
|
|
||||||
MetaMethod string `gorm:"size:20" json:"meta_method"`
|
|
||||||
MetaPeriodDays int `gorm:"default:90" json:"meta_period_days"`
|
|
||||||
IsHidden bool `gorm:"default:false" json:"is_hidden"`
|
|
||||||
|
|
||||||
// Relations
|
|
||||||
Lot *Lot `gorm:"foreignKey:LotName;references:LotName" json:"lot,omitempty"`
|
|
||||||
Category *Category `gorm:"foreignKey:CategoryID" json:"category,omitempty"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func (LotMetadata) TableName() string {
|
|
||||||
return "qt_lot_metadata"
|
|
||||||
}
|
|
||||||
|
|
||||||
type PriceFreshness string
|
|
||||||
|
|
||||||
const (
|
|
||||||
FreshnessFresh PriceFreshness = "fresh"
|
|
||||||
FreshnessNormal PriceFreshness = "normal"
|
|
||||||
FreshnessStale PriceFreshness = "stale"
|
|
||||||
FreshnessCritical PriceFreshness = "critical"
|
|
||||||
)
|
|
||||||
|
|
||||||
func (m *LotMetadata) GetPriceFreshness(greenDays, yellowDays, redDays, minQuotes int) PriceFreshness {
|
|
||||||
if m.CurrentPrice == nil || *m.CurrentPrice == 0 {
|
|
||||||
return FreshnessCritical
|
|
||||||
}
|
|
||||||
if m.PriceUpdatedAt == nil {
|
|
||||||
return FreshnessCritical
|
|
||||||
}
|
|
||||||
|
|
||||||
daysSince := int(time.Since(*m.PriceUpdatedAt).Hours() / 24)
|
|
||||||
|
|
||||||
if daysSince < greenDays && m.RequestCount >= minQuotes {
|
|
||||||
return FreshnessFresh
|
|
||||||
} else if daysSince < yellowDays {
|
|
||||||
return FreshnessNormal
|
|
||||||
} else if daysSince < redDays {
|
|
||||||
return FreshnessStale
|
|
||||||
}
|
|
||||||
return FreshnessCritical
|
|
||||||
}
|
|
||||||
@@ -11,7 +11,6 @@ import (
|
|||||||
func AllModels() []interface{} {
|
func AllModels() []interface{} {
|
||||||
return []interface{}{
|
return []interface{}{
|
||||||
&Category{},
|
&Category{},
|
||||||
&LotMetadata{},
|
|
||||||
&Project{},
|
&Project{},
|
||||||
&Configuration{},
|
&Configuration{},
|
||||||
&Pricelist{},
|
&Pricelist{},
|
||||||
|
|||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -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
|
|
||||||
}
|
|
||||||
@@ -1,43 +1,5 @@
|
|||||||
package services
|
package services
|
||||||
|
|
||||||
import (
|
|
||||||
"fmt"
|
|
||||||
"log/slog"
|
|
||||||
"strings"
|
|
||||||
|
|
||||||
"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"`
|
||||||
@@ -52,173 +14,4 @@ type ComponentView struct {
|
|||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
CategoryName string `json:"category_name"`
|
CategoryName string `json:"category_name"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
PriceFreshness models.PriceFreshness `json:"price_freshness"`
|
|
||||||
PopularityScore float64 `json:"popularity_score"`
|
|
||||||
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
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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)
|
|
||||||
// }
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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()
|
latest, err := s.localDB.GetLatestLocalPricelist()
|
||||||
|
|||||||
@@ -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) {
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
15
releases/v2.24/RELEASE_NOTES.md
Normal file
15
releases/v2.24/RELEASE_NOTES.md
Normal 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.
|
||||||
17
releases/v2.25/RELEASE_NOTES.md
Normal file
17
releases/v2.25/RELEASE_NOTES.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# QuoteForge v2.25
|
||||||
|
|
||||||
|
Дата релиза: 2026-06-29
|
||||||
|
Тег: `v2.25`
|
||||||
|
|
||||||
|
Предыдущий релиз: `v2.24`
|
||||||
|
|
||||||
|
## Ключевые изменения
|
||||||
|
|
||||||
|
- исправлено дублирование позиций в таблице «Цена покупки» и в экспорте CSV: сопоставление LOT между BOM и корзиной теперь регистронезависимое;
|
||||||
|
- нормализация LOT-маппингов BOM сведена в единую каноничную функцию на бэкенде (UPPERCASE + схлопывание дублей) — устранены разошедшиеся копии, дававшие разный результат на фронте и в CSV;
|
||||||
|
- единый источник категории LOT — `local_pricelist_items.lot_category`; удалён неиспользуемый серверный слой управления компонентами/категориями.
|
||||||
|
|
||||||
|
## Запуск на macOS
|
||||||
|
|
||||||
|
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/qfs-darwin-arm64`
|
||||||
|
После этого бинарник запустится без предупреждения Gatekeeper.
|
||||||
17
releases/v2.26/RELEASE_NOTES.md
Normal file
17
releases/v2.26/RELEASE_NOTES.md
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# QuoteForge v2.26
|
||||||
|
|
||||||
|
Дата релиза: 2026-06-29
|
||||||
|
Тег: `v2.26`
|
||||||
|
|
||||||
|
Предыдущий релиз: `v2.25`
|
||||||
|
|
||||||
|
## Ключевые изменения
|
||||||
|
|
||||||
|
- fix: лоты, отсутствующие в текущем прайслисте, больше не блокируют сохранение конфига и генерацию артикула — такие лоты просто пропускаются;
|
||||||
|
- fix: если прайслист конфига удалён с сервера, автоматически выбирается последний активный;
|
||||||
|
- refactor: удалён мёртвый код qt_lot_metadata;
|
||||||
|
|
||||||
|
## Запуск на macOS
|
||||||
|
|
||||||
|
Снимите карантинный атрибут через терминал: `xattr -d com.apple.quarantine /path/to/qfs-darwin-arm64`
|
||||||
|
После этого бинарник запустится без предупреждения Gatekeeper.
|
||||||
@@ -3379,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)
|
||||||
}));
|
}));
|
||||||
}
|
}
|
||||||
@@ -3389,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) {
|
||||||
@@ -4039,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 {
|
||||||
@@ -4097,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 */ }
|
||||||
}
|
}
|
||||||
@@ -4119,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,
|
||||||
@@ -4148,28 +4150,28 @@ async function renderPricingTab() {
|
|||||||
const catB = ciStr(b.category);
|
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,
|
||||||
@@ -4180,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,
|
||||||
@@ -4223,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 };
|
||||||
|
|||||||
Reference in New Issue
Block a user