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

@@ -28,6 +28,9 @@ func ConfigurationToLocal(cfg *models.Configuration) *LocalConfiguration {
Notes: cfg.Notes,
IsTemplate: cfg.IsTemplate,
ServerCount: cfg.ServerCount,
ServerModel: cfg.ServerModel,
SupportCode: cfg.SupportCode,
Article: cfg.Article,
PricelistID: cfg.PricelistID,
OnlyInStock: cfg.OnlyInStock,
PriceUpdatedAt: cfg.PriceUpdatedAt,
@@ -72,6 +75,9 @@ func LocalToConfiguration(local *LocalConfiguration) *models.Configuration {
Notes: local.Notes,
IsTemplate: local.IsTemplate,
ServerCount: local.ServerCount,
ServerModel: local.ServerModel,
SupportCode: local.SupportCode,
Article: local.Article,
PricelistID: local.PricelistID,
OnlyInStock: local.OnlyInStock,
PriceUpdatedAt: local.PriceUpdatedAt,
@@ -169,6 +175,7 @@ func PricelistItemToLocal(item *models.PricelistItem, localPricelistID uint) *Lo
return &LocalPricelistItem{
PricelistID: localPricelistID,
LotName: item.LotName,
LotCategory: item.LotCategory,
Price: item.Price,
AvailableQty: item.AvailableQty,
Partnumbers: partnumbers,
@@ -183,6 +190,7 @@ func LocalToPricelistItem(local *LocalPricelistItem, serverPricelistID uint) *mo
ID: local.ID,
PricelistID: serverPricelistID,
LotName: local.LotName,
LotCategory: local.LotCategory,
Price: local.Price,
AvailableQty: local.AvailableQty,
Partnumbers: partnumbers,

View File

@@ -0,0 +1,34 @@
package localdb
import (
"testing"
"git.mchus.pro/mchus/quoteforge/internal/models"
)
func TestPricelistItemToLocal_PreservesLotCategory(t *testing.T) {
item := &models.PricelistItem{
LotName: "CPU_A",
LotCategory: "CPU",
Price: 10,
}
local := PricelistItemToLocal(item, 123)
if local.LotCategory != "CPU" {
t.Fatalf("expected LotCategory=CPU, got %q", local.LotCategory)
}
}
func TestLocalToPricelistItem_PreservesLotCategory(t *testing.T) {
local := &LocalPricelistItem{
LotName: "CPU_A",
LotCategory: "CPU",
Price: 10,
}
item := LocalToPricelistItem(local, 456)
if item.LotCategory != "CPU" {
t.Fatalf("expected LotCategory=CPU, got %q", item.LotCategory)
}
}

View File

@@ -645,6 +645,17 @@ func (l *LocalDB) CountLocalPricelistItems(pricelistID uint) int64 {
return count
}
// CountLocalPricelistItemsWithEmptyCategory returns the number of items for a pricelist with missing lot_category.
func (l *LocalDB) CountLocalPricelistItemsWithEmptyCategory(pricelistID uint) (int64, error) {
var count int64
if err := l.db.Model(&LocalPricelistItem{}).
Where("pricelist_id = ? AND (lot_category IS NULL OR TRIM(lot_category) = '')", pricelistID).
Count(&count).Error; err != nil {
return 0, err
}
return count, nil
}
// SaveLocalPricelistItems saves pricelist items to local SQLite
func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error {
if len(items) == 0 {
@@ -665,6 +676,30 @@ func (l *LocalDB) SaveLocalPricelistItems(items []LocalPricelistItem) error {
return nil
}
// ReplaceLocalPricelistItems atomically replaces all items for a pricelist.
func (l *LocalDB) ReplaceLocalPricelistItems(pricelistID uint, items []LocalPricelistItem) error {
return l.db.Transaction(func(tx *gorm.DB) error {
if err := tx.Where("pricelist_id = ?", pricelistID).Delete(&LocalPricelistItem{}).Error; err != nil {
return err
}
if len(items) == 0 {
return nil
}
batchSize := 500
for i := 0; i < len(items); i += batchSize {
end := i + batchSize
if end > len(items) {
end = len(items)
}
if err := tx.CreateInBatches(items[i:end], batchSize).Error; err != nil {
return err
}
}
return nil
})
}
// GetLocalPricelistItems returns items for a local pricelist
func (l *LocalDB) GetLocalPricelistItems(pricelistID uint) ([]LocalPricelistItem, error) {
var items []LocalPricelistItem
@@ -684,6 +719,36 @@ func (l *LocalDB) GetLocalPriceForLot(pricelistID uint, lotName string) (float64
return item.Price, nil
}
// GetLocalLotCategoriesByServerPricelistID returns lot_category for each lot_name from a local pricelist resolved by server ID.
// Missing lots are not included in the map; caller is responsible for strict validation.
func (l *LocalDB) GetLocalLotCategoriesByServerPricelistID(serverPricelistID uint, lotNames []string) (map[string]string, error) {
result := make(map[string]string, len(lotNames))
if serverPricelistID == 0 || len(lotNames) == 0 {
return result, nil
}
localPL, err := l.GetLocalPricelistByServerID(serverPricelistID)
if err != nil {
return nil, err
}
type row struct {
LotName string `gorm:"column:lot_name"`
LotCategory string `gorm:"column:lot_category"`
}
var rows []row
if err := l.db.Model(&LocalPricelistItem{}).
Select("lot_name, lot_category").
Where("pricelist_id = ? AND lot_name IN ?", localPL.ID, lotNames).
Find(&rows).Error; err != nil {
return nil, err
}
for _, r := range rows {
result[r.LotName] = r.LotCategory
}
return result, nil
}
// MarkPricelistAsUsed marks a pricelist as used by a configuration
func (l *LocalDB) MarkPricelistAsUsed(pricelistID uint, isUsed bool) error {
return l.db.Model(&LocalPricelist{}).Where("id = ?", pricelistID).

View File

@@ -68,6 +68,26 @@ var localMigrations = []localMigration{
name: "Add warehouse_pricelist_id and competitor_pricelist_id to local_configurations",
run: addWarehouseCompetitorPriceLists,
},
{
id: "2026_02_11_local_pricelist_item_category",
name: "Add lot_category to local_pricelist_items and create indexes",
run: addLocalPricelistItemCategoryAndIndexes,
},
{
id: "2026_02_11_local_config_article",
name: "Add article to local_configurations",
run: addLocalConfigurationArticle,
},
{
id: "2026_02_11_local_config_server_model",
name: "Add server_model to local_configurations",
run: addLocalConfigurationServerModel,
},
{
id: "2026_02_11_local_config_support_code",
name: "Add support_code to local_configurations",
run: addLocalConfigurationSupportCode,
},
}
func runLocalMigrations(db *gorm.DB) error {
@@ -436,3 +456,112 @@ func addWarehouseCompetitorPriceLists(tx *gorm.DB) error {
slog.Info("added warehouse and competitor pricelist fields to local_configurations")
return nil
}
func addLocalPricelistItemCategoryAndIndexes(tx *gorm.DB) error {
type columnInfo struct {
Name string `gorm:"column:name"`
}
var columns []columnInfo
if err := tx.Raw(`
SELECT name FROM pragma_table_info('local_pricelist_items')
WHERE name IN ('lot_category')
`).Scan(&columns).Error; err != nil {
return fmt.Errorf("check local_pricelist_items(lot_category) existence: %w", err)
}
if len(columns) == 0 {
if err := tx.Exec(`
ALTER TABLE local_pricelist_items
ADD COLUMN lot_category TEXT
`).Error; err != nil {
return fmt.Errorf("add local_pricelist_items.lot_category: %w", err)
}
slog.Info("added lot_category to local_pricelist_items")
}
if err := tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_local_pricelist_items_pricelist_lot
ON local_pricelist_items(pricelist_id, lot_name)
`).Error; err != nil {
return fmt.Errorf("ensure idx_local_pricelist_items_pricelist_lot: %w", err)
}
if err := tx.Exec(`
CREATE INDEX IF NOT EXISTS idx_local_pricelist_items_lot_category
ON local_pricelist_items(lot_category)
`).Error; err != nil {
return fmt.Errorf("ensure idx_local_pricelist_items_lot_category: %w", err)
}
return nil
}
func addLocalConfigurationArticle(tx *gorm.DB) error {
type columnInfo struct {
Name string `gorm:"column:name"`
}
var columns []columnInfo
if err := tx.Raw(`
SELECT name FROM pragma_table_info('local_configurations')
WHERE name IN ('article')
`).Scan(&columns).Error; err != nil {
return fmt.Errorf("check local_configurations(article) existence: %w", err)
}
if len(columns) == 0 {
if err := tx.Exec(`
ALTER TABLE local_configurations
ADD COLUMN article TEXT
`).Error; err != nil {
return fmt.Errorf("add local_configurations.article: %w", err)
}
slog.Info("added article to local_configurations")
}
return nil
}
func addLocalConfigurationServerModel(tx *gorm.DB) error {
type columnInfo struct {
Name string `gorm:"column:name"`
}
var columns []columnInfo
if err := tx.Raw(`
SELECT name FROM pragma_table_info('local_configurations')
WHERE name IN ('server_model')
`).Scan(&columns).Error; err != nil {
return fmt.Errorf("check local_configurations(server_model) existence: %w", err)
}
if len(columns) == 0 {
if err := tx.Exec(`
ALTER TABLE local_configurations
ADD COLUMN server_model TEXT
`).Error; err != nil {
return fmt.Errorf("add local_configurations.server_model: %w", err)
}
slog.Info("added server_model to local_configurations")
}
return nil
}
func addLocalConfigurationSupportCode(tx *gorm.DB) error {
type columnInfo struct {
Name string `gorm:"column:name"`
}
var columns []columnInfo
if err := tx.Raw(`
SELECT name FROM pragma_table_info('local_configurations')
WHERE name IN ('support_code')
`).Scan(&columns).Error; err != nil {
return fmt.Errorf("check local_configurations(support_code) existence: %w", err)
}
if len(columns) == 0 {
if err := tx.Exec(`
ALTER TABLE local_configurations
ADD COLUMN support_code TEXT
`).Error; err != nil {
return fmt.Errorf("add local_configurations.support_code: %w", err)
}
slog.Info("added support_code to local_configurations")
}
return nil
}

View File

@@ -96,6 +96,9 @@ type LocalConfiguration struct {
Notes string `json:"notes"`
IsTemplate bool `gorm:"default:false" json:"is_template"`
ServerCount int `gorm:"default:1" json:"server_count"`
ServerModel string `gorm:"size:100" json:"server_model,omitempty"`
SupportCode string `gorm:"size:20" json:"support_code,omitempty"`
Article string `gorm:"size:80" json:"article,omitempty"`
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
@@ -172,6 +175,7 @@ type LocalPricelistItem struct {
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
PricelistID uint `gorm:"not null;index" json:"pricelist_id"`
LotName string `gorm:"not null" json:"lot_name"`
LotCategory string `gorm:"column:lot_category" json:"lot_category,omitempty"`
Price float64 `gorm:"not null" json:"price"`
AvailableQty *float64 `json:"available_qty,omitempty"`
Partnumbers LocalStringList `gorm:"type:text" json:"partnumbers,omitempty"`

View File

@@ -22,6 +22,9 @@ func BuildConfigurationSnapshot(localCfg *LocalConfiguration) (string, error) {
"notes": localCfg.Notes,
"is_template": localCfg.IsTemplate,
"server_count": localCfg.ServerCount,
"server_model": localCfg.ServerModel,
"support_code": localCfg.SupportCode,
"article": localCfg.Article,
"pricelist_id": localCfg.PricelistID,
"only_in_stock": localCfg.OnlyInStock,
"price_updated_at": localCfg.PriceUpdatedAt,
@@ -52,6 +55,9 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
Notes string `json:"notes"`
IsTemplate bool `json:"is_template"`
ServerCount int `json:"server_count"`
ServerModel string `json:"server_model"`
SupportCode string `json:"support_code"`
Article string `json:"article"`
PricelistID *uint `json:"pricelist_id"`
OnlyInStock bool `json:"only_in_stock"`
PriceUpdatedAt *time.Time `json:"price_updated_at"`
@@ -78,6 +84,9 @@ func DecodeConfigurationSnapshot(data string) (*LocalConfiguration, error) {
Notes: snapshot.Notes,
IsTemplate: snapshot.IsTemplate,
ServerCount: snapshot.ServerCount,
ServerModel: snapshot.ServerModel,
SupportCode: snapshot.SupportCode,
Article: snapshot.Article,
PricelistID: snapshot.PricelistID,
OnlyInStock: snapshot.OnlyInStock,
PriceUpdatedAt: snapshot.PriceUpdatedAt,