Add vendor workspace import and pricing export workflow
This commit is contained in:
@@ -14,7 +14,7 @@ type SeenPartnumber struct {
|
||||
}
|
||||
|
||||
// PushPartnumberSeen inserts unresolved vendor partnumbers into qt_vendor_partnumber_seen on MariaDB.
|
||||
// Uses INSERT ... ON DUPLICATE KEY UPDATE so existing rows are updated (last_seen_at) without error.
|
||||
// Existing rows are left untouched: no updates to last_seen_at, is_ignored, or description.
|
||||
func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
@@ -36,12 +36,10 @@ func (s *Service) PushPartnumberSeen(items []SeenPartnumber) error {
|
||||
VALUES
|
||||
('manual', '', ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_seen_at = VALUES(last_seen_at),
|
||||
is_ignored = VALUES(is_ignored),
|
||||
description = COALESCE(NULLIF(VALUES(description), ''), description)
|
||||
partnumber = partnumber
|
||||
`, item.Partnumber, item.Description, item.Ignored, now).Error
|
||||
if err != nil {
|
||||
slog.Error("failed to upsert partnumber_seen", "partnumber", item.Partnumber, "error", err)
|
||||
slog.Error("failed to insert partnumber_seen", "partnumber", item.Partnumber, "error", err)
|
||||
// Continue with remaining items
|
||||
}
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
@@ -213,17 +214,64 @@ func ensureClientMigrationRegistryTable(db *gorm.DB) error {
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS qt_client_schema_state (
|
||||
username VARCHAR(100) NOT NULL,
|
||||
hostname VARCHAR(255) NOT NULL DEFAULT '',
|
||||
last_applied_migration_id VARCHAR(128) NULL,
|
||||
app_version VARCHAR(64) NULL,
|
||||
last_sync_at DATETIME NULL,
|
||||
last_sync_status VARCHAR(32) NULL,
|
||||
pending_changes_count INT NOT NULL DEFAULT 0,
|
||||
pending_errors_count INT NOT NULL DEFAULT 0,
|
||||
configurations_count INT NOT NULL DEFAULT 0,
|
||||
projects_count INT NOT NULL DEFAULT 0,
|
||||
estimate_pricelist_version VARCHAR(128) NULL,
|
||||
warehouse_pricelist_version VARCHAR(128) NULL,
|
||||
competitor_pricelist_version VARCHAR(128) NULL,
|
||||
last_sync_error_code VARCHAR(128) NULL,
|
||||
last_sync_error_text TEXT NULL,
|
||||
last_checked_at DATETIME NOT NULL,
|
||||
updated_at DATETIME NOT NULL,
|
||||
PRIMARY KEY (username),
|
||||
PRIMARY KEY (username, hostname),
|
||||
INDEX idx_qt_client_schema_state_checked (last_checked_at)
|
||||
)
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("create qt_client_schema_state table: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
if tableExists(db, "qt_client_schema_state") {
|
||||
if err := db.Exec(`
|
||||
ALTER TABLE qt_client_schema_state
|
||||
ADD COLUMN IF NOT EXISTS hostname VARCHAR(255) NOT NULL DEFAULT '' AFTER username
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("add qt_client_schema_state.hostname: %w", err)
|
||||
}
|
||||
|
||||
if err := db.Exec(`
|
||||
ALTER TABLE qt_client_schema_state
|
||||
DROP PRIMARY KEY,
|
||||
ADD PRIMARY KEY (username, hostname)
|
||||
`).Error; err != nil && !isDuplicatePrimaryKeyDefinition(err) {
|
||||
return fmt.Errorf("set qt_client_schema_state primary key: %w", err)
|
||||
}
|
||||
|
||||
for _, stmt := range []string{
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_at DATETIME NULL AFTER app_version",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_status VARCHAR(32) NULL AFTER last_sync_at",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS pending_changes_count INT NOT NULL DEFAULT 0 AFTER last_sync_status",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS pending_errors_count INT NOT NULL DEFAULT 0 AFTER pending_changes_count",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS configurations_count INT NOT NULL DEFAULT 0 AFTER pending_errors_count",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS projects_count INT NOT NULL DEFAULT 0 AFTER configurations_count",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS estimate_pricelist_version VARCHAR(128) NULL AFTER projects_count",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS warehouse_pricelist_version VARCHAR(128) NULL AFTER estimate_pricelist_version",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS competitor_pricelist_version VARCHAR(128) NULL AFTER warehouse_pricelist_version",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_code VARCHAR(128) NULL AFTER competitor_pricelist_version",
|
||||
"ALTER TABLE qt_client_schema_state ADD COLUMN IF NOT EXISTS last_sync_error_text TEXT NULL AFTER last_sync_error_code",
|
||||
} {
|
||||
if err := db.Exec(stmt).Error; err != nil {
|
||||
return fmt.Errorf("expand qt_client_schema_state: %w", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -351,19 +399,108 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
|
||||
if username == "" {
|
||||
return nil
|
||||
}
|
||||
hostname, err := os.Hostname()
|
||||
if err != nil {
|
||||
hostname = ""
|
||||
}
|
||||
hostname = strings.TrimSpace(hostname)
|
||||
lastMigrationID := ""
|
||||
if id, err := s.localDB.GetLatestAppliedRemoteMigrationID(); err == nil {
|
||||
lastMigrationID = id
|
||||
}
|
||||
lastSyncAt := s.localDB.GetLastSyncTime()
|
||||
lastSyncStatus := ReadinessReady
|
||||
pendingChangesCount := s.localDB.CountPendingChanges()
|
||||
pendingErrorsCount := s.localDB.CountErroredChanges()
|
||||
configurationsCount := s.localDB.CountConfigurations()
|
||||
projectsCount := s.localDB.CountProjects()
|
||||
estimateVersion := latestPricelistVersion(s.localDB, "estimate")
|
||||
warehouseVersion := latestPricelistVersion(s.localDB, "warehouse")
|
||||
competitorVersion := latestPricelistVersion(s.localDB, "competitor")
|
||||
lastSyncErrorCode, lastSyncErrorText := latestSyncErrorState(s.localDB)
|
||||
return mariaDB.Exec(`
|
||||
INSERT INTO qt_client_schema_state (username, last_applied_migration_id, app_version, last_checked_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?)
|
||||
INSERT INTO qt_client_schema_state (
|
||||
username, hostname, last_applied_migration_id, app_version,
|
||||
last_sync_at, last_sync_status, pending_changes_count, pending_errors_count,
|
||||
configurations_count, projects_count,
|
||||
estimate_pricelist_version, warehouse_pricelist_version, competitor_pricelist_version,
|
||||
last_sync_error_code, last_sync_error_text,
|
||||
last_checked_at, updated_at
|
||||
)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
ON DUPLICATE KEY UPDATE
|
||||
last_applied_migration_id = VALUES(last_applied_migration_id),
|
||||
app_version = VALUES(app_version),
|
||||
last_sync_at = VALUES(last_sync_at),
|
||||
last_sync_status = VALUES(last_sync_status),
|
||||
pending_changes_count = VALUES(pending_changes_count),
|
||||
pending_errors_count = VALUES(pending_errors_count),
|
||||
configurations_count = VALUES(configurations_count),
|
||||
projects_count = VALUES(projects_count),
|
||||
estimate_pricelist_version = VALUES(estimate_pricelist_version),
|
||||
warehouse_pricelist_version = VALUES(warehouse_pricelist_version),
|
||||
competitor_pricelist_version = VALUES(competitor_pricelist_version),
|
||||
last_sync_error_code = VALUES(last_sync_error_code),
|
||||
last_sync_error_text = VALUES(last_sync_error_text),
|
||||
last_checked_at = VALUES(last_checked_at),
|
||||
updated_at = VALUES(updated_at)
|
||||
`, username, lastMigrationID, appmeta.Version(), checkedAt, checkedAt).Error
|
||||
`, username, hostname, lastMigrationID, appmeta.Version(),
|
||||
lastSyncAt, lastSyncStatus, pendingChangesCount, pendingErrorsCount,
|
||||
configurationsCount, projectsCount,
|
||||
estimateVersion, warehouseVersion, competitorVersion,
|
||||
lastSyncErrorCode, lastSyncErrorText,
|
||||
checkedAt, checkedAt).Error
|
||||
}
|
||||
|
||||
func isDuplicatePrimaryKeyDefinition(err error) bool {
|
||||
if err == nil {
|
||||
return false
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "multiple primary key defined") ||
|
||||
strings.Contains(msg, "duplicate key name 'primary'") ||
|
||||
strings.Contains(msg, "duplicate entry")
|
||||
}
|
||||
|
||||
func latestPricelistVersion(local *localdb.LocalDB, source string) *string {
|
||||
if local == nil {
|
||||
return nil
|
||||
}
|
||||
pl, err := local.GetLatestLocalPricelistBySource(source)
|
||||
if err != nil || pl == nil {
|
||||
return nil
|
||||
}
|
||||
version := strings.TrimSpace(pl.Version)
|
||||
if version == "" {
|
||||
return nil
|
||||
}
|
||||
return &version
|
||||
}
|
||||
|
||||
func latestSyncErrorState(local *localdb.LocalDB) (*string, *string) {
|
||||
if local == nil {
|
||||
return nil, nil
|
||||
}
|
||||
if guard, err := local.GetSyncGuardState(); err == nil && guard != nil && strings.EqualFold(guard.Status, ReadinessBlocked) {
|
||||
return optionalString(strings.TrimSpace(guard.ReasonCode)), optionalString(strings.TrimSpace(guard.ReasonText))
|
||||
}
|
||||
|
||||
var pending localdb.PendingChange
|
||||
if err := local.DB().
|
||||
Where("TRIM(COALESCE(last_error, '')) <> ''").
|
||||
Order("id DESC").
|
||||
First(&pending).Error; err == nil {
|
||||
return optionalString("PENDING_CHANGE_ERROR"), optionalString(strings.TrimSpace(pending.LastError))
|
||||
}
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
func optionalString(value string) *string {
|
||||
if strings.TrimSpace(value) == "" {
|
||||
return nil
|
||||
}
|
||||
v := strings.TrimSpace(value)
|
||||
return &v
|
||||
}
|
||||
|
||||
func normalizeVersionParts(v string) []int {
|
||||
|
||||
@@ -148,9 +148,6 @@ func (s *Service) ImportConfigurationsToLocal() (*ConfigImportResult, error) {
|
||||
if localCfg.Line <= 0 && existing.Line > 0 {
|
||||
localCfg.Line = existing.Line
|
||||
}
|
||||
// vendor_spec is local-only for BOM tab and is not stored on server.
|
||||
// Preserve it during server pull updates.
|
||||
localCfg.VendorSpec = existing.VendorSpec
|
||||
result.Updated++
|
||||
} else {
|
||||
result.Imported++
|
||||
|
||||
@@ -315,10 +315,21 @@ func TestImportConfigurationsToLocalPullsLine(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestImportConfigurationsToLocalPreservesLocalVendorSpec(t *testing.T) {
|
||||
func TestImportConfigurationsToLocalLoadsVendorSpecFromServer(t *testing.T) {
|
||||
local := newLocalDBForSyncTest(t)
|
||||
serverDB := newServerDBForSyncTest(t)
|
||||
|
||||
serverSpec := models.VendorSpec{
|
||||
{
|
||||
SortOrder: 10,
|
||||
VendorPartnumber: "GPU-NVHGX-H200-8141",
|
||||
Quantity: 1,
|
||||
Description: "NVIDIA HGX Delta-Next GPU Baseboard",
|
||||
LotMappings: []models.VendorSpecLotMapping{
|
||||
{LotName: "GPU_NV_H200_141GB_SXM_(HGX)", QuantityPerPN: 8},
|
||||
},
|
||||
},
|
||||
}
|
||||
cfg := models.Configuration{
|
||||
UUID: "server-vendorspec-config",
|
||||
OwnerUsername: "tester",
|
||||
@@ -326,6 +337,7 @@ func TestImportConfigurationsToLocalPreservesLocalVendorSpec(t *testing.T) {
|
||||
Items: models.ConfigItems{{LotName: "CPU_PULL", Quantity: 1, UnitPrice: 900}},
|
||||
ServerCount: 1,
|
||||
Line: 50,
|
||||
VendorSpec: serverSpec,
|
||||
}
|
||||
total := cfg.Items.Total()
|
||||
cfg.TotalPrice = &total
|
||||
@@ -333,32 +345,6 @@ func TestImportConfigurationsToLocalPreservesLocalVendorSpec(t *testing.T) {
|
||||
t.Fatalf("seed server config: %v", err)
|
||||
}
|
||||
|
||||
localSpec := localdb.VendorSpec{
|
||||
{
|
||||
SortOrder: 10,
|
||||
VendorPartnumber: "GPU-NVHGX-H200-8141",
|
||||
Quantity: 1,
|
||||
Description: "NVIDIA HGX Delta-Next GPU Baseboard",
|
||||
LotMappings: []localdb.VendorSpecLotMapping{
|
||||
{LotName: "GPU_NV_H200_141GB_SXM_(HGX)", QuantityPerPN: 8},
|
||||
},
|
||||
},
|
||||
}
|
||||
if err := local.SaveConfiguration(&localdb.LocalConfiguration{
|
||||
UUID: cfg.UUID,
|
||||
OriginalUsername: "tester",
|
||||
Name: "Local cfg",
|
||||
Items: localdb.LocalConfigItems{{LotName: "CPU_PULL", Quantity: 1, UnitPrice: 900}},
|
||||
IsActive: true,
|
||||
SyncStatus: "synced",
|
||||
Line: 50,
|
||||
VendorSpec: localSpec,
|
||||
CreatedAt: time.Now().Add(-30 * time.Minute),
|
||||
UpdatedAt: time.Now().Add(-30 * time.Minute),
|
||||
}); err != nil {
|
||||
t.Fatalf("seed local configuration: %v", err)
|
||||
}
|
||||
|
||||
svc := syncsvc.NewServiceWithDB(serverDB, local)
|
||||
if _, err := svc.ImportConfigurationsToLocal(); err != nil {
|
||||
t.Fatalf("import configurations to local: %v", err)
|
||||
@@ -369,7 +355,7 @@ func TestImportConfigurationsToLocalPreservesLocalVendorSpec(t *testing.T) {
|
||||
t.Fatalf("load local config: %v", err)
|
||||
}
|
||||
if len(localCfg.VendorSpec) != 1 {
|
||||
t.Fatalf("expected local vendor_spec preserved, got %d rows", len(localCfg.VendorSpec))
|
||||
t.Fatalf("expected server vendor_spec imported, got %d rows", len(localCfg.VendorSpec))
|
||||
}
|
||||
if localCfg.VendorSpec[0].VendorPartnumber != "GPU-NVHGX-H200-8141" {
|
||||
t.Fatalf("unexpected vendor_partnumber after import: %q", localCfg.VendorSpec[0].VendorPartnumber)
|
||||
@@ -492,6 +478,7 @@ CREATE TABLE qt_configurations (
|
||||
only_in_stock INTEGER NOT NULL DEFAULT 0,
|
||||
line_no INTEGER NULL,
|
||||
price_updated_at DATETIME NULL,
|
||||
vendor_spec TEXT NULL,
|
||||
created_at DATETIME
|
||||
);`).Error; err != nil {
|
||||
t.Fatalf("create qt_configurations: %v", err)
|
||||
|
||||
Reference in New Issue
Block a user