Add article generation and pricelist categories

This commit is contained in:
Mikhail Chusavitin
2026-02-11 19:16:01 +03:00
parent 99fd80bca7
commit 5edffe822b
32 changed files with 1953 additions and 323 deletions

View File

@@ -388,6 +388,9 @@ func (s *Service) SyncPricelists() (int, error) {
slog.Info("deleted stale local pricelists", "deleted", removed)
}
// Backfill lot_category for used pricelists (older local caches may miss the column values).
s.backfillUsedPricelistItemCategories(pricelistRepo, serverPricelistIDs)
// Update last sync time
s.localDB.SetLastSyncTime(time.Now())
s.RecordSyncHeartbeat()
@@ -396,6 +399,83 @@ func (s *Service) SyncPricelists() (int, error) {
return synced, nil
}
func (s *Service) backfillUsedPricelistItemCategories(pricelistRepo *repository.PricelistRepository, activeServerPricelistIDs []uint) {
if s.localDB == nil || pricelistRepo == nil {
return
}
activeSet := make(map[uint]struct{}, len(activeServerPricelistIDs))
for _, id := range activeServerPricelistIDs {
activeSet[id] = struct{}{}
}
type row struct {
ID uint `gorm:"column:id"`
}
var usedRows []row
if err := s.localDB.DB().Raw(`
SELECT DISTINCT pricelist_id AS id
FROM local_configurations
WHERE is_active = 1 AND pricelist_id IS NOT NULL
UNION
SELECT DISTINCT warehouse_pricelist_id AS id
FROM local_configurations
WHERE is_active = 1 AND warehouse_pricelist_id IS NOT NULL
UNION
SELECT DISTINCT competitor_pricelist_id AS id
FROM local_configurations
WHERE is_active = 1 AND competitor_pricelist_id IS NOT NULL
`).Scan(&usedRows).Error; err != nil {
slog.Warn("pricelist category backfill: failed to list used pricelists", "error", err)
return
}
for _, r := range usedRows {
serverID := r.ID
if serverID == 0 {
continue
}
if _, ok := activeSet[serverID]; !ok {
// Not present on server (or not active) - cannot backfill from remote.
continue
}
localPL, err := s.localDB.GetLocalPricelistByServerID(serverID)
if err != nil || localPL == nil {
continue
}
if s.localDB.CountLocalPricelistItems(localPL.ID) == 0 {
continue
}
missing, err := s.localDB.CountLocalPricelistItemsWithEmptyCategory(localPL.ID)
if err != nil {
slog.Warn("pricelist category backfill: failed to check local items", "server_id", serverID, "error", err)
continue
}
if missing == 0 {
continue
}
serverItems, _, err := pricelistRepo.GetItems(serverID, 0, 10000, "")
if err != nil {
slog.Warn("pricelist category backfill: failed to load server items", "server_id", serverID, "error", err)
continue
}
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
for i := range serverItems {
localItems[i] = *localdb.PricelistItemToLocal(&serverItems[i], localPL.ID)
}
if err := s.localDB.ReplaceLocalPricelistItems(localPL.ID, localItems); err != nil {
slog.Warn("pricelist category backfill: failed to replace local items", "server_id", serverID, "error", err)
continue
}
slog.Info("pricelist category backfill: refreshed local items", "server_id", serverID, "items", len(localItems))
}
}
// RecordSyncHeartbeat updates shared sync heartbeat for current DB user.
// Only users with write rights are expected to be able to update this table.
func (s *Service) RecordSyncHeartbeat() {
@@ -595,15 +675,7 @@ func (s *Service) SyncPricelistItems(localPricelistID uint) (int, error) {
// Convert and save locally
localItems := make([]localdb.LocalPricelistItem, len(serverItems))
for i, item := range serverItems {
partnumbers := make(localdb.LocalStringList, 0, len(item.Partnumbers))
partnumbers = append(partnumbers, item.Partnumbers...)
localItems[i] = localdb.LocalPricelistItem{
PricelistID: localPricelistID,
LotName: item.LotName,
Price: item.Price,
AvailableQty: item.AvailableQty,
Partnumbers: partnumbers,
}
localItems[i] = *localdb.PricelistItemToLocal(&item, localPricelistID)
}
if err := s.localDB.SaveLocalPricelistItems(localItems); err != nil {

View File

@@ -0,0 +1,107 @@
package sync_test
import (
"testing"
"time"
"git.mchus.pro/mchus/quoteforge/internal/localdb"
"git.mchus.pro/mchus/quoteforge/internal/models"
syncsvc "git.mchus.pro/mchus/quoteforge/internal/services/sync"
)
func TestSyncPricelists_BackfillsLotCategoryForUsedPricelistItems(t *testing.T) {
local := newLocalDBForSyncTest(t)
serverDB := newServerDBForSyncTest(t)
if err := serverDB.AutoMigrate(
&models.Pricelist{},
&models.PricelistItem{},
&models.Lot{},
&models.LotPartnumber{},
&models.StockLog{},
); err != nil {
t.Fatalf("migrate server tables: %v", err)
}
serverPL := models.Pricelist{
Source: "estimate",
Version: "2026-02-11-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,
PriceMethod: "",
MetaPrices: "",
ManualPrice: nil,
AvailableQty: nil,
}).Error; err != nil {
t.Fatalf("create server pricelist item: %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.SaveLocalPricelistItems([]localdb.LocalPricelistItem{
{
PricelistID: localPL.ID,
LotName: "CPU_A",
LotCategory: "",
Price: 10,
},
}); err != nil {
t.Fatalf("seed local pricelist items: %v", err)
}
if err := local.SaveConfiguration(&localdb.LocalConfiguration{
UUID: "cfg-1",
OriginalUsername: "tester",
Name: "cfg",
Items: localdb.LocalConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 10}},
IsActive: true,
PricelistID: &serverPL.ID,
SyncStatus: "synced",
CreatedAt: time.Now().Add(-30 * time.Minute),
UpdatedAt: time.Now().Add(-30 * time.Minute),
}); err != nil {
t.Fatalf("seed local configuration with pricelist ref: %v", err)
}
svc := syncsvc.NewServiceWithDB(serverDB, local)
if _, err := svc.SyncPricelists(); err != nil {
t.Fatalf("sync pricelists: %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].LotCategory != "CPU" {
t.Fatalf("expected lot_category backfilled to CPU, got %q", items[0].LotCategory)
}
}

View File

@@ -348,6 +348,9 @@ CREATE TABLE qt_configurations (
notes TEXT NULL,
is_template INTEGER NOT NULL DEFAULT 0,
server_count INTEGER NOT NULL DEFAULT 1,
server_model TEXT NULL,
support_code TEXT NULL,
article TEXT NULL,
pricelist_id INTEGER NULL,
warehouse_pricelist_id INTEGER NULL,
competitor_pricelist_id INTEGER NULL,