Local-first runtime cleanup and recovery hardening
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"time"
|
||||
@@ -23,13 +24,14 @@ func (s *Service) PullPartnumberBooks() (int, error) {
|
||||
localBookRepo := repository.NewPartnumberBookRepository(s.localDB.DB())
|
||||
|
||||
type serverBook struct {
|
||||
ID int `gorm:"column:id"`
|
||||
Version string `gorm:"column:version"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
IsActive bool `gorm:"column:is_active"`
|
||||
ID int `gorm:"column:id"`
|
||||
Version string `gorm:"column:version"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
IsActive bool `gorm:"column:is_active"`
|
||||
PartnumbersJSON string `gorm:"column:partnumbers_json"`
|
||||
}
|
||||
var serverBooks []serverBook
|
||||
if err := mariaDB.Raw("SELECT id, version, created_at, is_active FROM qt_partnumber_books ORDER BY created_at DESC, id DESC").Scan(&serverBooks).Error; err != nil {
|
||||
if err := mariaDB.Raw("SELECT id, version, created_at, is_active, partnumbers_json FROM qt_partnumber_books ORDER BY created_at DESC, id DESC").Scan(&serverBooks).Error; err != nil {
|
||||
return 0, fmt.Errorf("querying server partnumber books: %w", err)
|
||||
}
|
||||
slog.Info("partnumber books found on server", "count", len(serverBooks))
|
||||
@@ -38,16 +40,28 @@ func (s *Service) PullPartnumberBooks() (int, error) {
|
||||
for _, sb := range serverBooks {
|
||||
var existing localdb.LocalPartnumberBook
|
||||
err := s.localDB.DB().Where("server_id = ?", sb.ID).First(&existing).Error
|
||||
partnumbers, errPartnumbers := decodeServerPartnumbers(sb.PartnumbersJSON)
|
||||
if errPartnumbers != nil {
|
||||
slog.Error("failed to decode server partnumbers_json", "server_id", sb.ID, "error", errPartnumbers)
|
||||
continue
|
||||
}
|
||||
if err == nil {
|
||||
// Header exists — check whether items were saved
|
||||
existing.Version = sb.Version
|
||||
existing.CreatedAt = sb.CreatedAt
|
||||
existing.IsActive = sb.IsActive
|
||||
existing.PartnumbersJSON = partnumbers
|
||||
if err := localBookRepo.SaveBook(&existing); err != nil {
|
||||
slog.Error("failed to update local partnumber book header", "server_id", sb.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
localItemCount := localBookRepo.CountBookItems(existing.ID)
|
||||
if localItemCount > 0 {
|
||||
if localItemCount > 0 && localBookRepo.HasAllBookItems(existing.ID) {
|
||||
slog.Debug("partnumber book already synced, skipping", "server_id", sb.ID, "version", sb.Version, "items", localItemCount)
|
||||
continue
|
||||
}
|
||||
// Items missing — re-pull them
|
||||
slog.Info("partnumber book header exists but has no items, re-pulling items", "server_id", sb.ID, "version", sb.Version)
|
||||
n, err := pullBookItems(mariaDB, localBookRepo, sb.ID, existing.ID)
|
||||
slog.Info("partnumber book header exists but catalog items are missing, re-pulling items", "server_id", sb.ID, "version", sb.Version)
|
||||
n, err := pullBookItems(mariaDB, localBookRepo, existing.PartnumbersJSON)
|
||||
if err != nil {
|
||||
slog.Error("failed to re-pull items for existing book", "server_id", sb.ID, "error", err)
|
||||
} else {
|
||||
@@ -60,17 +74,18 @@ func (s *Service) PullPartnumberBooks() (int, error) {
|
||||
slog.Info("pulling new partnumber book", "server_id", sb.ID, "version", sb.Version, "is_active", sb.IsActive)
|
||||
|
||||
localBook := &localdb.LocalPartnumberBook{
|
||||
ServerID: sb.ID,
|
||||
Version: sb.Version,
|
||||
CreatedAt: sb.CreatedAt,
|
||||
IsActive: sb.IsActive,
|
||||
ServerID: sb.ID,
|
||||
Version: sb.Version,
|
||||
CreatedAt: sb.CreatedAt,
|
||||
IsActive: sb.IsActive,
|
||||
PartnumbersJSON: partnumbers,
|
||||
}
|
||||
if err := localBookRepo.SaveBook(localBook); err != nil {
|
||||
slog.Error("failed to save local partnumber book", "server_id", sb.ID, "error", err)
|
||||
continue
|
||||
}
|
||||
|
||||
n, err := pullBookItems(mariaDB, localBookRepo, sb.ID, localBook.ID)
|
||||
n, err := pullBookItems(mariaDB, localBookRepo, localBook.PartnumbersJSON)
|
||||
if err != nil {
|
||||
slog.Error("failed to pull items for new book", "server_id", sb.ID, "error", err)
|
||||
continue
|
||||
@@ -84,39 +99,39 @@ func (s *Service) PullPartnumberBooks() (int, error) {
|
||||
return pulled, nil
|
||||
}
|
||||
|
||||
// pullBookItems fetches items for a single book from MariaDB and saves them to SQLite.
|
||||
// pullBookItems fetches catalog items for a partnumber list from MariaDB and saves them to SQLite.
|
||||
// Returns the number of items saved.
|
||||
func pullBookItems(mariaDB *gorm.DB, repo *repository.PartnumberBookRepository, serverBookID int, localBookID uint) (int, error) {
|
||||
func pullBookItems(mariaDB *gorm.DB, repo *repository.PartnumberBookRepository, partnumbers localdb.LocalStringList) (int, error) {
|
||||
if len(partnumbers) == 0 {
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
type serverItem struct {
|
||||
Partnumber string `gorm:"column:partnumber"`
|
||||
LotName string `gorm:"column:lot_name"`
|
||||
IsPrimaryPN bool `gorm:"column:is_primary_pn"`
|
||||
LotsJSON string `gorm:"column:lots_json"`
|
||||
Description string `gorm:"column:description"`
|
||||
}
|
||||
// description column may not exist yet on older server schemas — query without it first,
|
||||
// then retry with it to populate descriptions if available.
|
||||
var serverItems []serverItem
|
||||
err := mariaDB.Raw("SELECT partnumber, lot_name, is_primary_pn, description FROM qt_partnumber_book_items WHERE book_id = ?", serverBookID).Scan(&serverItems).Error
|
||||
err := mariaDB.Raw("SELECT partnumber, lots_json, description FROM qt_partnumber_book_items WHERE partnumber IN ?", []string(partnumbers)).Scan(&serverItems).Error
|
||||
if err != nil {
|
||||
slog.Warn("description column not available on server, retrying without it", "server_book_id", serverBookID, "error", err)
|
||||
if err2 := mariaDB.Raw("SELECT partnumber, lot_name, is_primary_pn FROM qt_partnumber_book_items WHERE book_id = ?", serverBookID).Scan(&serverItems).Error; err2 != nil {
|
||||
return 0, fmt.Errorf("querying items from server: %w", err2)
|
||||
}
|
||||
return 0, fmt.Errorf("querying items from server: %w", err)
|
||||
}
|
||||
slog.Info("partnumber book items fetched from server", "server_book_id", serverBookID, "count", len(serverItems))
|
||||
slog.Info("partnumber book items fetched from server", "count", len(serverItems), "requested_partnumbers", len(partnumbers))
|
||||
|
||||
if len(serverItems) == 0 {
|
||||
slog.Warn("server returned 0 items for book — check qt_partnumber_book_items on server", "server_book_id", serverBookID)
|
||||
slog.Warn("server returned 0 partnumber book items")
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
localItems := make([]localdb.LocalPartnumberBookItem, 0, len(serverItems))
|
||||
for _, si := range serverItems {
|
||||
var lots localdb.LocalPartnumberBookLots
|
||||
if err := json.Unmarshal([]byte(si.LotsJSON), &lots); err != nil {
|
||||
return 0, fmt.Errorf("decode lots_json for %s: %w", si.Partnumber, err)
|
||||
}
|
||||
localItems = append(localItems, localdb.LocalPartnumberBookItem{
|
||||
BookID: localBookID,
|
||||
Partnumber: si.Partnumber,
|
||||
LotName: si.LotName,
|
||||
IsPrimaryPN: si.IsPrimaryPN,
|
||||
LotsJSON: lots,
|
||||
Description: si.Description,
|
||||
})
|
||||
}
|
||||
@@ -125,3 +140,14 @@ func pullBookItems(mariaDB *gorm.DB, repo *repository.PartnumberBookRepository,
|
||||
}
|
||||
return len(localItems), nil
|
||||
}
|
||||
|
||||
func decodeServerPartnumbers(raw string) (localdb.LocalStringList, error) {
|
||||
if raw == "" {
|
||||
return localdb.LocalStringList{}, nil
|
||||
}
|
||||
var items []string
|
||||
if err := json.Unmarshal([]byte(raw), &items); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return localdb.LocalStringList(items), nil
|
||||
}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
package sync
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"crypto/sha256"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"os"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -80,48 +76,6 @@ func (s *Service) GetReadiness() (*SyncReadiness, error) {
|
||||
)
|
||||
}
|
||||
|
||||
migrations, err := listActiveClientMigrations(mariaDB)
|
||||
if err != nil {
|
||||
return s.blockedReadiness(
|
||||
now,
|
||||
"REMOTE_MIGRATION_REGISTRY_UNAVAILABLE",
|
||||
"Синхронизация заблокирована: не удалось проверить централизованные миграции локальной БД.",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
for i := range migrations {
|
||||
m := migrations[i]
|
||||
if strings.TrimSpace(m.MinAppVersion) != "" {
|
||||
if compareVersions(appmeta.Version(), m.MinAppVersion) < 0 {
|
||||
min := m.MinAppVersion
|
||||
return s.blockedReadiness(
|
||||
now,
|
||||
"MIN_APP_VERSION_REQUIRED",
|
||||
fmt.Sprintf("Требуется обновление приложения до версии %s для безопасной синхронизации.", m.MinAppVersion),
|
||||
&min,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if err := s.applyMissingRemoteMigrations(migrations); err != nil {
|
||||
if strings.Contains(strings.ToLower(err.Error()), "checksum") {
|
||||
return s.blockedReadiness(
|
||||
now,
|
||||
"REMOTE_MIGRATION_CHECKSUM_MISMATCH",
|
||||
"Синхронизация заблокирована: контрольная сумма миграции не совпадает.",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
return s.blockedReadiness(
|
||||
now,
|
||||
"LOCAL_MIGRATION_APPLY_FAILED",
|
||||
"Синхронизация заблокирована: не удалось применить миграции локальной БД.",
|
||||
nil,
|
||||
)
|
||||
}
|
||||
|
||||
if err := s.reportClientSchemaState(mariaDB, now); err != nil {
|
||||
slog.Warn("failed to report client schema state", "error", err)
|
||||
}
|
||||
@@ -158,64 +112,12 @@ func (s *Service) isOnline() bool {
|
||||
return s.connMgr.IsOnline()
|
||||
}
|
||||
|
||||
type clientLocalMigration struct {
|
||||
ID string `gorm:"column:id"`
|
||||
Name string `gorm:"column:name"`
|
||||
SQLText string `gorm:"column:sql_text"`
|
||||
Checksum string `gorm:"column:checksum"`
|
||||
MinAppVersion string `gorm:"column:min_app_version"`
|
||||
OrderNo int `gorm:"column:order_no"`
|
||||
CreatedAt time.Time `gorm:"column:created_at"`
|
||||
}
|
||||
|
||||
func listActiveClientMigrations(db *gorm.DB) ([]clientLocalMigration, error) {
|
||||
if strings.EqualFold(db.Dialector.Name(), "sqlite") {
|
||||
return []clientLocalMigration{}, nil
|
||||
}
|
||||
if err := ensureClientMigrationRegistryTable(db); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
rows := make([]clientLocalMigration, 0)
|
||||
if err := db.Raw(`
|
||||
SELECT id, name, sql_text, checksum, COALESCE(min_app_version, '') AS min_app_version, order_no, created_at
|
||||
FROM qt_client_local_migrations
|
||||
WHERE is_active = 1
|
||||
ORDER BY order_no ASC, created_at ASC, id ASC
|
||||
`).Scan(&rows).Error; err != nil {
|
||||
return nil, fmt.Errorf("load client local migrations: %w", err)
|
||||
}
|
||||
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func ensureClientMigrationRegistryTable(db *gorm.DB) error {
|
||||
// Check if table exists instead of trying to create (avoids permission issues)
|
||||
if !tableExists(db, "qt_client_local_migrations") {
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS qt_client_local_migrations (
|
||||
id VARCHAR(128) NOT NULL,
|
||||
name VARCHAR(255) NOT NULL,
|
||||
sql_text LONGTEXT NOT NULL,
|
||||
checksum VARCHAR(128) NOT NULL,
|
||||
min_app_version VARCHAR(64) NULL,
|
||||
order_no INT NOT NULL DEFAULT 0,
|
||||
is_active TINYINT(1) NOT NULL DEFAULT 1,
|
||||
created_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP,
|
||||
PRIMARY KEY (id),
|
||||
INDEX idx_qt_client_local_migrations_active_order (is_active, order_no, created_at)
|
||||
)
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("create qt_client_local_migrations table: %w", err)
|
||||
}
|
||||
}
|
||||
|
||||
func ensureClientSchemaStateTable(db *gorm.DB) error {
|
||||
if !tableExists(db, "qt_client_schema_state") {
|
||||
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,
|
||||
@@ -287,114 +189,13 @@ func tableExists(db *gorm.DB, tableName string) bool {
|
||||
return count > 0
|
||||
}
|
||||
|
||||
func (s *Service) applyMissingRemoteMigrations(migrations []clientLocalMigration) error {
|
||||
for i := range migrations {
|
||||
m := migrations[i]
|
||||
computedChecksum := digestSQL(m.SQLText)
|
||||
checksum := strings.TrimSpace(m.Checksum)
|
||||
if checksum == "" {
|
||||
checksum = computedChecksum
|
||||
} else if !strings.EqualFold(checksum, computedChecksum) {
|
||||
return fmt.Errorf("checksum mismatch for migration %s", m.ID)
|
||||
}
|
||||
|
||||
applied, err := s.localDB.GetRemoteMigrationApplied(m.ID)
|
||||
if err == nil {
|
||||
if strings.TrimSpace(applied.Checksum) != checksum {
|
||||
return fmt.Errorf("checksum mismatch for migration %s", m.ID)
|
||||
}
|
||||
continue
|
||||
}
|
||||
if !errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return fmt.Errorf("check local applied migration %s: %w", m.ID, err)
|
||||
}
|
||||
|
||||
if strings.TrimSpace(m.SQLText) == "" {
|
||||
if err := s.localDB.UpsertRemoteMigrationApplied(m.ID, checksum, appmeta.Version(), time.Now().UTC()); err != nil {
|
||||
return fmt.Errorf("mark empty migration %s as applied: %w", m.ID, err)
|
||||
}
|
||||
continue
|
||||
}
|
||||
|
||||
statements := splitSQLStatementsLite(m.SQLText)
|
||||
if err := s.localDB.DB().Transaction(func(tx *gorm.DB) error {
|
||||
for _, stmt := range statements {
|
||||
if err := tx.Exec(stmt).Error; err != nil {
|
||||
return fmt.Errorf("apply migration %s statement %q: %w", m.ID, stmt, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if err := s.localDB.UpsertRemoteMigrationApplied(m.ID, checksum, appmeta.Version(), time.Now().UTC()); err != nil {
|
||||
return fmt.Errorf("record applied migration %s: %w", m.ID, err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func splitSQLStatementsLite(script string) []string {
|
||||
scanner := bufio.NewScanner(strings.NewReader(script))
|
||||
scanner.Buffer(make([]byte, 1024), 1024*1024)
|
||||
|
||||
lines := make([]string, 0, 64)
|
||||
for scanner.Scan() {
|
||||
line := strings.TrimSpace(scanner.Text())
|
||||
if line == "" || strings.HasPrefix(line, "--") {
|
||||
continue
|
||||
}
|
||||
lines = append(lines, scanner.Text())
|
||||
}
|
||||
combined := strings.Join(lines, "\n")
|
||||
raw := strings.Split(combined, ";")
|
||||
stmts := make([]string, 0, len(raw))
|
||||
for _, stmt := range raw {
|
||||
trimmed := strings.TrimSpace(stmt)
|
||||
if trimmed == "" {
|
||||
continue
|
||||
}
|
||||
stmts = append(stmts, trimmed)
|
||||
}
|
||||
return stmts
|
||||
}
|
||||
|
||||
func digestSQL(sqlText string) string {
|
||||
hash := sha256.Sum256([]byte(sqlText))
|
||||
return hex.EncodeToString(hash[:])
|
||||
}
|
||||
|
||||
func compareVersions(left, right string) int {
|
||||
leftParts := normalizeVersionParts(left)
|
||||
rightParts := normalizeVersionParts(right)
|
||||
maxLen := len(leftParts)
|
||||
if len(rightParts) > maxLen {
|
||||
maxLen = len(rightParts)
|
||||
}
|
||||
for i := 0; i < maxLen; i++ {
|
||||
lv := 0
|
||||
rv := 0
|
||||
if i < len(leftParts) {
|
||||
lv = leftParts[i]
|
||||
}
|
||||
if i < len(rightParts) {
|
||||
rv = rightParts[i]
|
||||
}
|
||||
if lv < rv {
|
||||
return -1
|
||||
}
|
||||
if lv > rv {
|
||||
return 1
|
||||
}
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time) error {
|
||||
if strings.EqualFold(mariaDB.Dialector.Name(), "sqlite") {
|
||||
return nil
|
||||
}
|
||||
if err := ensureClientSchemaStateTable(mariaDB); err != nil {
|
||||
return err
|
||||
}
|
||||
username := strings.TrimSpace(s.localDB.GetDBUser())
|
||||
if username == "" {
|
||||
return nil
|
||||
@@ -404,10 +205,6 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
|
||||
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()
|
||||
@@ -420,16 +217,15 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
|
||||
lastSyncErrorCode, lastSyncErrorText := latestSyncErrorState(s.localDB)
|
||||
return mariaDB.Exec(`
|
||||
INSERT INTO qt_client_schema_state (
|
||||
username, hostname, last_applied_migration_id, app_version,
|
||||
username, hostname, 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 (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||
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),
|
||||
@@ -444,7 +240,7 @@ func (s *Service) reportClientSchemaState(mariaDB *gorm.DB, checkedAt time.Time)
|
||||
last_sync_error_text = VALUES(last_sync_error_text),
|
||||
last_checked_at = VALUES(last_checked_at),
|
||||
updated_at = VALUES(updated_at)
|
||||
`, username, hostname, lastMigrationID, appmeta.Version(),
|
||||
`, username, hostname, appmeta.Version(),
|
||||
lastSyncAt, lastSyncStatus, pendingChangesCount, pendingErrorsCount,
|
||||
configurationsCount, projectsCount,
|
||||
estimateVersion, warehouseVersion, competitorVersion,
|
||||
@@ -503,34 +299,6 @@ func optionalString(value string) *string {
|
||||
return &v
|
||||
}
|
||||
|
||||
func normalizeVersionParts(v string) []int {
|
||||
trimmed := strings.TrimSpace(v)
|
||||
trimmed = strings.TrimPrefix(trimmed, "v")
|
||||
chunks := strings.Split(trimmed, ".")
|
||||
parts := make([]int, 0, len(chunks))
|
||||
for _, chunk := range chunks {
|
||||
clean := strings.TrimSpace(chunk)
|
||||
if clean == "" {
|
||||
parts = append(parts, 0)
|
||||
continue
|
||||
}
|
||||
n := 0
|
||||
for i := 0; i < len(clean); i++ {
|
||||
if clean[i] < '0' || clean[i] > '9' {
|
||||
clean = clean[:i]
|
||||
break
|
||||
}
|
||||
}
|
||||
if clean != "" {
|
||||
if parsed, err := strconv.Atoi(clean); err == nil {
|
||||
n = parsed
|
||||
}
|
||||
}
|
||||
parts = append(parts, n)
|
||||
}
|
||||
return parts
|
||||
}
|
||||
|
||||
func toReadinessFromState(state *localdb.LocalSyncGuardState) *SyncReadiness {
|
||||
if state == nil {
|
||||
return nil
|
||||
|
||||
@@ -690,6 +690,9 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
|
||||
for i, item := range serverItems {
|
||||
localItems[i] = *localdb.PricelistItemToLocal(&item, localPricelistID)
|
||||
}
|
||||
if err := s.enrichLocalPricelistItemsWithStock(mariaDB, localItems); err != nil {
|
||||
slog.Warn("pricelist stock enrichment skipped", "pricelist_id", localPricelistID, "error", err)
|
||||
}
|
||||
|
||||
if err := s.localDB.SaveLocalPricelistItems(localItems); err != nil {
|
||||
return 0, fmt.Errorf("saving local pricelist items: %w", err)
|
||||
@@ -708,6 +711,111 @@ func (s *Service) SyncPricelistItemsByServerID(serverPricelistID uint) (int, err
|
||||
return s.SyncPricelistItems(localPL.ID)
|
||||
}
|
||||
|
||||
func (s *Service) enrichLocalPricelistItemsWithStock(mariaDB *gorm.DB, items []localdb.LocalPricelistItem) error {
|
||||
if len(items) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
bookRepo := repository.NewPartnumberBookRepository(s.localDB.DB())
|
||||
book, err := bookRepo.GetActiveBook()
|
||||
if err != nil || book == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
bookItems, err := bookRepo.GetBookItems(book.ID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(bookItems) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
partnumberToLots := make(map[string][]string, len(bookItems))
|
||||
for _, item := range bookItems {
|
||||
pn := strings.TrimSpace(item.Partnumber)
|
||||
if pn == "" {
|
||||
continue
|
||||
}
|
||||
seenLots := make(map[string]struct{}, len(item.LotsJSON))
|
||||
for _, lot := range item.LotsJSON {
|
||||
lotName := strings.TrimSpace(lot.LotName)
|
||||
if lotName == "" {
|
||||
continue
|
||||
}
|
||||
key := strings.ToLower(lotName)
|
||||
if _, exists := seenLots[key]; exists {
|
||||
continue
|
||||
}
|
||||
seenLots[key] = struct{}{}
|
||||
partnumberToLots[pn] = append(partnumberToLots[pn], lotName)
|
||||
}
|
||||
}
|
||||
if len(partnumberToLots) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
type stockRow struct {
|
||||
Partnumber string `gorm:"column:partnumber"`
|
||||
Qty *float64 `gorm:"column:qty"`
|
||||
}
|
||||
rows := make([]stockRow, 0)
|
||||
if err := mariaDB.Raw(`
|
||||
SELECT s.partnumber, s.qty
|
||||
FROM stock_log s
|
||||
INNER JOIN (
|
||||
SELECT partnumber, MAX(date) AS max_date
|
||||
FROM stock_log
|
||||
GROUP BY partnumber
|
||||
) latest ON latest.partnumber = s.partnumber AND latest.max_date = s.date
|
||||
WHERE s.qty IS NOT NULL
|
||||
`).Scan(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
lotTotals := make(map[string]float64, len(items))
|
||||
lotPartnumbers := make(map[string][]string, len(items))
|
||||
seenPartnumbers := make(map[string]map[string]struct{}, len(items))
|
||||
|
||||
for _, row := range rows {
|
||||
pn := strings.TrimSpace(row.Partnumber)
|
||||
if pn == "" || row.Qty == nil {
|
||||
continue
|
||||
}
|
||||
lots := partnumberToLots[pn]
|
||||
if len(lots) == 0 {
|
||||
continue
|
||||
}
|
||||
for _, lotName := range lots {
|
||||
lotTotals[lotName] += *row.Qty
|
||||
if _, ok := seenPartnumbers[lotName]; !ok {
|
||||
seenPartnumbers[lotName] = make(map[string]struct{}, 4)
|
||||
}
|
||||
key := strings.ToLower(pn)
|
||||
if _, exists := seenPartnumbers[lotName][key]; exists {
|
||||
continue
|
||||
}
|
||||
seenPartnumbers[lotName][key] = struct{}{}
|
||||
lotPartnumbers[lotName] = append(lotPartnumbers[lotName], pn)
|
||||
}
|
||||
}
|
||||
|
||||
for i := range items {
|
||||
lotName := strings.TrimSpace(items[i].LotName)
|
||||
if qty, ok := lotTotals[lotName]; ok {
|
||||
qtyCopy := qty
|
||||
items[i].AvailableQty = &qtyCopy
|
||||
}
|
||||
if partnumbers := lotPartnumbers[lotName]; len(partnumbers) > 0 {
|
||||
sort.Slice(partnumbers, func(a, b int) bool {
|
||||
return strings.ToLower(partnumbers[a]) < strings.ToLower(partnumbers[b])
|
||||
})
|
||||
items[i].Partnumbers = append(localdb.LocalStringList{}, partnumbers...)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetLocalPriceForLot returns the price for a lot from a local pricelist
|
||||
func (s *Service) GetLocalPriceForLot(localPricelistID uint, lotName string) (float64, error) {
|
||||
return s.localDB.GetLocalPriceForLot(localPricelistID, lotName)
|
||||
|
||||
@@ -17,7 +17,6 @@ func TestSyncPricelists_BackfillsLotCategoryForUsedPricelistItems(t *testing.T)
|
||||
&models.Pricelist{},
|
||||
&models.PricelistItem{},
|
||||
&models.Lot{},
|
||||
&models.LotPartnumber{},
|
||||
&models.StockLog{},
|
||||
); err != nil {
|
||||
t.Fatalf("migrate server tables: %v", err)
|
||||
@@ -105,3 +104,102 @@ func TestSyncPricelists_BackfillsLotCategoryForUsedPricelistItems(t *testing.T)
|
||||
}
|
||||
}
|
||||
|
||||
func TestSyncPricelistItems_EnrichesStockFromLocalPartnumberBook(t *testing.T) {
|
||||
local := newLocalDBForSyncTest(t)
|
||||
serverDB := newServerDBForSyncTest(t)
|
||||
|
||||
if err := serverDB.AutoMigrate(
|
||||
&models.Pricelist{},
|
||||
&models.PricelistItem{},
|
||||
&models.Lot{},
|
||||
&models.StockLog{},
|
||||
); err != nil {
|
||||
t.Fatalf("migrate server tables: %v", err)
|
||||
}
|
||||
|
||||
serverPL := models.Pricelist{
|
||||
Source: "warehouse",
|
||||
Version: "2026-03-07-001",
|
||||
Notification: "server",
|
||||
CreatedBy: "tester",
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now().Add(-1 * time.Hour),
|
||||
}
|
||||
if err := serverDB.Create(&serverPL).Error; err != nil {
|
||||
t.Fatalf("create server pricelist: %v", err)
|
||||
}
|
||||
if err := serverDB.Create(&models.PricelistItem{
|
||||
PricelistID: serverPL.ID,
|
||||
LotName: "CPU_A",
|
||||
LotCategory: "CPU",
|
||||
Price: 10,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create server pricelist item: %v", err)
|
||||
}
|
||||
qty := 7.0
|
||||
if err := serverDB.Create(&models.StockLog{
|
||||
Partnumber: "CPU-PN-1",
|
||||
Date: time.Now(),
|
||||
Price: 100,
|
||||
Qty: &qty,
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create stock log: %v", err)
|
||||
}
|
||||
|
||||
if err := local.SaveLocalPricelist(&localdb.LocalPricelist{
|
||||
ServerID: serverPL.ID,
|
||||
Source: serverPL.Source,
|
||||
Version: serverPL.Version,
|
||||
Name: serverPL.Notification,
|
||||
CreatedAt: serverPL.CreatedAt,
|
||||
SyncedAt: time.Now(),
|
||||
IsUsed: false,
|
||||
}); err != nil {
|
||||
t.Fatalf("seed local pricelist: %v", err)
|
||||
}
|
||||
localPL, err := local.GetLocalPricelistByServerID(serverPL.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("get local pricelist: %v", err)
|
||||
}
|
||||
|
||||
if err := local.DB().Create(&localdb.LocalPartnumberBook{
|
||||
ServerID: 1,
|
||||
Version: "2026-03-07-001",
|
||||
CreatedAt: time.Now(),
|
||||
IsActive: true,
|
||||
PartnumbersJSON: localdb.LocalStringList{"CPU-PN-1"},
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create local partnumber book: %v", err)
|
||||
}
|
||||
if err := local.DB().Create(&localdb.LocalPartnumberBookItem{
|
||||
Partnumber: "CPU-PN-1",
|
||||
LotsJSON: localdb.LocalPartnumberBookLots{
|
||||
{LotName: "CPU_A", Qty: 1},
|
||||
},
|
||||
Description: "CPU PN",
|
||||
}).Error; err != nil {
|
||||
t.Fatalf("create local partnumber book item: %v", err)
|
||||
}
|
||||
|
||||
svc := syncsvc.NewServiceWithDB(serverDB, local)
|
||||
if _, err := svc.SyncPricelistItems(localPL.ID); err != nil {
|
||||
t.Fatalf("sync pricelist items: %v", err)
|
||||
}
|
||||
|
||||
items, err := local.GetLocalPricelistItems(localPL.ID)
|
||||
if err != nil {
|
||||
t.Fatalf("load local items: %v", err)
|
||||
}
|
||||
if len(items) != 1 {
|
||||
t.Fatalf("expected 1 local item, got %d", len(items))
|
||||
}
|
||||
if items[0].AvailableQty == nil {
|
||||
t.Fatalf("expected available_qty to be set")
|
||||
}
|
||||
if *items[0].AvailableQty != 7 {
|
||||
t.Fatalf("expected available_qty=7, got %v", *items[0].AvailableQty)
|
||||
}
|
||||
if len(items[0].Partnumbers) != 1 || items[0].Partnumbers[0] != "CPU-PN-1" {
|
||||
t.Fatalf("expected partnumbers [CPU-PN-1], got %v", items[0].Partnumbers)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user