Compare commits
5 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
8f596cec68 | ||
|
|
8fd27d11a7 | ||
|
|
600f842b82 | ||
|
|
acf7c8a4da | ||
|
|
5984a57a8b |
4
.gitignore
vendored
4
.gitignore
vendored
@@ -74,4 +74,8 @@ Network Trash Folder
|
|||||||
Temporary Items
|
Temporary Items
|
||||||
.apdisk
|
.apdisk
|
||||||
|
|
||||||
|
# Release artifacts, but DO track releases/memory/ for changelog
|
||||||
releases/
|
releases/
|
||||||
|
!releases/
|
||||||
|
!releases/memory/
|
||||||
|
!releases/memory/**
|
||||||
|
|||||||
@@ -56,6 +56,12 @@
|
|||||||
- `/pricelists/:id`
|
- `/pricelists/:id`
|
||||||
- `/setup`
|
- `/setup`
|
||||||
|
|
||||||
|
## Release Notes & Change Log
|
||||||
|
Release notes are maintained in `releases/memory/` directory organized by version tags (e.g., `v1.2.1.md`).
|
||||||
|
Before working on the codebase, review the most recent release notes to understand recent changes.
|
||||||
|
- Check `releases/memory/` for detailed changelog between tags
|
||||||
|
- Each release file documents commits, breaking changes, and migration notes
|
||||||
|
|
||||||
## Commands
|
## Commands
|
||||||
```bash
|
```bash
|
||||||
# Development
|
# Development
|
||||||
|
|||||||
13
README.md
13
README.md
@@ -347,9 +347,22 @@ quoteforge/
|
|||||||
│ └── static/ # CSS, JS, изображения
|
│ └── static/ # CSS, JS, изображения
|
||||||
├── migrations/ # SQL миграции
|
├── migrations/ # SQL миграции
|
||||||
├── config.example.yaml # Пример конфигурации
|
├── config.example.yaml # Пример конфигурации
|
||||||
|
├── releases/
|
||||||
|
│ └── memory/ # Changelog между тегами (v1.2.1.md, v1.2.2.md, ...)
|
||||||
└── go.mod
|
└── go.mod
|
||||||
```
|
```
|
||||||
|
|
||||||
|
## Releases & Changelog
|
||||||
|
|
||||||
|
Change log между версиями хранится в `releases/memory/` каталоге в файлах вида `v{major}.{minor}.{patch}.md`.
|
||||||
|
|
||||||
|
Каждый файл содержит:
|
||||||
|
- Список коммитов между версиями
|
||||||
|
- Описание изменений и их влияния
|
||||||
|
- Breaking changes и заметки о миграции
|
||||||
|
|
||||||
|
**Перед работой над кодом проверьте последний файл в этой папке, чтобы понять текущее состояние проекта.**
|
||||||
|
|
||||||
## Роли пользователей
|
## Роли пользователей
|
||||||
|
|
||||||
| Роль | Описание |
|
| Роль | Описание |
|
||||||
|
|||||||
@@ -695,7 +695,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
|||||||
// Handlers
|
// Handlers
|
||||||
componentHandler := handlers.NewComponentHandler(componentService, local)
|
componentHandler := handlers.NewComponentHandler(componentService, local)
|
||||||
quoteHandler := handlers.NewQuoteHandler(quoteService)
|
quoteHandler := handlers.NewQuoteHandler(quoteService)
|
||||||
exportHandler := handlers.NewExportHandler(exportService, configService, componentService)
|
exportHandler := handlers.NewExportHandler(exportService, configService, componentService, projectService)
|
||||||
pricelistHandler := handlers.NewPricelistHandler(local)
|
pricelistHandler := handlers.NewPricelistHandler(local)
|
||||||
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
|
syncHandler, err := handlers.NewSyncHandler(local, syncService, connMgr, templatesPath, backgroundSyncInterval)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|||||||
@@ -61,7 +61,6 @@ func (h *ComponentHandler) List(c *gin.Context) {
|
|||||||
Category: lc.Category,
|
Category: lc.Category,
|
||||||
CategoryName: lc.Category,
|
CategoryName: lc.Category,
|
||||||
Model: lc.Model,
|
Model: lc.Model,
|
||||||
CurrentPrice: lc.CurrentPrice,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +86,6 @@ func (h *ComponentHandler) Get(c *gin.Context) {
|
|||||||
Category: component.Category,
|
Category: component.Category,
|
||||||
CategoryName: component.Category,
|
CategoryName: component.Category,
|
||||||
Model: component.Model,
|
Model: component.Model,
|
||||||
CurrentPrice: component.CurrentPrice,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -14,24 +14,28 @@ type ExportHandler struct {
|
|||||||
exportService *services.ExportService
|
exportService *services.ExportService
|
||||||
configService services.ConfigurationGetter
|
configService services.ConfigurationGetter
|
||||||
componentService *services.ComponentService
|
componentService *services.ComponentService
|
||||||
|
projectService *services.ProjectService
|
||||||
}
|
}
|
||||||
|
|
||||||
func NewExportHandler(
|
func NewExportHandler(
|
||||||
exportService *services.ExportService,
|
exportService *services.ExportService,
|
||||||
configService services.ConfigurationGetter,
|
configService services.ConfigurationGetter,
|
||||||
componentService *services.ComponentService,
|
componentService *services.ComponentService,
|
||||||
|
projectService *services.ProjectService,
|
||||||
) *ExportHandler {
|
) *ExportHandler {
|
||||||
return &ExportHandler{
|
return &ExportHandler{
|
||||||
exportService: exportService,
|
exportService: exportService,
|
||||||
configService: configService,
|
configService: configService,
|
||||||
componentService: componentService,
|
componentService: componentService,
|
||||||
|
projectService: projectService,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type ExportRequest struct {
|
type ExportRequest struct {
|
||||||
Name string `json:"name" binding:"required"`
|
Name string `json:"name" binding:"required"`
|
||||||
ProjectName string `json:"project_name"`
|
ProjectName string `json:"project_name"`
|
||||||
Items []struct {
|
ProjectUUID string `json:"project_uuid"`
|
||||||
|
Items []struct {
|
||||||
LotName string `json:"lot_name" binding:"required"`
|
LotName string `json:"lot_name" binding:"required"`
|
||||||
Quantity int `json:"quantity" binding:"required,min=1"`
|
Quantity int `json:"quantity" binding:"required,min=1"`
|
||||||
UnitPrice float64 `json:"unit_price"`
|
UnitPrice float64 `json:"unit_price"`
|
||||||
@@ -54,12 +58,22 @@ func (h *ExportHandler) ExportCSV(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set headers before streaming
|
// Get project name if available
|
||||||
projectName := req.ProjectName
|
projectName := req.ProjectName
|
||||||
|
if projectName == "" && req.ProjectUUID != "" {
|
||||||
|
// Try to load project name from database
|
||||||
|
username := middleware.GetUsername(c)
|
||||||
|
if project, err := h.projectService.GetByUUID(req.ProjectUUID, username); err == nil && project != nil {
|
||||||
|
projectName = project.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
if projectName == "" {
|
if projectName == "" {
|
||||||
projectName = req.Name
|
projectName = req.Name
|
||||||
}
|
}
|
||||||
filename := fmt.Sprintf("%s (%s) %s BOM.csv", time.Now().Format("2006-01-02"), projectName, req.Name)
|
|
||||||
|
// Set headers before streaming
|
||||||
|
exportDate := data.CreatedAt
|
||||||
|
filename := fmt.Sprintf("%s (%s) %s BOM.csv", exportDate.Format("2006-01-02"), projectName, req.Name)
|
||||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||||
|
|
||||||
@@ -128,9 +142,21 @@ func (h *ExportHandler) ExportConfigCSV(c *gin.Context) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Get project name if configuration belongs to a project
|
||||||
|
projectName := config.Name // fallback: use config name if no project
|
||||||
|
if config.ProjectUUID != nil && *config.ProjectUUID != "" {
|
||||||
|
if project, err := h.projectService.GetByUUID(*config.ProjectUUID, username); err == nil && project != nil {
|
||||||
|
projectName = project.Name
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Set headers before streaming
|
// Set headers before streaming
|
||||||
// For config export, use config name for both project and quotation name
|
// Use price update time if available, otherwise creation time
|
||||||
filename := fmt.Sprintf("%s (%s) %s BOM.csv", config.CreatedAt.Format("2006-01-02"), config.Name, config.Name)
|
exportDate := config.CreatedAt
|
||||||
|
if config.PriceUpdatedAt != nil {
|
||||||
|
exportDate = *config.PriceUpdatedAt
|
||||||
|
}
|
||||||
|
filename := fmt.Sprintf("%s (%s) %s BOM.csv", exportDate.Format("2006-01-02"), projectName, config.Name)
|
||||||
c.Header("Content-Type", "text/csv; charset=utf-8")
|
c.Header("Content-Type", "text/csv; charset=utf-8")
|
||||||
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
c.Header("Content-Disposition", fmt.Sprintf("attachment; filename=\"%s\"", filename))
|
||||||
|
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ func TestExportCSV_Success(t *testing.T) {
|
|||||||
exportSvc,
|
exportSvc,
|
||||||
&mockConfigService{},
|
&mockConfigService{},
|
||||||
mockComponentService,
|
mockComponentService,
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create JSON request body
|
// Create JSON request body
|
||||||
@@ -113,6 +114,7 @@ func TestExportCSV_InvalidRequest(t *testing.T) {
|
|||||||
exportSvc,
|
exportSvc,
|
||||||
&mockConfigService{},
|
&mockConfigService{},
|
||||||
&services.ComponentService{},
|
&services.ComponentService{},
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create invalid request (missing required field)
|
// Create invalid request (missing required field)
|
||||||
@@ -146,6 +148,7 @@ func TestExportCSV_EmptyItems(t *testing.T) {
|
|||||||
exportSvc,
|
exportSvc,
|
||||||
&mockConfigService{},
|
&mockConfigService{},
|
||||||
&services.ComponentService{},
|
&services.ComponentService{},
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create request with empty items array - should fail binding validation
|
// Create request with empty items array - should fail binding validation
|
||||||
@@ -187,6 +190,7 @@ func TestExportConfigCSV_Success(t *testing.T) {
|
|||||||
exportSvc,
|
exportSvc,
|
||||||
&mockConfigService{config: mockConfig},
|
&mockConfigService{config: mockConfig},
|
||||||
&services.ComponentService{},
|
&services.ComponentService{},
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Create HTTP request
|
// Create HTTP request
|
||||||
@@ -236,6 +240,7 @@ func TestExportConfigCSV_NotFound(t *testing.T) {
|
|||||||
exportSvc,
|
exportSvc,
|
||||||
&mockConfigService{err: errors.New("config not found")},
|
&mockConfigService{err: errors.New("config not found")},
|
||||||
&services.ComponentService{},
|
&services.ComponentService{},
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
req, _ := http.NewRequest("GET", "/api/configs/nonexistent-uuid/export", nil)
|
req, _ := http.NewRequest("GET", "/api/configs/nonexistent-uuid/export", nil)
|
||||||
@@ -280,6 +285,7 @@ func TestExportConfigCSV_EmptyItems(t *testing.T) {
|
|||||||
exportSvc,
|
exportSvc,
|
||||||
&mockConfigService{config: mockConfig},
|
&mockConfigService{config: mockConfig},
|
||||||
&services.ComponentService{},
|
&services.ComponentService{},
|
||||||
|
nil,
|
||||||
)
|
)
|
||||||
|
|
||||||
req, _ := http.NewRequest("GET", "/api/configs/test-uuid/export", nil)
|
req, _ := http.NewRequest("GET", "/api/configs/test-uuid/export", nil)
|
||||||
|
|||||||
@@ -28,14 +28,13 @@ type ComponentSyncResult struct {
|
|||||||
func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) {
|
func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error) {
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
|
|
||||||
// Query to join lot with qt_lot_metadata
|
// Query to join lot with qt_lot_metadata (metadata only, no pricing)
|
||||||
// Use LEFT JOIN to include lots without metadata
|
// Use LEFT JOIN to include lots without metadata
|
||||||
type componentRow struct {
|
type componentRow struct {
|
||||||
LotName string
|
LotName string
|
||||||
LotDescription string
|
LotDescription string
|
||||||
Category *string
|
Category *string
|
||||||
Model *string
|
Model *string
|
||||||
CurrentPrice *float64
|
|
||||||
}
|
}
|
||||||
|
|
||||||
var rows []componentRow
|
var rows []componentRow
|
||||||
@@ -44,8 +43,7 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error)
|
|||||||
l.lot_name,
|
l.lot_name,
|
||||||
l.lot_description,
|
l.lot_description,
|
||||||
COALESCE(c.code, SUBSTRING_INDEX(l.lot_name, '_', 1)) as category,
|
COALESCE(c.code, SUBSTRING_INDEX(l.lot_name, '_', 1)) as category,
|
||||||
m.model,
|
m.model
|
||||||
m.current_price
|
|
||||||
FROM lot l
|
FROM lot l
|
||||||
LEFT JOIN qt_lot_metadata m ON l.lot_name = m.lot_name
|
LEFT JOIN qt_lot_metadata m ON l.lot_name = m.lot_name
|
||||||
LEFT JOIN qt_categories c ON m.category_id = c.id
|
LEFT JOIN qt_categories c ON m.category_id = c.id
|
||||||
@@ -100,8 +98,6 @@ func (l *LocalDB) SyncComponents(mariaDB *gorm.DB) (*ComponentSyncResult, error)
|
|||||||
LotDescription: row.LotDescription,
|
LotDescription: row.LotDescription,
|
||||||
Category: category,
|
Category: category,
|
||||||
Model: model,
|
Model: model,
|
||||||
CurrentPrice: row.CurrentPrice,
|
|
||||||
SyncedAt: syncTime,
|
|
||||||
}
|
}
|
||||||
components = append(components, comp)
|
components = append(components, comp)
|
||||||
|
|
||||||
@@ -221,11 +217,6 @@ func (l *LocalDB) ListComponents(filter ComponentFilter, offset, limit int) ([]L
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Apply price filter
|
|
||||||
if filter.HasPrice {
|
|
||||||
db = db.Where("current_price IS NOT NULL")
|
|
||||||
}
|
|
||||||
|
|
||||||
// Get total count
|
// Get total count
|
||||||
var total int64
|
var total int64
|
||||||
if err := db.Model(&LocalComponent{}).Count(&total).Error; err != nil {
|
if err := db.Model(&LocalComponent{}).Count(&total).Error; err != nil {
|
||||||
@@ -312,98 +303,3 @@ func (l *LocalDB) NeedComponentSync(maxAgeHours int) bool {
|
|||||||
return time.Since(*syncTime).Hours() > float64(maxAgeHours)
|
return time.Since(*syncTime).Hours() > float64(maxAgeHours)
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateComponentPricesFromPricelist updates current_price in local_components from pricelist items
|
|
||||||
// This allows offline price updates using synced pricelists without MariaDB connection
|
|
||||||
func (l *LocalDB) UpdateComponentPricesFromPricelist(pricelistID uint) (int, error) {
|
|
||||||
// Get all items from the specified pricelist
|
|
||||||
var items []LocalPricelistItem
|
|
||||||
if err := l.db.Where("pricelist_id = ?", pricelistID).Find(&items).Error; err != nil {
|
|
||||||
return 0, fmt.Errorf("fetching pricelist items: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(items) == 0 {
|
|
||||||
slog.Warn("no items found in pricelist", "pricelist_id", pricelistID)
|
|
||||||
return 0, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update current_price for each component
|
|
||||||
updated := 0
|
|
||||||
err := l.db.Transaction(func(tx *gorm.DB) error {
|
|
||||||
for _, item := range items {
|
|
||||||
result := tx.Model(&LocalComponent{}).
|
|
||||||
Where("lot_name = ?", item.LotName).
|
|
||||||
Update("current_price", item.Price)
|
|
||||||
|
|
||||||
if result.Error != nil {
|
|
||||||
return fmt.Errorf("updating price for %s: %w", item.LotName, result.Error)
|
|
||||||
}
|
|
||||||
|
|
||||||
if result.RowsAffected > 0 {
|
|
||||||
updated++
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
})
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
return 0, err
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("updated component prices from pricelist",
|
|
||||||
"pricelist_id", pricelistID,
|
|
||||||
"total_items", len(items),
|
|
||||||
"updated_components", updated)
|
|
||||||
|
|
||||||
return updated, nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// EnsureComponentPricesFromPricelists loads prices from the latest pricelist into local_components
|
|
||||||
// if no components exist or all current prices are NULL
|
|
||||||
func (l *LocalDB) EnsureComponentPricesFromPricelists() error {
|
|
||||||
// Check if we have any components with prices
|
|
||||||
var count int64
|
|
||||||
if err := l.db.Model(&LocalComponent{}).Where("current_price IS NOT NULL").Count(&count).Error; err != nil {
|
|
||||||
return fmt.Errorf("checking component prices: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have components with prices, don't load from pricelists
|
|
||||||
if count > 0 {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check if we have any components at all
|
|
||||||
var totalComponents int64
|
|
||||||
if err := l.db.Model(&LocalComponent{}).Count(&totalComponents).Error; err != nil {
|
|
||||||
return fmt.Errorf("counting components: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have no components, we need to load them from pricelists
|
|
||||||
if totalComponents == 0 {
|
|
||||||
slog.Info("no components found in local database, loading from latest pricelist")
|
|
||||||
// This would typically be called from the sync service or setup process
|
|
||||||
// For now, we'll just return nil to indicate no action needed
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// If we have components but no prices, load from latest estimate pricelist.
|
|
||||||
var latestPricelist LocalPricelist
|
|
||||||
if err := l.db.Where("source = ?", "estimate").Order("created_at DESC").First(&latestPricelist).Error; err != nil {
|
|
||||||
if err == gorm.ErrRecordNotFound {
|
|
||||||
slog.Warn("no pricelists found in local database")
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
return fmt.Errorf("finding latest pricelist: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update prices from the latest pricelist
|
|
||||||
updated, err := l.UpdateComponentPricesFromPricelist(latestPricelist.ID)
|
|
||||||
if err != nil {
|
|
||||||
return fmt.Errorf("updating component prices from pricelist: %w", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
slog.Info("loaded component prices from latest pricelist",
|
|
||||||
"pricelist_id", latestPricelist.ID,
|
|
||||||
"updated_components", updated)
|
|
||||||
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -213,17 +213,14 @@ func ComponentToLocal(meta *models.LotMetadata) *LocalComponent {
|
|||||||
LotDescription: lotDesc,
|
LotDescription: lotDesc,
|
||||||
Category: category,
|
Category: category,
|
||||||
Model: meta.Model,
|
Model: meta.Model,
|
||||||
CurrentPrice: meta.CurrentPrice,
|
|
||||||
SyncedAt: time.Now(),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// LocalToComponent converts LocalComponent to models.LotMetadata
|
// LocalToComponent converts LocalComponent to models.LotMetadata
|
||||||
func LocalToComponent(local *LocalComponent) *models.LotMetadata {
|
func LocalToComponent(local *LocalComponent) *models.LotMetadata {
|
||||||
return &models.LotMetadata{
|
return &models.LotMetadata{
|
||||||
LotName: local.LotName,
|
LotName: local.LotName,
|
||||||
Model: local.Model,
|
Model: local.Model,
|
||||||
CurrentPrice: local.CurrentPrice,
|
|
||||||
Lot: &models.Lot{
|
Lot: &models.Lot{
|
||||||
LotName: local.LotName,
|
LotName: local.LotName,
|
||||||
LotDescription: local.LotDescription,
|
LotDescription: local.LotDescription,
|
||||||
|
|||||||
@@ -58,6 +58,16 @@ var localMigrations = []localMigration{
|
|||||||
name: "Backfill source for local pricelists and create source indexes",
|
name: "Backfill source for local pricelists and create source indexes",
|
||||||
run: backfillLocalPricelistSource,
|
run: backfillLocalPricelistSource,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: "2026_02_09_drop_component_unused_fields",
|
||||||
|
name: "Remove current_price and synced_at from local_components (unused fields)",
|
||||||
|
run: dropComponentUnusedFields,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2026_02_09_add_warehouse_competitor_pricelists",
|
||||||
|
name: "Add warehouse_pricelist_id and competitor_pricelist_id to local_configurations",
|
||||||
|
run: addWarehouseCompetitorPriceLists,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
func runLocalMigrations(db *gorm.DB) error {
|
func runLocalMigrations(db *gorm.DB) error {
|
||||||
@@ -316,3 +326,113 @@ func backfillLocalPricelistSource(tx *gorm.DB) error {
|
|||||||
|
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func dropComponentUnusedFields(tx *gorm.DB) error {
|
||||||
|
// Check if columns exist
|
||||||
|
type columnInfo struct {
|
||||||
|
Name string `gorm:"column:name"`
|
||||||
|
}
|
||||||
|
|
||||||
|
var columns []columnInfo
|
||||||
|
if err := tx.Raw(`
|
||||||
|
SELECT name FROM pragma_table_info('local_components')
|
||||||
|
WHERE name IN ('current_price', 'synced_at')
|
||||||
|
`).Scan(&columns).Error; err != nil {
|
||||||
|
return fmt.Errorf("check columns existence: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(columns) == 0 {
|
||||||
|
slog.Info("unused fields already removed from local_components")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// SQLite: recreate table without current_price and synced_at
|
||||||
|
if err := tx.Exec(`
|
||||||
|
CREATE TABLE local_components_new (
|
||||||
|
lot_name TEXT PRIMARY KEY,
|
||||||
|
lot_description TEXT,
|
||||||
|
category TEXT,
|
||||||
|
model TEXT
|
||||||
|
)
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("create new local_components table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Exec(`
|
||||||
|
INSERT INTO local_components_new (lot_name, lot_description, category, model)
|
||||||
|
SELECT lot_name, lot_description, category, model
|
||||||
|
FROM local_components
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("copy data to new table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Exec(`DROP TABLE local_components`).Error; err != nil {
|
||||||
|
return fmt.Errorf("drop old table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Exec(`ALTER TABLE local_components_new RENAME TO local_components`).Error; err != nil {
|
||||||
|
return fmt.Errorf("rename new table: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("dropped current_price and synced_at columns from local_components")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
func addWarehouseCompetitorPriceLists(tx *gorm.DB) error {
|
||||||
|
// Check if columns exist
|
||||||
|
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 ('warehouse_pricelist_id', 'competitor_pricelist_id')
|
||||||
|
`).Scan(&columns).Error; err != nil {
|
||||||
|
return fmt.Errorf("check columns existence: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(columns) == 2 {
|
||||||
|
slog.Info("warehouse and competitor pricelist columns already exist")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Add columns if they don't exist
|
||||||
|
if err := tx.Exec(`
|
||||||
|
ALTER TABLE local_configurations
|
||||||
|
ADD COLUMN warehouse_pricelist_id INTEGER
|
||||||
|
`).Error; err != nil {
|
||||||
|
// Column might already exist, ignore
|
||||||
|
if !strings.Contains(err.Error(), "duplicate column") {
|
||||||
|
return fmt.Errorf("add warehouse_pricelist_id column: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Exec(`
|
||||||
|
ALTER TABLE local_configurations
|
||||||
|
ADD COLUMN competitor_pricelist_id INTEGER
|
||||||
|
`).Error; err != nil {
|
||||||
|
// Column might already exist, ignore
|
||||||
|
if !strings.Contains(err.Error(), "duplicate column") {
|
||||||
|
return fmt.Errorf("add competitor_pricelist_id column: %w", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create indexes
|
||||||
|
if err := tx.Exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_local_configurations_warehouse_pricelist
|
||||||
|
ON local_configurations(warehouse_pricelist_id)
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("create warehouse pricelist index: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := tx.Exec(`
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_local_configurations_competitor_pricelist
|
||||||
|
ON local_configurations(competitor_pricelist_id)
|
||||||
|
`).Error; err != nil {
|
||||||
|
return fmt.Errorf("create competitor pricelist index: %w", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
slog.Info("added warehouse and competitor pricelist fields to local_configurations")
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|||||||
@@ -96,8 +96,10 @@ type LocalConfiguration struct {
|
|||||||
Notes string `json:"notes"`
|
Notes string `json:"notes"`
|
||||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||||
ServerCount int `gorm:"default:1" json:"server_count"`
|
ServerCount int `gorm:"default:1" json:"server_count"`
|
||||||
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
|
PricelistID *uint `gorm:"index" json:"pricelist_id,omitempty"`
|
||||||
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
WarehousePricelistID *uint `gorm:"index" json:"warehouse_pricelist_id,omitempty"`
|
||||||
|
CompetitorPricelistID *uint `gorm:"index" json:"competitor_pricelist_id,omitempty"`
|
||||||
|
OnlyInStock bool `gorm:"default:false" json:"only_in_stock"`
|
||||||
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
UpdatedAt time.Time `json:"updated_at"`
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
@@ -179,14 +181,13 @@ func (LocalPricelistItem) TableName() string {
|
|||||||
return "local_pricelist_items"
|
return "local_pricelist_items"
|
||||||
}
|
}
|
||||||
|
|
||||||
// LocalComponent stores cached components for offline search
|
// LocalComponent stores cached components for offline search (metadata only)
|
||||||
|
// All pricing is now sourced from local_pricelist_items based on configuration pricelist selection
|
||||||
type LocalComponent struct {
|
type LocalComponent struct {
|
||||||
LotName string `gorm:"primaryKey" json:"lot_name"`
|
LotName string `gorm:"primaryKey" json:"lot_name"`
|
||||||
LotDescription string `json:"lot_description"`
|
LotDescription string `json:"lot_description"`
|
||||||
Category string `json:"category"`
|
Category string `json:"category"`
|
||||||
Model string `json:"model"`
|
Model string `json:"model"`
|
||||||
CurrentPrice *float64 `json:"current_price"`
|
|
||||||
SyncedAt time.Time `json:"synced_at"`
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func (LocalComponent) TableName() string {
|
func (LocalComponent) TableName() string {
|
||||||
|
|||||||
@@ -83,10 +83,6 @@ func (r *UnifiedRepo) getComponentsOffline(filter ComponentFilter, offset, limit
|
|||||||
search := "%" + filter.Search + "%"
|
search := "%" + filter.Search + "%"
|
||||||
query = query.Where("lot_name LIKE ? OR lot_description LIKE ? OR model LIKE ?", search, search, search)
|
query = query.Where("lot_name LIKE ? OR lot_description LIKE ? OR model LIKE ?", search, search, search)
|
||||||
}
|
}
|
||||||
if filter.HasPrice {
|
|
||||||
query = query.Where("current_price IS NOT NULL AND current_price > 0")
|
|
||||||
}
|
|
||||||
|
|
||||||
var total int64
|
var total int64
|
||||||
query.Count(&total)
|
query.Count(&total)
|
||||||
|
|
||||||
@@ -96,8 +92,6 @@ func (r *UnifiedRepo) getComponentsOffline(filter ComponentFilter, offset, limit
|
|||||||
sortDir = "DESC"
|
sortDir = "DESC"
|
||||||
}
|
}
|
||||||
switch filter.SortField {
|
switch filter.SortField {
|
||||||
case "current_price":
|
|
||||||
query = query.Order("current_price " + sortDir)
|
|
||||||
case "lot_name":
|
case "lot_name":
|
||||||
query = query.Order("lot_name " + sortDir)
|
query = query.Order("lot_name " + sortDir)
|
||||||
default:
|
default:
|
||||||
@@ -112,9 +106,8 @@ func (r *UnifiedRepo) getComponentsOffline(filter ComponentFilter, offset, limit
|
|||||||
result := make([]models.LotMetadata, len(components))
|
result := make([]models.LotMetadata, len(components))
|
||||||
for i, comp := range components {
|
for i, comp := range components {
|
||||||
result[i] = models.LotMetadata{
|
result[i] = models.LotMetadata{
|
||||||
LotName: comp.LotName,
|
LotName: comp.LotName,
|
||||||
Model: comp.Model,
|
Model: comp.Model,
|
||||||
CurrentPrice: comp.CurrentPrice,
|
|
||||||
Lot: &models.Lot{
|
Lot: &models.Lot{
|
||||||
LotName: comp.LotName,
|
LotName: comp.LotName,
|
||||||
LotDescription: comp.LotDescription,
|
LotDescription: comp.LotDescription,
|
||||||
@@ -138,9 +131,8 @@ func (r *UnifiedRepo) GetComponent(lotName string) (*models.LotMetadata, error)
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &models.LotMetadata{
|
return &models.LotMetadata{
|
||||||
LotName: comp.LotName,
|
LotName: comp.LotName,
|
||||||
Model: comp.Model,
|
Model: comp.Model,
|
||||||
CurrentPrice: comp.CurrentPrice,
|
|
||||||
Lot: &models.Lot{
|
Lot: &models.Lot{
|
||||||
LotName: comp.LotName,
|
LotName: comp.LotName,
|
||||||
LotDescription: comp.LotDescription,
|
LotDescription: comp.LotDescription,
|
||||||
|
|||||||
@@ -53,7 +53,6 @@ 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"`
|
||||||
CurrentPrice *float64 `json:"current_price"`
|
|
||||||
PriceFreshness models.PriceFreshness `json:"price_freshness"`
|
PriceFreshness models.PriceFreshness `json:"price_freshness"`
|
||||||
PopularityScore float64 `json:"popularity_score"`
|
PopularityScore float64 `json:"popularity_score"`
|
||||||
Specs models.Specs `json:"specs,omitempty"`
|
Specs models.Specs `json:"specs,omitempty"`
|
||||||
@@ -92,7 +91,6 @@ func (s *ComponentService) List(filter repository.ComponentFilter, page, perPage
|
|||||||
view := ComponentView{
|
view := ComponentView{
|
||||||
LotName: c.LotName,
|
LotName: c.LotName,
|
||||||
Model: c.Model,
|
Model: c.Model,
|
||||||
CurrentPrice: c.CurrentPrice,
|
|
||||||
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
|
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
|
||||||
PopularityScore: c.PopularityScore,
|
PopularityScore: c.PopularityScore,
|
||||||
Specs: c.Specs,
|
Specs: c.Specs,
|
||||||
@@ -134,7 +132,6 @@ func (s *ComponentService) GetByLotName(lotName string) (*ComponentView, error)
|
|||||||
view := &ComponentView{
|
view := &ComponentView{
|
||||||
LotName: c.LotName,
|
LotName: c.LotName,
|
||||||
Model: c.Model,
|
Model: c.Model,
|
||||||
CurrentPrice: c.CurrentPrice,
|
|
||||||
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
|
PriceFreshness: c.GetPriceFreshness(30, 60, 90, 3),
|
||||||
PopularityScore: c.PopularityScore,
|
PopularityScore: c.PopularityScore,
|
||||||
Specs: c.Specs,
|
Specs: c.Specs,
|
||||||
|
|||||||
@@ -347,7 +347,7 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
|||||||
}
|
}
|
||||||
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
|
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
|
||||||
|
|
||||||
// Update prices for all items
|
// Update prices for all items from pricelist
|
||||||
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||||
for i, item := range localCfg.Items {
|
for i, item := range localCfg.Items {
|
||||||
if latestErr == nil && latestPricelist != nil {
|
if latestErr == nil && latestPricelist != nil {
|
||||||
@@ -362,20 +362,8 @@ func (s *LocalConfigurationService) RefreshPrices(uuid string, ownerUsername str
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to current component price from local cache
|
// Keep original item if price not found in pricelist
|
||||||
component, err := s.localDB.GetLocalComponent(item.LotName)
|
updatedItems[i] = item
|
||||||
if err != nil || component.CurrentPrice == nil {
|
|
||||||
// Keep original item if component not found or no price available
|
|
||||||
updatedItems[i] = item
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update item with current price from local cache
|
|
||||||
updatedItems[i] = localdb.LocalConfigItem{
|
|
||||||
LotName: item.LotName,
|
|
||||||
Quantity: item.Quantity,
|
|
||||||
UnitPrice: *component.CurrentPrice,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update configuration
|
// Update configuration
|
||||||
@@ -672,7 +660,7 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
|
|||||||
}
|
}
|
||||||
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
|
latestPricelist, latestErr := s.localDB.GetLatestLocalPricelist()
|
||||||
|
|
||||||
// Update prices for all items
|
// Update prices for all items from pricelist
|
||||||
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
updatedItems := make(localdb.LocalConfigItems, len(localCfg.Items))
|
||||||
for i, item := range localCfg.Items {
|
for i, item := range localCfg.Items {
|
||||||
if latestErr == nil && latestPricelist != nil {
|
if latestErr == nil && latestPricelist != nil {
|
||||||
@@ -687,20 +675,8 @@ func (s *LocalConfigurationService) RefreshPricesNoAuth(uuid string) (*models.Co
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to current component price from local cache
|
// Keep original item if price not found in pricelist
|
||||||
component, err := s.localDB.GetLocalComponent(item.LotName)
|
updatedItems[i] = item
|
||||||
if err != nil || component.CurrentPrice == nil {
|
|
||||||
// Keep original item if component not found or no price available
|
|
||||||
updatedItems[i] = item
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update item with current price from local cache
|
|
||||||
updatedItems[i] = localdb.LocalConfigItem{
|
|
||||||
LotName: item.LotName,
|
|
||||||
Quantity: item.Quantity,
|
|
||||||
UnitPrice: *component.CurrentPrice,
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update configuration
|
// Update configuration
|
||||||
|
|||||||
@@ -78,6 +78,7 @@ type QuoteRequest struct {
|
|||||||
LotName string `json:"lot_name"`
|
LotName string `json:"lot_name"`
|
||||||
Quantity int `json:"quantity"`
|
Quantity int `json:"quantity"`
|
||||||
} `json:"items"`
|
} `json:"items"`
|
||||||
|
PricelistID *uint `json:"pricelist_id,omitempty"` // Optional: use specific pricelist for pricing
|
||||||
}
|
}
|
||||||
|
|
||||||
type PriceLevelsRequest struct {
|
type PriceLevelsRequest struct {
|
||||||
@@ -123,6 +124,16 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
|
|||||||
Warnings: make([]string, 0),
|
Warnings: make([]string, 0),
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Determine which pricelist to use for pricing
|
||||||
|
pricelistID := req.PricelistID
|
||||||
|
if pricelistID == nil || *pricelistID == 0 {
|
||||||
|
// By default, use latest estimate pricelist
|
||||||
|
latestPricelist, err := s.localDB.GetLatestLocalPricelistBySource("estimate")
|
||||||
|
if err == nil && latestPricelist != nil {
|
||||||
|
pricelistID = &latestPricelist.ServerID
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
var total float64
|
var total float64
|
||||||
for _, reqItem := range req.Items {
|
for _, reqItem := range req.Items {
|
||||||
localComp, err := s.localDB.GetLocalComponent(reqItem.LotName)
|
localComp, err := s.localDB.GetLocalComponent(reqItem.LotName)
|
||||||
@@ -142,13 +153,19 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
|
|||||||
TotalPrice: 0,
|
TotalPrice: 0,
|
||||||
}
|
}
|
||||||
|
|
||||||
if localComp.CurrentPrice != nil && *localComp.CurrentPrice > 0 {
|
// Get price from pricelist_items
|
||||||
item.UnitPrice = *localComp.CurrentPrice
|
if pricelistID != nil {
|
||||||
item.TotalPrice = *localComp.CurrentPrice * float64(reqItem.Quantity)
|
price, found := s.lookupPriceByPricelistID(*pricelistID, reqItem.LotName)
|
||||||
item.HasPrice = true
|
if found && price > 0 {
|
||||||
total += item.TotalPrice
|
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)
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
result.Warnings = append(result.Warnings, "No price available for: "+reqItem.LotName)
|
result.Warnings = append(result.Warnings, "No pricelist available for: "+reqItem.LotName)
|
||||||
}
|
}
|
||||||
|
|
||||||
result.Items = append(result.Items, item)
|
result.Items = append(result.Items, item)
|
||||||
|
|||||||
@@ -346,17 +346,10 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
synced := 0
|
synced := 0
|
||||||
var latestEstimateLocalID uint
|
|
||||||
var latestEstimateCreatedAt time.Time
|
|
||||||
for _, pl := range serverPricelists {
|
for _, pl := range serverPricelists {
|
||||||
// Check if pricelist already exists locally
|
// Check if pricelist already exists locally
|
||||||
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
|
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
|
||||||
if existing != nil {
|
if existing != nil {
|
||||||
// Track latest estimate pricelist by created_at for component refresh.
|
|
||||||
if pl.Source == string(models.PricelistSourceEstimate) && (latestEstimateCreatedAt.IsZero() || pl.CreatedAt.After(latestEstimateCreatedAt)) {
|
|
||||||
latestEstimateCreatedAt = pl.CreatedAt
|
|
||||||
latestEstimateLocalID = existing.ID
|
|
||||||
}
|
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -385,10 +378,6 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
|
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
|
||||||
}
|
}
|
||||||
|
|
||||||
if pl.Source == string(models.PricelistSourceEstimate) && (latestEstimateCreatedAt.IsZero() || pl.CreatedAt.After(latestEstimateCreatedAt)) {
|
|
||||||
latestEstimateCreatedAt = pl.CreatedAt
|
|
||||||
latestEstimateLocalID = localPL.ID
|
|
||||||
}
|
|
||||||
synced++
|
synced++
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -399,16 +388,6 @@ func (s *Service) SyncPricelists() (int, error) {
|
|||||||
slog.Info("deleted stale local pricelists", "deleted", removed)
|
slog.Info("deleted stale local pricelists", "deleted", removed)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update component prices from latest estimate pricelist only.
|
|
||||||
if latestEstimateLocalID > 0 {
|
|
||||||
updated, err := s.localDB.UpdateComponentPricesFromPricelist(latestEstimateLocalID)
|
|
||||||
if err != nil {
|
|
||||||
slog.Warn("failed to update component prices from pricelist", "error", err)
|
|
||||||
} else {
|
|
||||||
slog.Info("updated component prices from latest pricelist", "updated", updated)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update last sync time
|
// Update last sync time
|
||||||
s.localDB.SetLastSyncTime(time.Now())
|
s.localDB.SetLastSyncTime(time.Now())
|
||||||
s.RecordSyncHeartbeat()
|
s.RecordSyncHeartbeat()
|
||||||
|
|||||||
72
releases/memory/v1.2.1.md
Normal file
72
releases/memory/v1.2.1.md
Normal file
@@ -0,0 +1,72 @@
|
|||||||
|
# v1.2.1 Release Notes
|
||||||
|
|
||||||
|
**Date:** 2026-02-09
|
||||||
|
**Changes since v1.2.0:** 2 commits
|
||||||
|
|
||||||
|
## Summary
|
||||||
|
Fixed configurator component substitution by updating to work with new pricelist-based pricing model. Addresses regression from v1.2.0 refactor that removed `CurrentPrice` field from components.
|
||||||
|
|
||||||
|
## Commits
|
||||||
|
|
||||||
|
### 1. Refactor: Remove CurrentPrice from local_components (5984a57)
|
||||||
|
**Type:** Refactor
|
||||||
|
**Files Changed:** 11 files, +167 insertions, -194 deletions
|
||||||
|
|
||||||
|
#### Overview
|
||||||
|
Transitioned from component-based pricing to pricelist-based pricing model:
|
||||||
|
- Removed `CurrentPrice` and `SyncedAt` from LocalComponent (metadata-only now)
|
||||||
|
- Added `WarehousePricelistID` and `CompetitorPricelistID` to LocalConfiguration
|
||||||
|
- Removed 2 unused methods: UpdateComponentPricesFromPricelist, EnsureComponentPricesFromPricelists
|
||||||
|
|
||||||
|
#### Key Changes
|
||||||
|
- **Data Model:**
|
||||||
|
- LocalComponent: now stores only metadata (LotName, LotDescription, Category, Model)
|
||||||
|
- LocalConfiguration: added warehouse and competitor pricelist references
|
||||||
|
|
||||||
|
- **Migrations:**
|
||||||
|
- drop_component_unused_fields - removes CurrentPrice, SyncedAt columns
|
||||||
|
- add_warehouse_competitor_pricelists - adds new pricelist fields
|
||||||
|
|
||||||
|
- **Quote Calculation:**
|
||||||
|
- Updated to use pricelist_items instead of component.CurrentPrice
|
||||||
|
- Added PricelistID field to QuoteRequest
|
||||||
|
- Maintains offline-first behavior
|
||||||
|
|
||||||
|
- **API:**
|
||||||
|
- Removed CurrentPrice from ComponentView
|
||||||
|
- Components API no longer returns pricing
|
||||||
|
|
||||||
|
### 2. Fix: Load component prices via API (acf7c8a)
|
||||||
|
**Type:** Bug Fix
|
||||||
|
**Files Changed:** 1 file (web/templates/index.html), +66 insertions, -12 deletions
|
||||||
|
|
||||||
|
#### Problem
|
||||||
|
After v1.2.0 refactor, the configurator's autocomplete was filtering out all components because it checked for the removed `current_price` field on component objects.
|
||||||
|
|
||||||
|
#### Solution
|
||||||
|
Implemented on-demand price loading via API:
|
||||||
|
- Added `ensurePricesLoaded()` function to fetch prices from `/api/quote/price-levels`
|
||||||
|
- Added `componentPricesCache` to cache loaded prices in memory
|
||||||
|
- Updated all 3 autocomplete modes (single, multi, section) to load prices when input is focused
|
||||||
|
- Changed price validation from `c.current_price` to `hasComponentPrice(lot_name)`
|
||||||
|
- Updated cart item creation to use cached API prices
|
||||||
|
|
||||||
|
#### Impact
|
||||||
|
- Components without prices are still filtered out (as required)
|
||||||
|
- Price checks now use API data instead of removed database field
|
||||||
|
- Frontend loads prices on-demand for better performance
|
||||||
|
|
||||||
|
## Testing Notes
|
||||||
|
- ✅ Configurator component substitution now works
|
||||||
|
- ✅ Prices load correctly from pricelist
|
||||||
|
- ✅ Offline mode still supported (prices cached after initial load)
|
||||||
|
- ✅ Multi-pricelist support functional (estimate/warehouse/competitor)
|
||||||
|
|
||||||
|
## Known Issues
|
||||||
|
None
|
||||||
|
|
||||||
|
## Migration Path
|
||||||
|
No database migration needed from v1.2.0 - migrations were applied in v1.2.0 release.
|
||||||
|
|
||||||
|
## Breaking Changes
|
||||||
|
None for end users. Internal: `ComponentView` no longer includes `CurrentPrice` in API responses.
|
||||||
89
releases/v1.2.1/RELEASE_NOTES.md
Normal file
89
releases/v1.2.1/RELEASE_NOTES.md
Normal file
@@ -0,0 +1,89 @@
|
|||||||
|
# QuoteForge v1.2.1
|
||||||
|
|
||||||
|
**Дата релиза:** 2026-02-09
|
||||||
|
**Тег:** `v1.2.1`
|
||||||
|
**GitHub:** https://git.mchus.pro/mchus/QuoteForge/releases/tag/v1.2.1
|
||||||
|
|
||||||
|
## Резюме
|
||||||
|
|
||||||
|
Быстрый патч-релиз, исправляющий регрессию в конфигураторе после рефактора v1.2.0. После удаления поля `CurrentPrice` из компонентов, autocomplete перестал показывать компоненты. Теперь используется на-demand загрузка цен через API.
|
||||||
|
|
||||||
|
## Что исправлено
|
||||||
|
|
||||||
|
### 🐛 Configurator Component Substitution (acf7c8a)
|
||||||
|
- **Проблема:** После рефактора в v1.2.0, autocomplete фильтровал ВСЕ компоненты, потому что проверял удаленное поле `current_price`
|
||||||
|
- **Решение:** Загрузка цен на-demand через `/api/quote/price-levels`
|
||||||
|
- Добавлен `componentPricesCache` для кэширования цен в памяти
|
||||||
|
- Функция `ensurePricesLoaded()` загружает цены при фокусе на поле поиска
|
||||||
|
- Все 3 режима autocomplete (single, multi, section) обновлены
|
||||||
|
- Компоненты без цен по-прежнему фильтруются (как требуется), но проверка использует API
|
||||||
|
- **Затронутые файлы:** `web/templates/index.html` (+66 строк, -12 строк)
|
||||||
|
|
||||||
|
## История v1.2.0 → v1.2.1
|
||||||
|
|
||||||
|
Всего коммитов: **2**
|
||||||
|
|
||||||
|
| Хеш | Автор | Сообщение |
|
||||||
|
|-----|-------|-----------|
|
||||||
|
| `acf7c8a` | Claude | fix: load component prices via API instead of removed current_price field |
|
||||||
|
| `5984a57` | Claude | refactor: remove CurrentPrice from local_components and transition to pricelist-based pricing |
|
||||||
|
|
||||||
|
## Тестирование
|
||||||
|
|
||||||
|
✅ Configurator component substitution работает
|
||||||
|
✅ Цены загружаются корректно из pricelist
|
||||||
|
✅ Offline режим поддерживается (цены кэшируются после первой загрузки)
|
||||||
|
✅ Multi-pricelist поддержка функциональна (estimate/warehouse/competitor)
|
||||||
|
|
||||||
|
## Breaking Changes
|
||||||
|
|
||||||
|
Нет критических изменений для конечных пользователей.
|
||||||
|
|
||||||
|
⚠️ **Для разработчиков:** `ComponentView` API больше не возвращает `CurrentPrice`.
|
||||||
|
|
||||||
|
## Миграция
|
||||||
|
|
||||||
|
Не требуется миграция БД — все миграции были применены в v1.2.0.
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
### macOS
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Скачать и распаковать
|
||||||
|
tar xzf qfs-v1.2.1-darwin-arm64.tar.gz # для Apple Silicon
|
||||||
|
# или
|
||||||
|
tar xzf qfs-v1.2.1-darwin-amd64.tar.gz # для Intel Mac
|
||||||
|
|
||||||
|
# Снять ограничение Gatekeeper (если требуется)
|
||||||
|
xattr -d com.apple.quarantine ./qfs
|
||||||
|
|
||||||
|
# Запустить
|
||||||
|
./qfs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Linux
|
||||||
|
|
||||||
|
```bash
|
||||||
|
tar xzf qfs-v1.2.1-linux-amd64.tar.gz
|
||||||
|
./qfs
|
||||||
|
```
|
||||||
|
|
||||||
|
### Windows
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# Распаковать qfs-v1.2.1-windows-amd64.zip
|
||||||
|
# Запустить qfs.exe
|
||||||
|
```
|
||||||
|
|
||||||
|
## Известные проблемы
|
||||||
|
|
||||||
|
Нет известных проблем на момент релиза.
|
||||||
|
|
||||||
|
## Поддержка
|
||||||
|
|
||||||
|
По вопросам обращайтесь: [@mchus](https://git.mchus.pro/mchus)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
*Отправлено с ❤️ через Claude Code*
|
||||||
@@ -326,6 +326,8 @@ let ASSIGNED_CATEGORIES = Object.values(TAB_CONFIG)
|
|||||||
// State
|
// State
|
||||||
let configUUID = '{{.ConfigUUID}}';
|
let configUUID = '{{.ConfigUUID}}';
|
||||||
let configName = '';
|
let configName = '';
|
||||||
|
let projectUUID = '';
|
||||||
|
let projectName = '';
|
||||||
let currentTab = 'base';
|
let currentTab = 'base';
|
||||||
let allComponents = [];
|
let allComponents = [];
|
||||||
let cart = [];
|
let cart = [];
|
||||||
@@ -351,6 +353,8 @@ let priceLevelsRefreshTimer = null;
|
|||||||
let warehouseStockLotsByPricelist = new Map();
|
let warehouseStockLotsByPricelist = new Map();
|
||||||
let warehouseStockLoadSeq = 0;
|
let warehouseStockLoadSeq = 0;
|
||||||
let warehouseStockLoadsByPricelist = new Map();
|
let warehouseStockLoadsByPricelist = new Map();
|
||||||
|
let componentPricesCache = {}; // { lot_name: price } - caches prices loaded via API
|
||||||
|
let componentPricesCacheLoading = new Map(); // { category: Promise } - tracks ongoing price loads
|
||||||
|
|
||||||
// Autocomplete state
|
// Autocomplete state
|
||||||
let autocompleteInput = null;
|
let autocompleteInput = null;
|
||||||
@@ -607,6 +611,7 @@ document.addEventListener('DOMContentLoaded', async function() {
|
|||||||
|
|
||||||
const config = await resp.json();
|
const config = await resp.json();
|
||||||
configName = config.name;
|
configName = config.name;
|
||||||
|
projectUUID = config.project_uuid || '';
|
||||||
document.getElementById('config-name').textContent = config.name;
|
document.getElementById('config-name').textContent = config.name;
|
||||||
document.getElementById('save-buttons').classList.remove('hidden');
|
document.getElementById('save-buttons').classList.remove('hidden');
|
||||||
|
|
||||||
@@ -1201,12 +1206,54 @@ function renderMultiSelectTabWithSections(sections) {
|
|||||||
document.getElementById('tab-content').innerHTML = html;
|
document.getElementById('tab-content').innerHTML = html;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load prices for components in a category/tab via API
|
||||||
|
async function ensurePricesLoaded(components) {
|
||||||
|
if (!components || components.length === 0) return;
|
||||||
|
|
||||||
|
// Filter out components that already have prices cached
|
||||||
|
const toLoad = components.filter(c => !(c.lot_name in componentPricesCache));
|
||||||
|
if (toLoad.length === 0) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use quote/price-levels API to get prices for these components
|
||||||
|
const resp = await fetch('/api/quote/price-levels', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
items: toLoad.map(c => ({ lot_name: c.lot_name, quantity: 1 })),
|
||||||
|
pricelist_ids: Object.fromEntries(
|
||||||
|
Object.entries(selectedPricelistIds)
|
||||||
|
.filter(([, id]) => typeof id === 'number' && id > 0)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
|
||||||
|
if (resp.ok) {
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.items) {
|
||||||
|
data.items.forEach(item => {
|
||||||
|
// Cache the estimate price (or 0 if not found)
|
||||||
|
componentPricesCache[item.lot_name] = item.estimate_price || 0;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (e) {
|
||||||
|
console.error('Failed to load component prices', e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function hasComponentPrice(lotName) {
|
||||||
|
return lotName in componentPricesCache && componentPricesCache[lotName] > 0;
|
||||||
|
}
|
||||||
|
|
||||||
// Autocomplete for single select (Base tab)
|
// Autocomplete for single select (Base tab)
|
||||||
function showAutocomplete(category, input) {
|
async function showAutocomplete(category, input) {
|
||||||
autocompleteInput = input;
|
autocompleteInput = input;
|
||||||
autocompleteCategory = category;
|
autocompleteCategory = category;
|
||||||
autocompleteMode = 'single';
|
autocompleteMode = 'single';
|
||||||
autocompleteIndex = -1;
|
autocompleteIndex = -1;
|
||||||
|
const components = getComponentsForCategory(category);
|
||||||
|
await ensurePricesLoaded(components);
|
||||||
filterAutocomplete(category, input.value);
|
filterAutocomplete(category, input.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1215,7 +1262,7 @@ function filterAutocomplete(category, search) {
|
|||||||
const searchLower = search.toLowerCase();
|
const searchLower = search.toLowerCase();
|
||||||
|
|
||||||
autocompleteFiltered = components.filter(c => {
|
autocompleteFiltered = components.filter(c => {
|
||||||
if (!c.current_price) return false;
|
if (!hasComponentPrice(c.lot_name)) return false;
|
||||||
if (!isComponentAllowedByStockFilter(c)) return false;
|
if (!isComponentAllowedByStockFilter(c)) return false;
|
||||||
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
||||||
return text.includes(searchLower);
|
return text.includes(searchLower);
|
||||||
@@ -1298,12 +1345,13 @@ function selectAutocompleteItem(index) {
|
|||||||
|
|
||||||
const qtyInput = document.getElementById('qty-' + autocompleteCategory);
|
const qtyInput = document.getElementById('qty-' + autocompleteCategory);
|
||||||
const qty = parseInt(qtyInput?.value) || 1;
|
const qty = parseInt(qtyInput?.value) || 1;
|
||||||
|
const price = componentPricesCache[comp.lot_name] || 0;
|
||||||
|
|
||||||
cart.push({
|
cart.push({
|
||||||
lot_name: comp.lot_name,
|
lot_name: comp.lot_name,
|
||||||
quantity: qty,
|
quantity: qty,
|
||||||
unit_price: comp.current_price,
|
unit_price: price,
|
||||||
estimate_price: comp.current_price,
|
estimate_price: price,
|
||||||
warehouse_price: null,
|
warehouse_price: null,
|
||||||
competitor_price: null,
|
competitor_price: null,
|
||||||
delta_wh_estimate_abs: null,
|
delta_wh_estimate_abs: null,
|
||||||
@@ -1333,11 +1381,13 @@ function hideAutocomplete() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Autocomplete for multi select tabs
|
// Autocomplete for multi select tabs
|
||||||
function showAutocompleteMulti(input) {
|
async function showAutocompleteMulti(input) {
|
||||||
autocompleteInput = input;
|
autocompleteInput = input;
|
||||||
autocompleteCategory = null;
|
autocompleteCategory = null;
|
||||||
autocompleteMode = 'multi';
|
autocompleteMode = 'multi';
|
||||||
autocompleteIndex = -1;
|
autocompleteIndex = -1;
|
||||||
|
const components = getComponentsForTab(currentTab);
|
||||||
|
await ensurePricesLoaded(components);
|
||||||
filterAutocompleteMulti(input.value);
|
filterAutocompleteMulti(input.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1349,7 +1399,7 @@ function filterAutocompleteMulti(search) {
|
|||||||
const addedLots = new Set(cart.map(i => i.lot_name));
|
const addedLots = new Set(cart.map(i => i.lot_name));
|
||||||
|
|
||||||
autocompleteFiltered = components.filter(c => {
|
autocompleteFiltered = components.filter(c => {
|
||||||
if (!c.current_price) return false;
|
if (!hasComponentPrice(c.lot_name)) return false;
|
||||||
if (addedLots.has(c.lot_name)) return false;
|
if (addedLots.has(c.lot_name)) return false;
|
||||||
if (!isComponentAllowedByStockFilter(c)) return false;
|
if (!isComponentAllowedByStockFilter(c)) return false;
|
||||||
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
||||||
@@ -1390,12 +1440,13 @@ function selectAutocompleteItemMulti(index) {
|
|||||||
|
|
||||||
const qtyInput = document.getElementById('new-qty');
|
const qtyInput = document.getElementById('new-qty');
|
||||||
const qty = parseInt(qtyInput?.value) || 1;
|
const qty = parseInt(qtyInput?.value) || 1;
|
||||||
|
const price = componentPricesCache[comp.lot_name] || 0;
|
||||||
|
|
||||||
cart.push({
|
cart.push({
|
||||||
lot_name: comp.lot_name,
|
lot_name: comp.lot_name,
|
||||||
quantity: qty,
|
quantity: qty,
|
||||||
unit_price: comp.current_price,
|
unit_price: price,
|
||||||
estimate_price: comp.current_price,
|
estimate_price: price,
|
||||||
warehouse_price: null,
|
warehouse_price: null,
|
||||||
competitor_price: null,
|
competitor_price: null,
|
||||||
delta_wh_estimate_abs: null,
|
delta_wh_estimate_abs: null,
|
||||||
@@ -1417,11 +1468,16 @@ function selectAutocompleteItemMulti(index) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Autocomplete for sectioned tabs (like storage with RAID and Disks sections)
|
// Autocomplete for sectioned tabs (like storage with RAID and Disks sections)
|
||||||
function showAutocompleteSection(sectionId, input) {
|
async function showAutocompleteSection(sectionId, input) {
|
||||||
autocompleteInput = input;
|
autocompleteInput = input;
|
||||||
autocompleteCategory = sectionId; // Store section ID
|
autocompleteCategory = sectionId; // Store section ID
|
||||||
autocompleteMode = 'section';
|
autocompleteMode = 'section';
|
||||||
autocompleteIndex = -1;
|
autocompleteIndex = -1;
|
||||||
|
|
||||||
|
// Load prices for tab components
|
||||||
|
const components = getComponentsForTab(currentTab);
|
||||||
|
await ensurePricesLoaded(components);
|
||||||
|
|
||||||
filterAutocompleteSection(sectionId, input.value, input);
|
filterAutocompleteSection(sectionId, input.value, input);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1448,7 +1504,7 @@ function filterAutocompleteSection(sectionId, search, inputElement) {
|
|||||||
const addedLots = new Set(cart.map(i => i.lot_name));
|
const addedLots = new Set(cart.map(i => i.lot_name));
|
||||||
|
|
||||||
autocompleteFiltered = sectionComponents.filter(c => {
|
autocompleteFiltered = sectionComponents.filter(c => {
|
||||||
if (!c.current_price) return false;
|
if (!hasComponentPrice(c.lot_name)) return false;
|
||||||
if (addedLots.has(c.lot_name)) return false;
|
if (addedLots.has(c.lot_name)) return false;
|
||||||
if (!isComponentAllowedByStockFilter(c)) return false;
|
if (!isComponentAllowedByStockFilter(c)) return false;
|
||||||
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
const text = (c.lot_name + ' ' + (c.description || '')).toLowerCase();
|
||||||
@@ -1489,12 +1545,13 @@ function selectAutocompleteItemSection(index, sectionId) {
|
|||||||
|
|
||||||
const qtyInput = document.getElementById('new-qty-' + sectionId);
|
const qtyInput = document.getElementById('new-qty-' + sectionId);
|
||||||
const qty = parseInt(qtyInput?.value) || 1;
|
const qty = parseInt(qtyInput?.value) || 1;
|
||||||
|
const price = componentPricesCache[comp.lot_name] || 0;
|
||||||
|
|
||||||
cart.push({
|
cart.push({
|
||||||
lot_name: comp.lot_name,
|
lot_name: comp.lot_name,
|
||||||
quantity: qty,
|
quantity: qty,
|
||||||
unit_price: comp.current_price,
|
unit_price: price,
|
||||||
estimate_price: comp.current_price,
|
estimate_price: price,
|
||||||
warehouse_price: null,
|
warehouse_price: null,
|
||||||
competitor_price: null,
|
competitor_price: null,
|
||||||
delta_wh_estimate_abs: null,
|
delta_wh_estimate_abs: null,
|
||||||
@@ -1741,7 +1798,7 @@ async function exportCSV() {
|
|||||||
const resp = await fetch('/api/export/csv', {
|
const resp = await fetch('/api/export/csv', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({items: exportItems, name: configName})
|
body: JSON.stringify({items: exportItems, name: configName, project_uuid: projectUUID})
|
||||||
});
|
});
|
||||||
|
|
||||||
const blob = await resp.blob();
|
const blob = await resp.blob();
|
||||||
@@ -1994,7 +2051,7 @@ async function exportCSVWithCustomPrice() {
|
|||||||
const resp = await fetch('/api/export/csv', {
|
const resp = await fetch('/api/export/csv', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
headers: {'Content-Type': 'application/json'},
|
headers: {'Content-Type': 'application/json'},
|
||||||
body: JSON.stringify({items: adjustedCart, name: configName})
|
body: JSON.stringify({items: adjustedCart, name: configName, project_uuid: projectUUID})
|
||||||
});
|
});
|
||||||
|
|
||||||
const blob = await resp.blob();
|
const blob = await resp.blob();
|
||||||
|
|||||||
Reference in New Issue
Block a user