Compare commits
21 Commits
v1.0.3
...
65871a8b04
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
65871a8b04 | ||
|
|
b27152b353 | ||
|
|
2e69089bd5 | ||
|
|
be1c962fec | ||
|
|
57215cb7b3 | ||
|
|
31dce9c721 | ||
|
|
06d0e8b14b | ||
|
|
b1b50ce2ef | ||
|
|
6ab1e9899e | ||
|
|
a1d21927a3 | ||
|
|
a90c07c879 | ||
|
|
e9307c4bad | ||
|
|
1b48401828 | ||
|
|
4a86f7b7ba | ||
|
|
955467fbea | ||
|
|
9ddffe48e9 | ||
|
|
4732605925 | ||
|
|
d318a7f462 | ||
|
|
1bec110d91 | ||
|
|
6392e4b4a9 | ||
|
|
8f7defdb8a |
@@ -114,10 +114,10 @@ go run ./cmd/migrate_ops_projects -config config.yaml -apply -yes
|
||||
|
||||
```sql
|
||||
-- 1) Создать пользователя (если его ещё нет)
|
||||
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY 'DB_PASSWORD_PLACEHOLDER';
|
||||
CREATE USER IF NOT EXISTS 'quote_user'@'%' IDENTIFIED BY 'StrongPassword!';
|
||||
|
||||
-- 2) Если пользователь уже существовал, принудительно обновить пароль
|
||||
ALTER USER 'quote_user'@'%' IDENTIFIED BY 'DB_PASSWORD_PLACEHOLDER';
|
||||
ALTER USER 'quote_user'@'%' IDENTIFIED BY 'StrongPassword!';
|
||||
|
||||
-- 3) (Опционально, но рекомендуется) удалить дубли пользователя с другими host,
|
||||
-- чтобы не возникало конфликтов вида user@localhost vs user@'%'
|
||||
@@ -147,7 +147,7 @@ SHOW CREATE USER 'quote_user'@'%';
|
||||
Полный набор прав для пользователя квотаций:
|
||||
|
||||
```sql
|
||||
GRANT USAGE ON *.* TO 'quote_user'@'%' IDENTIFIED BY 'DB_PASSWORD_PLACEHOLDER';
|
||||
GRANT USAGE ON *.* TO 'quote_user'@'%' IDENTIFIED BY 'StrongPassword!';
|
||||
GRANT SELECT ON RFQ_LOG.lot TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
|
||||
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
|
||||
|
||||
@@ -480,7 +480,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
if mariaDB != nil {
|
||||
pricingService = pricing.NewService(componentRepo, priceRepo, cfg.Pricing)
|
||||
componentService = services.NewComponentService(componentRepo, categoryRepo, statsRepo)
|
||||
quoteService = services.NewQuoteService(componentRepo, statsRepo, pricingService)
|
||||
quoteService = services.NewQuoteService(componentRepo, statsRepo, pricelistRepo, local, pricingService)
|
||||
exportService = services.NewExportService(cfg.Export, categoryRepo)
|
||||
alertService = alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
|
||||
pricelistService = pricelist.NewService(mariaDB, pricelistRepo, componentRepo, pricingService)
|
||||
@@ -488,7 +488,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
// In offline mode, we still need to create services that don't require DB
|
||||
pricingService = pricing.NewService(nil, nil, cfg.Pricing)
|
||||
componentService = services.NewComponentService(nil, nil, nil)
|
||||
quoteService = services.NewQuoteService(nil, nil, pricingService)
|
||||
quoteService = services.NewQuoteService(nil, nil, nil, local, pricingService)
|
||||
exportService = services.NewExportService(cfg.Export, nil)
|
||||
alertService = alerts.NewService(nil, nil, nil, nil, cfg.Alerts, cfg.Pricing)
|
||||
pricelistService = pricelist.NewService(nil, nil, nil, nil)
|
||||
@@ -527,44 +527,8 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
if !connMgr.IsOnline() {
|
||||
return
|
||||
}
|
||||
serverDB, err := connMgr.GetDB()
|
||||
if err != nil || serverDB == nil {
|
||||
return
|
||||
}
|
||||
|
||||
projectRepo := repository.NewProjectRepository(serverDB)
|
||||
serverProjects, _, err := projectRepo.List(0, 10000, true)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for i := range serverProjects {
|
||||
sp := serverProjects[i]
|
||||
localProject, getErr := local.GetProjectByUUID(sp.UUID)
|
||||
if getErr == nil && localProject != nil {
|
||||
// Keep unsynced local changes intact.
|
||||
if localProject.SyncStatus == "pending" {
|
||||
continue
|
||||
}
|
||||
localProject.OwnerUsername = sp.OwnerUsername
|
||||
localProject.Name = sp.Name
|
||||
localProject.IsActive = sp.IsActive
|
||||
localProject.IsSystem = sp.IsSystem
|
||||
localProject.CreatedAt = sp.CreatedAt
|
||||
localProject.UpdatedAt = sp.UpdatedAt
|
||||
serverID := sp.ID
|
||||
localProject.ServerID = &serverID
|
||||
localProject.SyncStatus = "synced"
|
||||
localProject.SyncedAt = &now
|
||||
_ = local.SaveProject(localProject)
|
||||
continue
|
||||
}
|
||||
|
||||
lp := localdb.ProjectToLocal(&sp)
|
||||
lp.SyncStatus = "synced"
|
||||
lp.SyncedAt = &now
|
||||
_ = local.SaveProject(lp)
|
||||
if _, err := syncService.ImportProjectsToLocal(); err != nil && !errors.Is(err, sync.ErrOffline) {
|
||||
slog.Warn("failed to sync projects from server", "error", err)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -727,6 +691,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
{
|
||||
quote.POST("/validate", quoteHandler.Validate)
|
||||
quote.POST("/calculate", quoteHandler.Calculate)
|
||||
quote.POST("/price-levels", quoteHandler.PriceLevels)
|
||||
}
|
||||
|
||||
// Export (public)
|
||||
@@ -1166,6 +1131,7 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
|
||||
"uuid": p.UUID,
|
||||
"owner_username": p.OwnerUsername,
|
||||
"name": p.Name,
|
||||
"tracker_url": p.TrackerURL,
|
||||
"is_active": p.IsActive,
|
||||
"is_system": p.IsSystem,
|
||||
"created_at": p.CreatedAt,
|
||||
|
||||
@@ -1,11 +1,14 @@
|
||||
package handlers
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strconv"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/pricelist"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
@@ -24,6 +27,7 @@ func (h *PricelistHandler) List(c *gin.Context) {
|
||||
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
|
||||
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
|
||||
activeOnly := c.DefaultQuery("active_only", "false") == "true"
|
||||
source := c.Query("source")
|
||||
|
||||
var (
|
||||
pricelists any
|
||||
@@ -32,9 +36,9 @@ func (h *PricelistHandler) List(c *gin.Context) {
|
||||
)
|
||||
|
||||
if activeOnly {
|
||||
pricelists, total, err = h.service.ListActive(page, perPage)
|
||||
pricelists, total, err = h.service.ListActiveBySource(page, perPage, source)
|
||||
} else {
|
||||
pricelists, total, err = h.service.List(page, perPage)
|
||||
pricelists, total, err = h.service.ListBySource(page, perPage, source)
|
||||
}
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
@@ -45,11 +49,21 @@ func (h *PricelistHandler) List(c *gin.Context) {
|
||||
if total == 0 && h.localDB != nil {
|
||||
localPLs, err := h.localDB.GetLocalPricelists()
|
||||
if err == nil && len(localPLs) > 0 {
|
||||
if source != "" {
|
||||
filtered := localPLs[:0]
|
||||
for _, lpl := range localPLs {
|
||||
if lpl.Source == source {
|
||||
filtered = append(filtered, lpl)
|
||||
}
|
||||
}
|
||||
localPLs = filtered
|
||||
}
|
||||
// Convert to PricelistSummary format
|
||||
summaries := make([]map[string]interface{}, len(localPLs))
|
||||
for i, lpl := range localPLs {
|
||||
summaries[i] = map[string]interface{}{
|
||||
"id": lpl.ServerID,
|
||||
"source": lpl.Source,
|
||||
"version": lpl.Version,
|
||||
"created_by": "sync",
|
||||
"item_count": 0, // Not tracked
|
||||
@@ -108,13 +122,33 @@ func (h *PricelistHandler) Create(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Source string `json:"source"`
|
||||
Items []struct {
|
||||
LotName string `json:"lot_name"`
|
||||
Price float64 `json:"price"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
source := string(models.NormalizePricelistSource(req.Source))
|
||||
|
||||
// Get the database username as the creator
|
||||
createdBy := h.localDB.GetDBUser()
|
||||
if createdBy == "" {
|
||||
createdBy = "unknown"
|
||||
}
|
||||
sourceItems := make([]pricelist.CreateItemInput, 0, len(req.Items))
|
||||
for _, item := range req.Items {
|
||||
sourceItems = append(sourceItems, pricelist.CreateItemInput{
|
||||
LotName: item.LotName,
|
||||
Price: item.Price,
|
||||
})
|
||||
}
|
||||
|
||||
pl, err := h.service.CreateFromCurrentPrices(createdBy)
|
||||
pl, err := h.service.CreateForSourceWithProgress(createdBy, source, sourceItems, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -134,10 +168,30 @@ func (h *PricelistHandler) CreateWithProgress(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
var req struct {
|
||||
Source string `json:"source"`
|
||||
Items []struct {
|
||||
LotName string `json:"lot_name"`
|
||||
Price float64 `json:"price"`
|
||||
} `json:"items"`
|
||||
}
|
||||
if err := c.ShouldBindJSON(&req); err != nil && !errors.Is(err, io.EOF) {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
source := string(models.NormalizePricelistSource(req.Source))
|
||||
|
||||
createdBy := h.localDB.GetDBUser()
|
||||
if createdBy == "" {
|
||||
createdBy = "unknown"
|
||||
}
|
||||
sourceItems := make([]pricelist.CreateItemInput, 0, len(req.Items))
|
||||
for _, item := range req.Items {
|
||||
sourceItems = append(sourceItems, pricelist.CreateItemInput{
|
||||
LotName: item.LotName,
|
||||
Price: item.Price,
|
||||
})
|
||||
}
|
||||
|
||||
c.Header("Content-Type", "text/event-stream")
|
||||
c.Header("Cache-Control", "no-cache")
|
||||
@@ -146,7 +200,7 @@ func (h *PricelistHandler) CreateWithProgress(c *gin.Context) {
|
||||
|
||||
flusher, ok := c.Writer.(http.Flusher)
|
||||
if !ok {
|
||||
pl, err := h.service.CreateFromCurrentPrices(createdBy)
|
||||
pl, err := h.service.CreateForSourceWithProgress(createdBy, source, sourceItems, nil)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
@@ -161,7 +215,7 @@ func (h *PricelistHandler) CreateWithProgress(c *gin.Context) {
|
||||
}
|
||||
|
||||
sendProgress(gin.H{"current": 0, "total": 4, "status": "starting", "message": "Запуск..."})
|
||||
pl, err := h.service.CreateFromCurrentPricesWithProgress(createdBy, func(p pricelist.CreateProgress) {
|
||||
pl, err := h.service.CreateForSourceWithProgress(createdBy, source, sourceItems, func(p pricelist.CreateProgress) {
|
||||
sendProgress(gin.H{
|
||||
"current": p.Current,
|
||||
"total": p.Total,
|
||||
@@ -269,8 +323,14 @@ func (h *PricelistHandler) GetItems(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
pl, _ := h.service.GetByID(uint(id))
|
||||
source := ""
|
||||
if pl != nil {
|
||||
source = pl.Source
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"source": source,
|
||||
"items": items,
|
||||
"total": total,
|
||||
"page": page,
|
||||
@@ -286,15 +346,18 @@ func (h *PricelistHandler) CanWrite(c *gin.Context) {
|
||||
|
||||
// GetLatest returns the most recent active pricelist
|
||||
func (h *PricelistHandler) GetLatest(c *gin.Context) {
|
||||
source := c.DefaultQuery("source", string(models.PricelistSourceEstimate))
|
||||
source = string(models.NormalizePricelistSource(source))
|
||||
|
||||
// Try to get from server first
|
||||
pl, err := h.service.GetLatestActive()
|
||||
pl, err := h.service.GetLatestActiveBySource(source)
|
||||
if err != nil {
|
||||
// If offline or no server pricelists, try to get from local cache
|
||||
if h.localDB == nil {
|
||||
c.JSON(http.StatusNotFound, gin.H{"error": "no database available"})
|
||||
return
|
||||
}
|
||||
localPL, localErr := h.localDB.GetLatestLocalPricelist()
|
||||
localPL, localErr := h.localDB.GetLatestLocalPricelistBySource(source)
|
||||
if localErr != nil {
|
||||
// No local pricelists either
|
||||
c.JSON(http.StatusNotFound, gin.H{
|
||||
@@ -306,6 +369,7 @@ func (h *PricelistHandler) GetLatest(c *gin.Context) {
|
||||
// Return local pricelist
|
||||
c.JSON(http.StatusOK, gin.H{
|
||||
"id": localPL.ServerID,
|
||||
"source": localPL.Source,
|
||||
"version": localPL.Version,
|
||||
"created_by": "sync",
|
||||
"item_count": 0, // Not tracked in local pricelists
|
||||
|
||||
@@ -3,8 +3,8 @@ package handlers
|
||||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services"
|
||||
"github.com/gin-gonic/gin"
|
||||
)
|
||||
|
||||
type QuoteHandler struct {
|
||||
@@ -49,3 +49,19 @@ func (h *QuoteHandler) Calculate(c *gin.Context) {
|
||||
"total": result.Total,
|
||||
})
|
||||
}
|
||||
|
||||
func (h *QuoteHandler) PriceLevels(c *gin.Context) {
|
||||
var req services.PriceLevelsRequest
|
||||
if err := c.ShouldBindJSON(&req); err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
result, err := h.quoteService.CalculatePriceLevels(&req)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, result)
|
||||
}
|
||||
|
||||
@@ -182,14 +182,23 @@ func (h *SyncHandler) SyncPricelists(c *gin.Context) {
|
||||
|
||||
// SyncAllResponse represents result of full sync
|
||||
type SyncAllResponse struct {
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
ComponentsSynced int `json:"components_synced"`
|
||||
PricelistsSynced int `json:"pricelists_synced"`
|
||||
Duration string `json:"duration"`
|
||||
Success bool `json:"success"`
|
||||
Message string `json:"message"`
|
||||
PendingPushed int `json:"pending_pushed"`
|
||||
ComponentsSynced int `json:"components_synced"`
|
||||
PricelistsSynced int `json:"pricelists_synced"`
|
||||
ProjectsImported int `json:"projects_imported"`
|
||||
ProjectsUpdated int `json:"projects_updated"`
|
||||
ProjectsSkipped int `json:"projects_skipped"`
|
||||
ConfigurationsImported int `json:"configurations_imported"`
|
||||
ConfigurationsUpdated int `json:"configurations_updated"`
|
||||
ConfigurationsSkipped int `json:"configurations_skipped"`
|
||||
Duration string `json:"duration"`
|
||||
}
|
||||
|
||||
// SyncAll syncs both components and pricelists
|
||||
// SyncAll performs full bidirectional sync:
|
||||
// - push pending local changes (projects/configurations) to server
|
||||
// - pull components, pricelists, projects, and configurations from server
|
||||
// POST /api/sync/all
|
||||
func (h *SyncHandler) SyncAll(c *gin.Context) {
|
||||
if !h.checkOnline() {
|
||||
@@ -201,7 +210,18 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
||||
}
|
||||
|
||||
startTime := time.Now()
|
||||
var componentsSynced, pricelistsSynced int
|
||||
var pendingPushed, componentsSynced, pricelistsSynced int
|
||||
|
||||
// Push local pending changes first (projects/configurations)
|
||||
pendingPushed, err := h.syncService.PushPendingChanges()
|
||||
if err != nil {
|
||||
slog.Error("pending push failed during full sync", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": "Pending changes push failed: " + err.Error(),
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
// Sync components
|
||||
mariaDB, err := h.connMgr.GetDB()
|
||||
@@ -231,17 +251,54 @@ func (h *SyncHandler) SyncAll(c *gin.Context) {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": "Pricelist sync failed: " + err.Error(),
|
||||
"pending_pushed": pendingPushed,
|
||||
"components_synced": componentsSynced,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
projectsResult, err := h.syncService.ImportProjectsToLocal()
|
||||
if err != nil {
|
||||
slog.Error("project import failed during full sync", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": "Project import failed: " + err.Error(),
|
||||
"pending_pushed": pendingPushed,
|
||||
"components_synced": componentsSynced,
|
||||
"pricelists_synced": pricelistsSynced,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
configsResult, err := h.syncService.ImportConfigurationsToLocal()
|
||||
if err != nil {
|
||||
slog.Error("configuration import failed during full sync", "error", err)
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
"success": false,
|
||||
"error": "Configuration import failed: " + err.Error(),
|
||||
"pending_pushed": pendingPushed,
|
||||
"components_synced": componentsSynced,
|
||||
"pricelists_synced": pricelistsSynced,
|
||||
"projects_imported": projectsResult.Imported,
|
||||
"projects_updated": projectsResult.Updated,
|
||||
"projects_skipped": projectsResult.Skipped,
|
||||
})
|
||||
return
|
||||
}
|
||||
|
||||
c.JSON(http.StatusOK, SyncAllResponse{
|
||||
Success: true,
|
||||
Message: "Full sync completed successfully",
|
||||
ComponentsSynced: componentsSynced,
|
||||
PricelistsSynced: pricelistsSynced,
|
||||
Duration: time.Since(startTime).String(),
|
||||
Success: true,
|
||||
Message: "Full sync completed successfully",
|
||||
PendingPushed: pendingPushed,
|
||||
ComponentsSynced: componentsSynced,
|
||||
PricelistsSynced: pricelistsSynced,
|
||||
ProjectsImported: projectsResult.Imported,
|
||||
ProjectsUpdated: projectsResult.Updated,
|
||||
ProjectsSkipped: projectsResult.Skipped,
|
||||
ConfigurationsImported: configsResult.Imported,
|
||||
ConfigurationsUpdated: configsResult.Updated,
|
||||
ConfigurationsSkipped: configsResult.Skipped,
|
||||
Duration: time.Since(startTime).String(),
|
||||
})
|
||||
h.syncService.RecordSyncHeartbeat()
|
||||
}
|
||||
@@ -396,6 +453,9 @@ func (h *SyncHandler) GetUsersStatus(c *gin.Context) {
|
||||
return
|
||||
}
|
||||
|
||||
// Keep current client heartbeat fresh so app version is available in the table.
|
||||
h.syncService.RecordSyncHeartbeat()
|
||||
|
||||
users, err := h.syncService.ListUserSyncStatuses(threshold)
|
||||
if err != nil {
|
||||
c.JSON(http.StatusInternalServerError, gin.H{
|
||||
|
||||
@@ -385,10 +385,9 @@ func (l *LocalDB) EnsureComponentPricesFromPricelists() error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If we have components but no prices, we should load prices from pricelists
|
||||
// Find the latest pricelist
|
||||
// If we have components but no prices, load from latest estimate pricelist.
|
||||
var latestPricelist LocalPricelist
|
||||
if err := l.db.Order("created_at DESC").First(&latestPricelist).Error; err != nil {
|
||||
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
|
||||
|
||||
@@ -99,6 +99,7 @@ func ProjectToLocal(project *models.Project) *LocalProject {
|
||||
UUID: project.UUID,
|
||||
OwnerUsername: project.OwnerUsername,
|
||||
Name: project.Name,
|
||||
TrackerURL: project.TrackerURL,
|
||||
IsActive: project.IsActive,
|
||||
IsSystem: project.IsSystem,
|
||||
CreatedAt: project.CreatedAt,
|
||||
@@ -117,6 +118,7 @@ func LocalToProject(local *LocalProject) *models.Project {
|
||||
UUID: local.UUID,
|
||||
OwnerUsername: local.OwnerUsername,
|
||||
Name: local.Name,
|
||||
TrackerURL: local.TrackerURL,
|
||||
IsActive: local.IsActive,
|
||||
IsSystem: local.IsSystem,
|
||||
CreatedAt: local.CreatedAt,
|
||||
@@ -137,6 +139,7 @@ func PricelistToLocal(pl *models.Pricelist) *LocalPricelist {
|
||||
|
||||
return &LocalPricelist{
|
||||
ServerID: pl.ID,
|
||||
Source: pl.Source,
|
||||
Version: pl.Version,
|
||||
Name: name,
|
||||
CreatedAt: pl.CreatedAt,
|
||||
@@ -149,6 +152,7 @@ func PricelistToLocal(pl *models.Pricelist) *LocalPricelist {
|
||||
func LocalToPricelist(local *LocalPricelist) *models.Pricelist {
|
||||
return &models.Pricelist{
|
||||
ID: local.ServerID,
|
||||
Source: local.Source,
|
||||
Version: local.Version,
|
||||
Notification: local.Name,
|
||||
CreatedAt: local.CreatedAt,
|
||||
|
||||
@@ -3,6 +3,7 @@ package localdb
|
||||
import (
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) {
|
||||
@@ -70,3 +71,57 @@ func TestRunLocalMigrationsBackfillsExistingConfigurations(t *testing.T) {
|
||||
t.Fatalf("expected local migrations to be recorded")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunLocalMigrationsFixesPricelistVersionUniqueIndex(t *testing.T) {
|
||||
dbPath := filepath.Join(t.TempDir(), "pricelist_index_fix.db")
|
||||
|
||||
local, err := New(dbPath)
|
||||
if err != nil {
|
||||
t.Fatalf("open localdb: %v", err)
|
||||
}
|
||||
t.Cleanup(func() { _ = local.Close() })
|
||||
|
||||
if err := local.SaveLocalPricelist(&LocalPricelist{
|
||||
ServerID: 10,
|
||||
Version: "2026-02-06-001",
|
||||
Name: "v1",
|
||||
CreatedAt: time.Now().Add(-time.Hour),
|
||||
SyncedAt: time.Now().Add(-time.Hour),
|
||||
}); err != nil {
|
||||
t.Fatalf("save first pricelist: %v", err)
|
||||
}
|
||||
|
||||
if err := local.DB().Exec(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_local_pricelists_version_legacy
|
||||
ON local_pricelists(version)
|
||||
`).Error; err != nil {
|
||||
t.Fatalf("create legacy unique version index: %v", err)
|
||||
}
|
||||
|
||||
if err := local.DB().Where("id = ?", "2026_02_06_pricelist_index_fix").
|
||||
Delete(&LocalSchemaMigration{}).Error; err != nil {
|
||||
t.Fatalf("delete migration record: %v", err)
|
||||
}
|
||||
|
||||
if err := runLocalMigrations(local.DB()); err != nil {
|
||||
t.Fatalf("rerun local migrations: %v", err)
|
||||
}
|
||||
|
||||
if err := local.SaveLocalPricelist(&LocalPricelist{
|
||||
ServerID: 11,
|
||||
Version: "2026-02-06-001",
|
||||
Name: "v1-duplicate-version",
|
||||
CreatedAt: time.Now(),
|
||||
SyncedAt: time.Now(),
|
||||
}); err != nil {
|
||||
t.Fatalf("save second pricelist with duplicate version: %v", err)
|
||||
}
|
||||
|
||||
var count int64
|
||||
if err := local.DB().Model(&LocalPricelist{}).Count(&count).Error; err != nil {
|
||||
t.Fatalf("count pricelists: %v", err)
|
||||
}
|
||||
if count != 2 {
|
||||
t.Fatalf("expected 2 pricelists, got %d", count)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -12,10 +12,11 @@ import (
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/appmeta"
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
"github.com/glebarez/sqlite"
|
||||
mysqlDriver "github.com/go-sql-driver/mysql"
|
||||
uuidpkg "github.com/google/uuid"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
"gorm.io/gorm/logger"
|
||||
)
|
||||
|
||||
@@ -522,7 +523,16 @@ func (l *LocalDB) CountLocalPricelists() int64 {
|
||||
// GetLatestLocalPricelist returns the most recently synced pricelist
|
||||
func (l *LocalDB) GetLatestLocalPricelist() (*LocalPricelist, error) {
|
||||
var pricelist LocalPricelist
|
||||
if err := l.db.Order("created_at DESC").First(&pricelist).Error; err != nil {
|
||||
if err := l.db.Where("source = ?", "estimate").Order("created_at DESC").First(&pricelist).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pricelist, nil
|
||||
}
|
||||
|
||||
// GetLatestLocalPricelistBySource returns the most recently synced pricelist for a source.
|
||||
func (l *LocalDB) GetLatestLocalPricelistBySource(source string) (*LocalPricelist, error) {
|
||||
var pricelist LocalPricelist
|
||||
if err := l.db.Where("source = ?", source).Order("created_at DESC").First(&pricelist).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pricelist, nil
|
||||
@@ -540,7 +550,16 @@ func (l *LocalDB) GetLocalPricelistByServerID(serverID uint) (*LocalPricelist, e
|
||||
// GetLocalPricelistByVersion returns a local pricelist by version string.
|
||||
func (l *LocalDB) GetLocalPricelistByVersion(version string) (*LocalPricelist, error) {
|
||||
var pricelist LocalPricelist
|
||||
if err := l.db.Where("version = ?", version).First(&pricelist).Error; err != nil {
|
||||
if err := l.db.Where("version = ? AND source = ?", version, "estimate").First(&pricelist).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pricelist, nil
|
||||
}
|
||||
|
||||
// GetLocalPricelistBySourceAndVersion returns a local pricelist by source and version string.
|
||||
func (l *LocalDB) GetLocalPricelistBySourceAndVersion(source, version string) (*LocalPricelist, error) {
|
||||
var pricelist LocalPricelist
|
||||
if err := l.db.Where("source = ? AND version = ?", source, version).First(&pricelist).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &pricelist, nil
|
||||
@@ -557,7 +576,17 @@ func (l *LocalDB) GetLocalPricelistByID(id uint) (*LocalPricelist, error) {
|
||||
|
||||
// SaveLocalPricelist saves a pricelist to local SQLite
|
||||
func (l *LocalDB) SaveLocalPricelist(pricelist *LocalPricelist) error {
|
||||
return l.db.Save(pricelist).Error
|
||||
return l.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "server_id"}},
|
||||
DoUpdates: clause.Assignments(map[string]interface{}{
|
||||
"source": pricelist.Source,
|
||||
"version": pricelist.Version,
|
||||
"name": pricelist.Name,
|
||||
"created_at": pricelist.CreatedAt,
|
||||
"synced_at": pricelist.SyncedAt,
|
||||
"is_used": pricelist.IsUsed,
|
||||
}),
|
||||
}).Create(pricelist).Error
|
||||
}
|
||||
|
||||
// GetLocalPricelists returns all local pricelists
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/google/uuid"
|
||||
@@ -47,6 +48,16 @@ var localMigrations = []localMigration{
|
||||
name: "Attach existing configurations to latest local pricelist and recalc usage",
|
||||
run: backfillConfigurationPricelists,
|
||||
},
|
||||
{
|
||||
id: "2026_02_06_pricelist_index_fix",
|
||||
name: "Use unique server_id for local pricelists and allow duplicate versions",
|
||||
run: fixLocalPricelistIndexes,
|
||||
},
|
||||
{
|
||||
id: "2026_02_06_pricelist_source",
|
||||
name: "Backfill source for local pricelists and create source indexes",
|
||||
run: backfillLocalPricelistSource,
|
||||
},
|
||||
}
|
||||
|
||||
func runLocalMigrations(db *gorm.DB) error {
|
||||
@@ -199,7 +210,7 @@ func ensureDefaultProjectTx(tx *gorm.DB, ownerUsername string) (*LocalProject, e
|
||||
|
||||
func backfillConfigurationPricelists(tx *gorm.DB) error {
|
||||
var latest LocalPricelist
|
||||
if err := tx.Order("created_at DESC").First(&latest).Error; err != nil {
|
||||
if err := tx.Where("source = ?", "estimate").Order("created_at DESC").First(&latest).Error; err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
return nil
|
||||
}
|
||||
@@ -237,3 +248,71 @@ func chooseNonZeroTime(candidate time.Time, fallback time.Time) time.Time {
|
||||
}
|
||||
return candidate
|
||||
}
|
||||
|
||||
func fixLocalPricelistIndexes(tx *gorm.DB) error {
|
||||
type indexRow struct {
|
||||
Name string `gorm:"column:name"`
|
||||
Unique int `gorm:"column:unique"`
|
||||
}
|
||||
var indexes []indexRow
|
||||
if err := tx.Raw("PRAGMA index_list('local_pricelists')").Scan(&indexes).Error; err != nil {
|
||||
return fmt.Errorf("list local_pricelists indexes: %w", err)
|
||||
}
|
||||
|
||||
for _, idx := range indexes {
|
||||
if idx.Unique == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
type indexInfoRow struct {
|
||||
Name string `gorm:"column:name"`
|
||||
}
|
||||
var info []indexInfoRow
|
||||
if err := tx.Raw(fmt.Sprintf("PRAGMA index_info('%s')", strings.ReplaceAll(idx.Name, "'", "''"))).Scan(&info).Error; err != nil {
|
||||
return fmt.Errorf("load index info for %s: %w", idx.Name, err)
|
||||
}
|
||||
if len(info) != 1 || info[0].Name != "version" {
|
||||
continue
|
||||
}
|
||||
|
||||
quoted := strings.ReplaceAll(idx.Name, `"`, `""`)
|
||||
if err := tx.Exec(fmt.Sprintf(`DROP INDEX IF EXISTS "%s"`, quoted)).Error; err != nil {
|
||||
return fmt.Errorf("drop unique version index %s: %w", idx.Name, err)
|
||||
}
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
CREATE UNIQUE INDEX IF NOT EXISTS idx_local_pricelists_server_id
|
||||
ON local_pricelists(server_id)
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("ensure unique index local_pricelists(server_id): %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_local_pricelists_version
|
||||
ON local_pricelists(version)
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("ensure index local_pricelists(version): %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func backfillLocalPricelistSource(tx *gorm.DB) error {
|
||||
if err := tx.Exec(`
|
||||
UPDATE local_pricelists
|
||||
SET source = 'estimate'
|
||||
WHERE source IS NULL OR source = ''
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("backfill local_pricelists.source: %w", err)
|
||||
}
|
||||
|
||||
if err := tx.Exec(`
|
||||
CREATE INDEX IF NOT EXISTS idx_local_pricelists_source_created_at
|
||||
ON local_pricelists(source, created_at DESC)
|
||||
`).Error; err != nil {
|
||||
return fmt.Errorf("ensure idx_local_pricelists_source_created_at: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -94,6 +94,7 @@ type LocalProject struct {
|
||||
ServerID *uint `json:"server_id,omitempty"`
|
||||
OwnerUsername string `gorm:"not null;index" json:"owner_username"`
|
||||
Name string `gorm:"not null" json:"name"`
|
||||
TrackerURL string `json:"tracker_url"`
|
||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||
IsSystem bool `gorm:"default:false;index" json:"is_system"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
@@ -126,10 +127,11 @@ func (LocalConfigurationVersion) TableName() string {
|
||||
// LocalPricelist stores cached pricelists from server
|
||||
type LocalPricelist struct {
|
||||
ID uint `gorm:"primaryKey;autoIncrement" json:"id"`
|
||||
ServerID uint `gorm:"not null" json:"server_id"` // ID on MariaDB server
|
||||
Version string `gorm:"uniqueIndex;not null" json:"version"`
|
||||
ServerID uint `gorm:"not null;uniqueIndex" json:"server_id"` // ID on MariaDB server
|
||||
Source string `gorm:"not null;default:'estimate';index:idx_local_pricelists_source_created_at,priority:1" json:"source"`
|
||||
Version string `gorm:"not null;index" json:"version"`
|
||||
Name string `json:"name"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
CreatedAt time.Time `gorm:"index:idx_local_pricelists_source_created_at,priority:2,sort:desc" json:"created_at"`
|
||||
SyncedAt time.Time `json:"synced_at"`
|
||||
IsUsed bool `gorm:"default:false" json:"is_used"` // Used by any local configuration
|
||||
}
|
||||
|
||||
@@ -54,6 +54,9 @@ type Configuration struct {
|
||||
IsTemplate bool `gorm:"default:false" json:"is_template"`
|
||||
ServerCount int `gorm:"default:1" json:"server_count"`
|
||||
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"`
|
||||
DisablePriceRefresh bool `gorm:"default:false" json:"disable_price_refresh"`
|
||||
PriceUpdatedAt *time.Time `gorm:"type:timestamp" json:"price_updated_at,omitempty"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
|
||||
|
||||
@@ -4,12 +4,41 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
type PricelistSource string
|
||||
|
||||
const (
|
||||
PricelistSourceEstimate PricelistSource = "estimate"
|
||||
PricelistSourceWarehouse PricelistSource = "warehouse"
|
||||
PricelistSourceCompetitor PricelistSource = "competitor"
|
||||
)
|
||||
|
||||
func (s PricelistSource) IsValid() bool {
|
||||
switch s {
|
||||
case PricelistSourceEstimate, PricelistSourceWarehouse, PricelistSourceCompetitor:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func NormalizePricelistSource(source string) PricelistSource {
|
||||
switch PricelistSource(source) {
|
||||
case PricelistSourceWarehouse:
|
||||
return PricelistSourceWarehouse
|
||||
case PricelistSourceCompetitor:
|
||||
return PricelistSourceCompetitor
|
||||
default:
|
||||
return PricelistSourceEstimate
|
||||
}
|
||||
}
|
||||
|
||||
// Pricelist represents a versioned snapshot of prices
|
||||
type Pricelist struct {
|
||||
ID uint `gorm:"primaryKey" json:"id"`
|
||||
Version string `gorm:"size:20;uniqueIndex;not null" json:"version"` // Format: YYYY-MM-DD-NNN
|
||||
Notification string `gorm:"size:500" json:"notification"` // Notification shown in configurator
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
Source string `gorm:"size:20;not null;default:'estimate';uniqueIndex:idx_qt_pricelists_source_version,priority:1;index:idx_qt_pricelists_source_created_at,priority:1" json:"source"`
|
||||
Version string `gorm:"size:20;not null;uniqueIndex:idx_qt_pricelists_source_version,priority:2" json:"version"` // Format: YYYY-MM-DD-NNN
|
||||
Notification string `gorm:"size:500" json:"notification"` // Notification shown in configurator
|
||||
CreatedAt time.Time `gorm:"index:idx_qt_pricelists_source_created_at,priority:2,sort:desc" json:"created_at"`
|
||||
CreatedBy string `gorm:"size:100" json:"created_by"`
|
||||
IsActive bool `gorm:"default:true" json:"is_active"`
|
||||
UsageCount int `gorm:"default:0" json:"usage_count"`
|
||||
@@ -47,6 +76,7 @@ func (PricelistItem) TableName() string {
|
||||
// PricelistSummary is used for list views
|
||||
type PricelistSummary struct {
|
||||
ID uint `json:"id"`
|
||||
Source string `json:"source"`
|
||||
Version string `json:"version"`
|
||||
Notification string `json:"notification"`
|
||||
CreatedAt time.Time `json:"created_at"`
|
||||
|
||||
@@ -7,6 +7,7 @@ type Project struct {
|
||||
UUID string `gorm:"size:36;uniqueIndex;not null" json:"uuid"`
|
||||
OwnerUsername string `gorm:"size:100;not null;index" json:"owner_username"`
|
||||
Name string `gorm:"size:200;not null" json:"name"`
|
||||
TrackerURL string `gorm:"size:500" json:"tracker_url"`
|
||||
IsActive bool `gorm:"default:true;index" json:"is_active"`
|
||||
IsSystem bool `gorm:"default:false;index" json:"is_system"`
|
||||
CreatedAt time.Time `gorm:"autoCreateTime" json:"created_at"`
|
||||
|
||||
@@ -21,13 +21,23 @@ func NewPricelistRepository(db *gorm.DB) *PricelistRepository {
|
||||
|
||||
// List returns pricelists with pagination
|
||||
func (r *PricelistRepository) List(offset, limit int) ([]models.PricelistSummary, int64, error) {
|
||||
return r.ListBySource("", offset, limit)
|
||||
}
|
||||
|
||||
// ListBySource returns pricelists filtered by source when provided.
|
||||
func (r *PricelistRepository) ListBySource(source string, offset, limit int) ([]models.PricelistSummary, int64, error) {
|
||||
query := r.db.Model(&models.Pricelist{})
|
||||
if source != "" {
|
||||
query = query.Where("source = ?", source)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := r.db.Model(&models.Pricelist{}).Count(&total).Error; err != nil {
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("counting pricelists: %w", err)
|
||||
}
|
||||
|
||||
var pricelists []models.Pricelist
|
||||
if err := r.db.Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
||||
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("listing pricelists: %w", err)
|
||||
}
|
||||
|
||||
@@ -36,13 +46,23 @@ func (r *PricelistRepository) List(offset, limit int) ([]models.PricelistSummary
|
||||
|
||||
// ListActive returns active pricelists with pagination.
|
||||
func (r *PricelistRepository) ListActive(offset, limit int) ([]models.PricelistSummary, int64, error) {
|
||||
return r.ListActiveBySource("", offset, limit)
|
||||
}
|
||||
|
||||
// ListActiveBySource returns active pricelists filtered by source when provided.
|
||||
func (r *PricelistRepository) ListActiveBySource(source string, offset, limit int) ([]models.PricelistSummary, int64, error) {
|
||||
query := r.db.Model(&models.Pricelist{}).Where("is_active = ?", true)
|
||||
if source != "" {
|
||||
query = query.Where("source = ?", source)
|
||||
}
|
||||
|
||||
var total int64
|
||||
if err := r.db.Model(&models.Pricelist{}).Where("is_active = ?", true).Count(&total).Error; err != nil {
|
||||
if err := query.Count(&total).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("counting active pricelists: %w", err)
|
||||
}
|
||||
|
||||
var pricelists []models.Pricelist
|
||||
if err := r.db.Where("is_active = ?", true).Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
||||
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&pricelists).Error; err != nil {
|
||||
return nil, 0, fmt.Errorf("listing active pricelists: %w", err)
|
||||
}
|
||||
|
||||
@@ -68,6 +88,7 @@ func (r *PricelistRepository) toSummaries(pricelists []models.Pricelist) []model
|
||||
|
||||
summaries[i] = models.PricelistSummary{
|
||||
ID: pl.ID,
|
||||
Source: pl.Source,
|
||||
Version: pl.Version,
|
||||
Notification: pl.Notification,
|
||||
CreatedAt: pl.CreatedAt,
|
||||
@@ -102,8 +123,13 @@ func (r *PricelistRepository) GetByID(id uint) (*models.Pricelist, error) {
|
||||
|
||||
// GetByVersion returns a pricelist by version string
|
||||
func (r *PricelistRepository) GetByVersion(version string) (*models.Pricelist, error) {
|
||||
return r.GetBySourceAndVersion(string(models.PricelistSourceEstimate), version)
|
||||
}
|
||||
|
||||
// GetBySourceAndVersion returns a pricelist by source/version.
|
||||
func (r *PricelistRepository) GetBySourceAndVersion(source, version string) (*models.Pricelist, error) {
|
||||
var pricelist models.Pricelist
|
||||
if err := r.db.Where("version = ?", version).First(&pricelist).Error; err != nil {
|
||||
if err := r.db.Where("source = ? AND version = ?", source, version).First(&pricelist).Error; err != nil {
|
||||
return nil, fmt.Errorf("getting pricelist by version: %w", err)
|
||||
}
|
||||
return &pricelist, nil
|
||||
@@ -111,8 +137,13 @@ func (r *PricelistRepository) GetByVersion(version string) (*models.Pricelist, e
|
||||
|
||||
// GetLatestActive returns the most recent active pricelist
|
||||
func (r *PricelistRepository) GetLatestActive() (*models.Pricelist, error) {
|
||||
return r.GetLatestActiveBySource(string(models.PricelistSourceEstimate))
|
||||
}
|
||||
|
||||
// GetLatestActiveBySource returns the most recent active pricelist by source.
|
||||
func (r *PricelistRepository) GetLatestActiveBySource(source string) (*models.Pricelist, error) {
|
||||
var pricelist models.Pricelist
|
||||
if err := r.db.Where("is_active = ?", true).Order("created_at DESC").First(&pricelist).Error; err != nil {
|
||||
if err := r.db.Where("is_active = ? AND source = ?", true, source).Order("created_at DESC").First(&pricelist).Error; err != nil {
|
||||
return nil, fmt.Errorf("getting latest pricelist: %w", err)
|
||||
}
|
||||
return &pricelist, nil
|
||||
@@ -228,12 +259,17 @@ func (r *PricelistRepository) SetActive(id uint, isActive bool) error {
|
||||
|
||||
// GenerateVersion generates a new version string in format YYYY-MM-DD-NNN
|
||||
func (r *PricelistRepository) GenerateVersion() (string, error) {
|
||||
return r.GenerateVersionBySource(string(models.PricelistSourceEstimate))
|
||||
}
|
||||
|
||||
// GenerateVersionBySource generates a new version string in format YYYY-MM-DD-NNN scoped by source.
|
||||
func (r *PricelistRepository) GenerateVersionBySource(source string) (string, error) {
|
||||
today := time.Now().Format("2006-01-02")
|
||||
|
||||
var last models.Pricelist
|
||||
err := r.db.Model(&models.Pricelist{}).
|
||||
Select("version").
|
||||
Where("version LIKE ?", today+"-%").
|
||||
Where("source = ? AND version LIKE ?", source, today+"-%").
|
||||
Order("version DESC").
|
||||
Limit(1).
|
||||
Take(&last).Error
|
||||
@@ -257,6 +293,19 @@ func (r *PricelistRepository) GenerateVersion() (string, error) {
|
||||
return fmt.Sprintf("%s-%03d", today, n+1), nil
|
||||
}
|
||||
|
||||
// GetPriceForLotBySource returns item price for a lot from latest active pricelist of source.
|
||||
func (r *PricelistRepository) GetPriceForLotBySource(source, lotName string) (float64, uint, error) {
|
||||
latest, err := r.GetLatestActiveBySource(source)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
price, err := r.GetPriceForLot(latest.ID, lotName)
|
||||
if err != nil {
|
||||
return 0, 0, err
|
||||
}
|
||||
return price, latest.ID, nil
|
||||
}
|
||||
|
||||
// CanWrite checks if the current database user has INSERT permission on qt_pricelists
|
||||
func (r *PricelistRepository) CanWrite() bool {
|
||||
canWrite, _ := r.CanWriteDebug()
|
||||
|
||||
@@ -13,9 +13,9 @@ import (
|
||||
func TestGenerateVersion_FirstOfDay(t *testing.T) {
|
||||
repo := newTestPricelistRepository(t)
|
||||
|
||||
version, err := repo.GenerateVersion()
|
||||
version, err := repo.GenerateVersionBySource(string(models.PricelistSourceEstimate))
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateVersion returned error: %v", err)
|
||||
t.Fatalf("GenerateVersionBySource returned error: %v", err)
|
||||
}
|
||||
|
||||
today := time.Now().Format("2006-01-02")
|
||||
@@ -30,8 +30,8 @@ func TestGenerateVersion_UsesMaxSuffixNotCount(t *testing.T) {
|
||||
today := time.Now().Format("2006-01-02")
|
||||
|
||||
seed := []models.Pricelist{
|
||||
{Version: fmt.Sprintf("%s-001", today), CreatedBy: "test", IsActive: true},
|
||||
{Version: fmt.Sprintf("%s-003", today), CreatedBy: "test", IsActive: true},
|
||||
{Source: string(models.PricelistSourceEstimate), Version: fmt.Sprintf("%s-001", today), CreatedBy: "test", IsActive: true},
|
||||
{Source: string(models.PricelistSourceEstimate), Version: fmt.Sprintf("%s-003", today), CreatedBy: "test", IsActive: true},
|
||||
}
|
||||
for _, pl := range seed {
|
||||
if err := repo.Create(&pl); err != nil {
|
||||
@@ -39,9 +39,9 @@ func TestGenerateVersion_UsesMaxSuffixNotCount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
version, err := repo.GenerateVersion()
|
||||
version, err := repo.GenerateVersionBySource(string(models.PricelistSourceEstimate))
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateVersion returned error: %v", err)
|
||||
t.Fatalf("GenerateVersionBySource returned error: %v", err)
|
||||
}
|
||||
|
||||
want := fmt.Sprintf("%s-004", today)
|
||||
@@ -50,6 +50,31 @@ func TestGenerateVersion_UsesMaxSuffixNotCount(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGenerateVersion_IsolatedBySource(t *testing.T) {
|
||||
repo := newTestPricelistRepository(t)
|
||||
today := time.Now().Format("2006-01-02")
|
||||
|
||||
seed := []models.Pricelist{
|
||||
{Source: string(models.PricelistSourceEstimate), Version: fmt.Sprintf("%s-009", today), CreatedBy: "test", IsActive: true},
|
||||
{Source: string(models.PricelistSourceWarehouse), Version: fmt.Sprintf("%s-002", today), CreatedBy: "test", IsActive: true},
|
||||
}
|
||||
for _, pl := range seed {
|
||||
if err := repo.Create(&pl); err != nil {
|
||||
t.Fatalf("seed insert failed: %v", err)
|
||||
}
|
||||
}
|
||||
|
||||
version, err := repo.GenerateVersionBySource(string(models.PricelistSourceWarehouse))
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateVersionBySource returned error: %v", err)
|
||||
}
|
||||
|
||||
want := fmt.Sprintf("%s-003", today)
|
||||
if version != want {
|
||||
t.Fatalf("expected %s, got %s", want, version)
|
||||
}
|
||||
}
|
||||
|
||||
func newTestPricelistRepository(t *testing.T) *PricelistRepository {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ package repository
|
||||
import (
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"gorm.io/gorm"
|
||||
"gorm.io/gorm/clause"
|
||||
)
|
||||
|
||||
type ProjectRepository struct {
|
||||
@@ -21,6 +22,30 @@ func (r *ProjectRepository) Update(project *models.Project) error {
|
||||
return r.db.Save(project).Error
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) UpsertByUUID(project *models.Project) error {
|
||||
if err := r.db.Clauses(clause.OnConflict{
|
||||
Columns: []clause.Column{{Name: "uuid"}},
|
||||
DoUpdates: clause.AssignmentColumns([]string{
|
||||
"owner_username",
|
||||
"name",
|
||||
"tracker_url",
|
||||
"is_active",
|
||||
"is_system",
|
||||
"updated_at",
|
||||
}),
|
||||
}).Create(project).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Ensure caller always gets canonical server ID.
|
||||
var persisted models.Project
|
||||
if err := r.db.Where("uuid = ?", project.UUID).First(&persisted).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
project.ID = persisted.ID
|
||||
return nil
|
||||
}
|
||||
|
||||
func (r *ProjectRepository) GetByUUID(uuid string) (*models.Project, error) {
|
||||
var project models.Project
|
||||
if err := r.db.Where("uuid = ?", uuid).First(&project).Error; err != nil {
|
||||
|
||||
@@ -129,9 +129,12 @@ func (s *LocalConfigurationService) Update(uuid string, ownerUsername string, re
|
||||
return nil, ErrConfigForbidden
|
||||
}
|
||||
|
||||
projectUUID, err := s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
projectUUID := localCfg.ProjectUUID
|
||||
if req.ProjectUUID != nil {
|
||||
projectUUID, err = s.resolveProjectUUID(ownerUsername, req.ProjectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||
if err != nil {
|
||||
@@ -418,9 +421,12 @@ func (s *LocalConfigurationService) UpdateNoAuth(uuid string, req *CreateConfigR
|
||||
return nil, ErrConfigNotFound
|
||||
}
|
||||
|
||||
projectUUID, err := s.resolveProjectUUID(localCfg.OriginalUsername, req.ProjectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
projectUUID := localCfg.ProjectUUID
|
||||
if req.ProjectUUID != nil {
|
||||
projectUUID, err = s.resolveProjectUUID(localCfg.OriginalUsername, req.ProjectUUID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
}
|
||||
pricelistID, err := s.resolvePricelistID(req.PricelistID)
|
||||
if err != nil {
|
||||
|
||||
@@ -185,6 +185,48 @@ WHERE configuration_uuid = ?`, created.UUID).Scan(&c).Error; err != nil {
|
||||
}
|
||||
}
|
||||
|
||||
func TestUpdateNoAuthKeepsProjectWhenProjectUUIDOmitted(t *testing.T) {
|
||||
service, local := newLocalConfigServiceForTest(t)
|
||||
|
||||
project := &localdb.LocalProject{
|
||||
UUID: "project-keep",
|
||||
OwnerUsername: "tester",
|
||||
Name: "Keep Project",
|
||||
IsActive: true,
|
||||
CreatedAt: time.Now(),
|
||||
UpdatedAt: time.Now(),
|
||||
SyncStatus: "synced",
|
||||
}
|
||||
if err := local.SaveProject(project); err != nil {
|
||||
t.Fatalf("save project: %v", err)
|
||||
}
|
||||
|
||||
created, err := service.Create("tester", &CreateConfigRequest{
|
||||
Name: "cfg",
|
||||
ProjectUUID: &project.UUID,
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 100}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
if created.ProjectUUID == nil || *created.ProjectUUID != project.UUID {
|
||||
t.Fatalf("expected created config project_uuid=%s", project.UUID)
|
||||
}
|
||||
|
||||
updated, err := service.UpdateNoAuth(created.UUID, &CreateConfigRequest{
|
||||
Name: "cfg-updated",
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 2, UnitPrice: 100}},
|
||||
ServerCount: 1,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("update config without project_uuid: %v", err)
|
||||
}
|
||||
if updated.ProjectUUID == nil || *updated.ProjectUUID != project.UUID {
|
||||
t.Fatalf("expected project_uuid to stay %s after update, got %+v", project.UUID, updated.ProjectUUID)
|
||||
}
|
||||
}
|
||||
|
||||
func newLocalConfigServiceForTest(t *testing.T) (*LocalConfigurationService, *localdb.LocalDB) {
|
||||
t.Helper()
|
||||
|
||||
|
||||
@@ -30,6 +30,11 @@ type CreateProgress struct {
|
||||
LotName string
|
||||
}
|
||||
|
||||
type CreateItemInput struct {
|
||||
LotName string
|
||||
Price float64
|
||||
}
|
||||
|
||||
func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo *repository.ComponentRepository, pricingSvc *pricing.Service) *Service {
|
||||
return &Service{
|
||||
repo: repo,
|
||||
@@ -41,14 +46,25 @@ func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo
|
||||
|
||||
// CreateFromCurrentPrices creates a new pricelist by taking a snapshot of current prices
|
||||
func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist, error) {
|
||||
return s.CreateFromCurrentPricesWithProgress(createdBy, nil)
|
||||
return s.CreateFromCurrentPricesForSource(createdBy, string(models.PricelistSourceEstimate))
|
||||
}
|
||||
|
||||
// CreateFromCurrentPricesForSource creates a new pricelist snapshot for one source.
|
||||
func (s *Service) CreateFromCurrentPricesForSource(createdBy, source string) (*models.Pricelist, error) {
|
||||
return s.CreateForSourceWithProgress(createdBy, source, nil, nil)
|
||||
}
|
||||
|
||||
// CreateFromCurrentPricesWithProgress creates a pricelist and reports coarse-grained progress.
|
||||
func (s *Service) CreateFromCurrentPricesWithProgress(createdBy string, onProgress func(CreateProgress)) (*models.Pricelist, error) {
|
||||
func (s *Service) CreateFromCurrentPricesWithProgress(createdBy, source string, onProgress func(CreateProgress)) (*models.Pricelist, error) {
|
||||
return s.CreateForSourceWithProgress(createdBy, source, nil, onProgress)
|
||||
}
|
||||
|
||||
// CreateForSourceWithProgress creates a source pricelist from current estimate snapshot or explicit item list.
|
||||
func (s *Service) CreateForSourceWithProgress(createdBy, source string, sourceItems []CreateItemInput, onProgress func(CreateProgress)) (*models.Pricelist, error) {
|
||||
if s.repo == nil || s.db == nil {
|
||||
return nil, fmt.Errorf("offline mode: cannot create pricelists")
|
||||
}
|
||||
source = string(models.NormalizePricelistSource(source))
|
||||
|
||||
report := func(p CreateProgress) {
|
||||
if onProgress != nil {
|
||||
@@ -58,7 +74,7 @@ func (s *Service) CreateFromCurrentPricesWithProgress(createdBy string, onProgre
|
||||
report(CreateProgress{Current: 0, Total: 100, Status: "starting", Message: "Подготовка"})
|
||||
|
||||
updated, errs := 0, 0
|
||||
if s.pricingSvc != nil {
|
||||
if source == string(models.PricelistSourceEstimate) && s.pricingSvc != nil {
|
||||
report(CreateProgress{Current: 1, Total: 100, Status: "recalculating", Message: "Обновление цен компонентов"})
|
||||
updated, errs = s.pricingSvc.RecalculateAllPricesWithProgress(func(p pricing.RecalculateProgress) {
|
||||
if p.Total <= 0 {
|
||||
@@ -86,12 +102,13 @@ func (s *Service) CreateFromCurrentPricesWithProgress(createdBy string, onProgre
|
||||
const maxCreateAttempts = 5
|
||||
var pricelist *models.Pricelist
|
||||
for attempt := 1; attempt <= maxCreateAttempts; attempt++ {
|
||||
version, err := s.repo.GenerateVersion()
|
||||
version, err := s.repo.GenerateVersionBySource(source)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("generating version: %w", err)
|
||||
}
|
||||
|
||||
pricelist = &models.Pricelist{
|
||||
Source: source,
|
||||
Version: version,
|
||||
CreatedBy: createdBy,
|
||||
IsActive: true,
|
||||
@@ -113,28 +130,43 @@ func (s *Service) CreateFromCurrentPricesWithProgress(createdBy string, onProgre
|
||||
break
|
||||
}
|
||||
|
||||
// Get all components with prices from qt_lot_metadata
|
||||
var metadata []models.LotMetadata
|
||||
if err := s.db.Where("current_price IS NOT NULL AND current_price > 0").Find(&metadata).Error; err != nil {
|
||||
return nil, fmt.Errorf("getting lot metadata: %w", err)
|
||||
}
|
||||
|
||||
// Create pricelist items with all price settings
|
||||
items := make([]models.PricelistItem, 0, len(metadata))
|
||||
for _, m := range metadata {
|
||||
if m.CurrentPrice == nil || *m.CurrentPrice <= 0 {
|
||||
continue
|
||||
items := make([]models.PricelistItem, 0)
|
||||
if len(sourceItems) > 0 {
|
||||
items = make([]models.PricelistItem, 0, len(sourceItems))
|
||||
for _, srcItem := range sourceItems {
|
||||
if strings.TrimSpace(srcItem.LotName) == "" || srcItem.Price <= 0 {
|
||||
continue
|
||||
}
|
||||
items = append(items, models.PricelistItem{
|
||||
PricelistID: pricelist.ID,
|
||||
LotName: strings.TrimSpace(srcItem.LotName),
|
||||
Price: srcItem.Price,
|
||||
})
|
||||
}
|
||||
} else {
|
||||
// Default snapshot source for estimate and backward compatibility.
|
||||
var metadata []models.LotMetadata
|
||||
if err := s.db.Where("current_price IS NOT NULL AND current_price > 0").Find(&metadata).Error; err != nil {
|
||||
return nil, fmt.Errorf("getting lot metadata: %w", err)
|
||||
}
|
||||
|
||||
// Create pricelist items with all price settings
|
||||
items = make([]models.PricelistItem, 0, len(metadata))
|
||||
for _, m := range metadata {
|
||||
if m.CurrentPrice == nil || *m.CurrentPrice <= 0 {
|
||||
continue
|
||||
}
|
||||
items = append(items, models.PricelistItem{
|
||||
PricelistID: pricelist.ID,
|
||||
LotName: m.LotName,
|
||||
Price: *m.CurrentPrice,
|
||||
PriceMethod: string(m.PriceMethod),
|
||||
PricePeriodDays: m.PricePeriodDays,
|
||||
PriceCoefficient: m.PriceCoefficient,
|
||||
ManualPrice: m.ManualPrice,
|
||||
MetaPrices: m.MetaPrices,
|
||||
})
|
||||
}
|
||||
items = append(items, models.PricelistItem{
|
||||
PricelistID: pricelist.ID,
|
||||
LotName: m.LotName,
|
||||
Price: *m.CurrentPrice,
|
||||
PriceMethod: string(m.PriceMethod),
|
||||
PricePeriodDays: m.PricePeriodDays,
|
||||
PriceCoefficient: m.PriceCoefficient,
|
||||
ManualPrice: m.ManualPrice,
|
||||
MetaPrices: m.MetaPrices,
|
||||
})
|
||||
}
|
||||
|
||||
if err := s.repo.CreateItems(items); err != nil {
|
||||
@@ -161,11 +193,17 @@ func isVersionConflictError(err error) bool {
|
||||
return true
|
||||
}
|
||||
msg := strings.ToLower(err.Error())
|
||||
return strings.Contains(msg, "duplicate entry") && strings.Contains(msg, "idx_qt_pricelists_version")
|
||||
return strings.Contains(msg, "duplicate entry") &&
|
||||
(strings.Contains(msg, "idx_qt_pricelists_source_version") || strings.Contains(msg, "idx_qt_pricelists_version"))
|
||||
}
|
||||
|
||||
// List returns pricelists with pagination
|
||||
func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, error) {
|
||||
return s.ListBySource(page, perPage, "")
|
||||
}
|
||||
|
||||
// ListBySource returns pricelists with optional source filter.
|
||||
func (s *Service) ListBySource(page, perPage int, source string) ([]models.PricelistSummary, int64, error) {
|
||||
// If no database connection (offline mode), return empty list
|
||||
if s.repo == nil {
|
||||
return []models.PricelistSummary{}, 0, nil
|
||||
@@ -178,11 +216,16 @@ func (s *Service) List(page, perPage int) ([]models.PricelistSummary, int64, err
|
||||
perPage = 20
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
return s.repo.List(offset, perPage)
|
||||
return s.repo.ListBySource(source, offset, perPage)
|
||||
}
|
||||
|
||||
// ListActive returns active pricelists with pagination.
|
||||
func (s *Service) ListActive(page, perPage int) ([]models.PricelistSummary, int64, error) {
|
||||
return s.ListActiveBySource(page, perPage, "")
|
||||
}
|
||||
|
||||
// ListActiveBySource returns active pricelists with optional source filter.
|
||||
func (s *Service) ListActiveBySource(page, perPage int, source string) ([]models.PricelistSummary, int64, error) {
|
||||
if s.repo == nil {
|
||||
return []models.PricelistSummary{}, 0, nil
|
||||
}
|
||||
@@ -193,7 +236,7 @@ func (s *Service) ListActive(page, perPage int) ([]models.PricelistSummary, int6
|
||||
perPage = 20
|
||||
}
|
||||
offset := (page - 1) * perPage
|
||||
return s.repo.ListActive(offset, perPage)
|
||||
return s.repo.ListActiveBySource(source, offset, perPage)
|
||||
}
|
||||
|
||||
// GetByID returns a pricelist by ID
|
||||
@@ -261,10 +304,15 @@ func (s *Service) CanWriteDebug() (bool, string) {
|
||||
|
||||
// GetLatestActive returns the most recent active pricelist
|
||||
func (s *Service) GetLatestActive() (*models.Pricelist, error) {
|
||||
return s.GetLatestActiveBySource(string(models.PricelistSourceEstimate))
|
||||
}
|
||||
|
||||
// GetLatestActiveBySource returns the latest active pricelist for a source.
|
||||
func (s *Service) GetLatestActiveBySource(source string) (*models.Pricelist, error) {
|
||||
if s.repo == nil {
|
||||
return nil, fmt.Errorf("offline mode: pricelist service not available")
|
||||
}
|
||||
return s.repo.GetLatestActive()
|
||||
return s.repo.GetLatestActiveBySource(source)
|
||||
}
|
||||
|
||||
// CleanupExpired deletes expired and unused pricelists
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/url"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -28,11 +29,13 @@ func NewProjectService(localDB *localdb.LocalDB) *ProjectService {
|
||||
}
|
||||
|
||||
type CreateProjectRequest struct {
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name"`
|
||||
TrackerURL string `json:"tracker_url"`
|
||||
}
|
||||
|
||||
type UpdateProjectRequest struct {
|
||||
Name string `json:"name"`
|
||||
Name string `json:"name"`
|
||||
TrackerURL *string `json:"tracker_url,omitempty"`
|
||||
}
|
||||
|
||||
type ProjectConfigurationsResult struct {
|
||||
@@ -52,6 +55,7 @@ func (s *ProjectService) Create(ownerUsername string, req *CreateProjectRequest)
|
||||
UUID: uuid.NewString(),
|
||||
OwnerUsername: ownerUsername,
|
||||
Name: name,
|
||||
TrackerURL: normalizeProjectTrackerURL(name, req.TrackerURL),
|
||||
IsActive: true,
|
||||
IsSystem: false,
|
||||
CreatedAt: now,
|
||||
@@ -82,6 +86,11 @@ func (s *ProjectService) Update(projectUUID, ownerUsername string, req *UpdatePr
|
||||
}
|
||||
|
||||
localProject.Name = name
|
||||
if req.TrackerURL != nil {
|
||||
localProject.TrackerURL = normalizeProjectTrackerURL(name, *req.TrackerURL)
|
||||
} else if strings.TrimSpace(localProject.TrackerURL) == "" {
|
||||
localProject.TrackerURL = normalizeProjectTrackerURL(name, "")
|
||||
}
|
||||
localProject.UpdatedAt = time.Now()
|
||||
localProject.SyncStatus = "pending"
|
||||
if err := s.localDB.SaveProject(localProject); err != nil {
|
||||
@@ -260,6 +269,20 @@ func (s *ProjectService) ResolveProjectUUID(ownerUsername string, projectUUID *s
|
||||
return &resolved, nil
|
||||
}
|
||||
|
||||
func normalizeProjectTrackerURL(projectCode, trackerURL string) string {
|
||||
trimmedURL := strings.TrimSpace(trackerURL)
|
||||
if trimmedURL != "" {
|
||||
return trimmedURL
|
||||
}
|
||||
|
||||
trimmedCode := strings.TrimSpace(projectCode)
|
||||
if trimmedCode == "" {
|
||||
return ""
|
||||
}
|
||||
|
||||
return "https://tracker.yandex.ru/" + url.PathEscape(trimmedCode)
|
||||
}
|
||||
|
||||
func (s *ProjectService) enqueueProjectPendingChange(project *localdb.LocalProject, operation string) error {
|
||||
return s.enqueueProjectPendingChangeTx(s.localDB.DB(), project, operation)
|
||||
}
|
||||
|
||||
@@ -3,6 +3,7 @@ package services
|
||||
import (
|
||||
"errors"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/localdb"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
|
||||
@@ -17,17 +18,23 @@ var (
|
||||
type QuoteService struct {
|
||||
componentRepo *repository.ComponentRepository
|
||||
statsRepo *repository.StatsRepository
|
||||
pricelistRepo *repository.PricelistRepository
|
||||
localDB *localdb.LocalDB
|
||||
pricingService *pricing.Service
|
||||
}
|
||||
|
||||
func NewQuoteService(
|
||||
componentRepo *repository.ComponentRepository,
|
||||
statsRepo *repository.StatsRepository,
|
||||
pricelistRepo *repository.PricelistRepository,
|
||||
localDB *localdb.LocalDB,
|
||||
pricingService *pricing.Service,
|
||||
) *QuoteService {
|
||||
return &QuoteService{
|
||||
componentRepo: componentRepo,
|
||||
statsRepo: statsRepo,
|
||||
pricelistRepo: pricelistRepo,
|
||||
localDB: localDB,
|
||||
pricingService: pricingService,
|
||||
}
|
||||
}
|
||||
@@ -57,6 +64,34 @@ type QuoteRequest struct {
|
||||
} `json:"items"`
|
||||
}
|
||||
|
||||
type PriceLevelsRequest struct {
|
||||
Items []struct {
|
||||
LotName string `json:"lot_name"`
|
||||
Quantity int `json:"quantity"`
|
||||
} `json:"items"`
|
||||
PricelistIDs map[string]uint `json:"pricelist_ids,omitempty"`
|
||||
}
|
||||
|
||||
type PriceLevelsItem struct {
|
||||
LotName string `json:"lot_name"`
|
||||
Quantity int `json:"quantity"`
|
||||
EstimatePrice *float64 `json:"estimate_price"`
|
||||
WarehousePrice *float64 `json:"warehouse_price"`
|
||||
CompetitorPrice *float64 `json:"competitor_price"`
|
||||
DeltaWhEstimateAbs *float64 `json:"delta_wh_estimate_abs"`
|
||||
DeltaWhEstimatePct *float64 `json:"delta_wh_estimate_pct"`
|
||||
DeltaCompEstimateAbs *float64 `json:"delta_comp_estimate_abs"`
|
||||
DeltaCompEstimatePct *float64 `json:"delta_comp_estimate_pct"`
|
||||
DeltaCompWhAbs *float64 `json:"delta_comp_wh_abs"`
|
||||
DeltaCompWhPct *float64 `json:"delta_comp_wh_pct"`
|
||||
PriceMissing []string `json:"price_missing"`
|
||||
}
|
||||
|
||||
type PriceLevelsResult struct {
|
||||
Items []PriceLevelsItem `json:"items"`
|
||||
ResolvedPricelistIDs map[string]uint `json:"resolved_pricelist_ids"`
|
||||
}
|
||||
|
||||
func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidationResult, error) {
|
||||
if len(req.Items) == 0 {
|
||||
return nil, ErrEmptyQuote
|
||||
@@ -130,6 +165,132 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *QuoteService) CalculatePriceLevels(req *PriceLevelsRequest) (*PriceLevelsResult, error) {
|
||||
if len(req.Items) == 0 {
|
||||
return nil, ErrEmptyQuote
|
||||
}
|
||||
|
||||
result := &PriceLevelsResult{
|
||||
Items: make([]PriceLevelsItem, 0, len(req.Items)),
|
||||
ResolvedPricelistIDs: map[string]uint{},
|
||||
}
|
||||
|
||||
for _, reqItem := range req.Items {
|
||||
item := PriceLevelsItem{
|
||||
LotName: reqItem.LotName,
|
||||
Quantity: reqItem.Quantity,
|
||||
PriceMissing: make([]string, 0, 3),
|
||||
}
|
||||
|
||||
estimatePrice, estimateID := s.lookupLevelPrice(models.PricelistSourceEstimate, reqItem.LotName, req.PricelistIDs)
|
||||
warehousePrice, warehouseID := s.lookupLevelPrice(models.PricelistSourceWarehouse, reqItem.LotName, req.PricelistIDs)
|
||||
competitorPrice, competitorID := s.lookupLevelPrice(models.PricelistSourceCompetitor, reqItem.LotName, req.PricelistIDs)
|
||||
|
||||
item.EstimatePrice = estimatePrice
|
||||
item.WarehousePrice = warehousePrice
|
||||
item.CompetitorPrice = competitorPrice
|
||||
|
||||
if estimateID != 0 {
|
||||
result.ResolvedPricelistIDs[string(models.PricelistSourceEstimate)] = estimateID
|
||||
}
|
||||
if warehouseID != 0 {
|
||||
result.ResolvedPricelistIDs[string(models.PricelistSourceWarehouse)] = warehouseID
|
||||
}
|
||||
if competitorID != 0 {
|
||||
result.ResolvedPricelistIDs[string(models.PricelistSourceCompetitor)] = competitorID
|
||||
}
|
||||
|
||||
if item.EstimatePrice == nil {
|
||||
item.PriceMissing = append(item.PriceMissing, string(models.PricelistSourceEstimate))
|
||||
}
|
||||
if item.WarehousePrice == nil {
|
||||
item.PriceMissing = append(item.PriceMissing, string(models.PricelistSourceWarehouse))
|
||||
}
|
||||
if item.CompetitorPrice == nil {
|
||||
item.PriceMissing = append(item.PriceMissing, string(models.PricelistSourceCompetitor))
|
||||
}
|
||||
|
||||
item.DeltaWhEstimateAbs, item.DeltaWhEstimatePct = calculateDelta(item.WarehousePrice, item.EstimatePrice)
|
||||
item.DeltaCompEstimateAbs, item.DeltaCompEstimatePct = calculateDelta(item.CompetitorPrice, item.EstimatePrice)
|
||||
item.DeltaCompWhAbs, item.DeltaCompWhPct = calculateDelta(item.CompetitorPrice, item.WarehousePrice)
|
||||
|
||||
result.Items = append(result.Items, item)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func calculateDelta(target, base *float64) (*float64, *float64) {
|
||||
if target == nil || base == nil {
|
||||
return nil, nil
|
||||
}
|
||||
abs := *target - *base
|
||||
if *base == 0 {
|
||||
return &abs, nil
|
||||
}
|
||||
pct := (abs / *base) * 100
|
||||
return &abs, &pct
|
||||
}
|
||||
|
||||
func (s *QuoteService) lookupLevelPrice(source models.PricelistSource, lotName string, pricelistIDs map[string]uint) (*float64, uint) {
|
||||
sourceKey := string(source)
|
||||
if id, ok := pricelistIDs[sourceKey]; ok && id > 0 {
|
||||
price, found := s.lookupPriceByPricelistID(id, lotName)
|
||||
if found {
|
||||
return &price, id
|
||||
}
|
||||
return nil, id
|
||||
}
|
||||
|
||||
if s.pricelistRepo != nil {
|
||||
price, id, err := s.pricelistRepo.GetPriceForLotBySource(sourceKey, lotName)
|
||||
if err == nil && price > 0 {
|
||||
return &price, id
|
||||
}
|
||||
|
||||
latest, latestErr := s.pricelistRepo.GetLatestActiveBySource(sourceKey)
|
||||
if latestErr == nil {
|
||||
return nil, latest.ID
|
||||
}
|
||||
}
|
||||
|
||||
if s.localDB != nil {
|
||||
localPL, err := s.localDB.GetLatestLocalPricelistBySource(sourceKey)
|
||||
if err != nil {
|
||||
return nil, 0
|
||||
}
|
||||
price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName)
|
||||
if err != nil || price <= 0 {
|
||||
return nil, localPL.ServerID
|
||||
}
|
||||
return &price, localPL.ServerID
|
||||
}
|
||||
|
||||
return nil, 0
|
||||
}
|
||||
|
||||
func (s *QuoteService) lookupPriceByPricelistID(pricelistID uint, lotName string) (float64, bool) {
|
||||
if s.pricelistRepo != nil {
|
||||
price, err := s.pricelistRepo.GetPriceForLot(pricelistID, lotName)
|
||||
if err == nil && price > 0 {
|
||||
return price, true
|
||||
}
|
||||
}
|
||||
|
||||
if s.localDB != nil {
|
||||
localPL, err := s.localDB.GetLocalPricelistByServerID(pricelistID)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
price, err := s.localDB.GetLocalPriceForLot(localPL.ID, lotName)
|
||||
if err == nil && price > 0 {
|
||||
return price, true
|
||||
}
|
||||
}
|
||||
|
||||
return 0, false
|
||||
}
|
||||
|
||||
// RecordUsage records that components were used in a quote
|
||||
func (s *QuoteService) RecordUsage(items []models.ConfigItem) error {
|
||||
if s.statsRepo == nil {
|
||||
|
||||
124
internal/services/quote_price_levels_test.go
Normal file
124
internal/services/quote_price_levels_test.go
Normal file
@@ -0,0 +1,124 @@
|
||||
package services
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"git.mchus.pro/mchus/quoteforge/internal/models"
|
||||
"git.mchus.pro/mchus/quoteforge/internal/repository"
|
||||
"github.com/glebarez/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestCalculatePriceLevels_WithMissingLevel(t *testing.T) {
|
||||
db := newPriceLevelsTestDB(t)
|
||||
repo := repository.NewPricelistRepository(db)
|
||||
service := NewQuoteService(nil, nil, repo, nil, nil)
|
||||
|
||||
estimate := seedPricelistWithItem(t, repo, "estimate", "CPU_X", 100)
|
||||
_ = estimate
|
||||
seedPricelistWithItem(t, repo, "warehouse", "CPU_X", 120)
|
||||
|
||||
result, err := service.CalculatePriceLevels(&PriceLevelsRequest{
|
||||
Items: []struct {
|
||||
LotName string `json:"lot_name"`
|
||||
Quantity int `json:"quantity"`
|
||||
}{
|
||||
{LotName: "CPU_X", Quantity: 2},
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CalculatePriceLevels returned error: %v", err)
|
||||
}
|
||||
if len(result.Items) != 1 {
|
||||
t.Fatalf("expected 1 item, got %d", len(result.Items))
|
||||
}
|
||||
item := result.Items[0]
|
||||
if item.EstimatePrice == nil || *item.EstimatePrice != 100 {
|
||||
t.Fatalf("expected estimate 100, got %#v", item.EstimatePrice)
|
||||
}
|
||||
if item.WarehousePrice == nil || *item.WarehousePrice != 120 {
|
||||
t.Fatalf("expected warehouse 120, got %#v", item.WarehousePrice)
|
||||
}
|
||||
if item.CompetitorPrice != nil {
|
||||
t.Fatalf("expected competitor nil, got %#v", item.CompetitorPrice)
|
||||
}
|
||||
if len(item.PriceMissing) != 1 || item.PriceMissing[0] != "competitor" {
|
||||
t.Fatalf("expected price_missing [competitor], got %#v", item.PriceMissing)
|
||||
}
|
||||
if item.DeltaWhEstimateAbs == nil || *item.DeltaWhEstimateAbs != 20 {
|
||||
t.Fatalf("expected delta abs 20, got %#v", item.DeltaWhEstimateAbs)
|
||||
}
|
||||
if item.DeltaWhEstimatePct == nil || *item.DeltaWhEstimatePct != 20 {
|
||||
t.Fatalf("expected delta pct 20, got %#v", item.DeltaWhEstimatePct)
|
||||
}
|
||||
}
|
||||
|
||||
func TestCalculatePriceLevels_UsesExplicitPricelistIDs(t *testing.T) {
|
||||
db := newPriceLevelsTestDB(t)
|
||||
repo := repository.NewPricelistRepository(db)
|
||||
service := NewQuoteService(nil, nil, repo, nil, nil)
|
||||
|
||||
olderEstimate := seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 80)
|
||||
seedPricelistWithItem(t, repo, "estimate", "CPU_Y", 90)
|
||||
|
||||
result, err := service.CalculatePriceLevels(&PriceLevelsRequest{
|
||||
Items: []struct {
|
||||
LotName string `json:"lot_name"`
|
||||
Quantity int `json:"quantity"`
|
||||
}{
|
||||
{LotName: "CPU_Y", Quantity: 1},
|
||||
},
|
||||
PricelistIDs: map[string]uint{
|
||||
"estimate": olderEstimate.ID,
|
||||
},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("CalculatePriceLevels returned error: %v", err)
|
||||
}
|
||||
item := result.Items[0]
|
||||
if item.EstimatePrice == nil || *item.EstimatePrice != 80 {
|
||||
t.Fatalf("expected explicit estimate 80, got %#v", item.EstimatePrice)
|
||||
}
|
||||
}
|
||||
|
||||
func newPriceLevelsTestDB(t *testing.T) *gorm.DB {
|
||||
t.Helper()
|
||||
db, err := gorm.Open(sqlite.Open("file::memory:?cache=shared"), &gorm.Config{})
|
||||
if err != nil {
|
||||
t.Fatalf("open sqlite: %v", err)
|
||||
}
|
||||
if err := db.AutoMigrate(&models.Pricelist{}, &models.PricelistItem{}); err != nil {
|
||||
t.Fatalf("migrate: %v", err)
|
||||
}
|
||||
return db
|
||||
}
|
||||
|
||||
func seedPricelistWithItem(t *testing.T, repo *repository.PricelistRepository, source, lot string, price float64) *models.Pricelist {
|
||||
t.Helper()
|
||||
version, err := repo.GenerateVersionBySource(source)
|
||||
if err != nil {
|
||||
t.Fatalf("GenerateVersionBySource: %v", err)
|
||||
}
|
||||
expiresAt := time.Now().Add(24 * time.Hour)
|
||||
pl := &models.Pricelist{
|
||||
Source: source,
|
||||
Version: version,
|
||||
CreatedBy: "test",
|
||||
IsActive: true,
|
||||
ExpiresAt: &expiresAt,
|
||||
}
|
||||
if err := repo.Create(pl); err != nil {
|
||||
t.Fatalf("create pricelist: %v", err)
|
||||
}
|
||||
if err := repo.CreateItems([]models.PricelistItem{
|
||||
{
|
||||
PricelistID: pl.ID,
|
||||
LotName: lot,
|
||||
Price: price,
|
||||
},
|
||||
}); err != nil {
|
||||
t.Fatalf("create items: %v", err)
|
||||
}
|
||||
return pl
|
||||
}
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"errors"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"sort"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -64,6 +65,13 @@ type ConfigImportResult struct {
|
||||
Skipped int `json:"skipped"`
|
||||
}
|
||||
|
||||
// ProjectImportResult represents server->local project import stats.
|
||||
type ProjectImportResult struct {
|
||||
Imported int `json:"imported"`
|
||||
Updated int `json:"updated"`
|
||||
Skipped int `json:"skipped"`
|
||||
}
|
||||
|
||||
// ConfigurationChangePayload is stored in pending_changes.payload for configuration events.
|
||||
// It carries version metadata so sync can push the latest snapshot and prepare for conflict resolution.
|
||||
type ConfigurationChangePayload struct {
|
||||
@@ -153,6 +161,78 @@ func (s *Service) ImportConfigurationsToLocal() (*ConfigImportResult, error) {
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// ImportProjectsToLocal imports projects from MariaDB into local SQLite.
|
||||
// Existing local projects with pending local changes are skipped to avoid data loss.
|
||||
func (s *Service) ImportProjectsToLocal() (*ProjectImportResult, error) {
|
||||
mariaDB, err := s.getDB()
|
||||
if err != nil {
|
||||
return nil, ErrOffline
|
||||
}
|
||||
|
||||
projectRepo := repository.NewProjectRepository(mariaDB)
|
||||
result := &ProjectImportResult{}
|
||||
|
||||
offset := 0
|
||||
const limit = 200
|
||||
for {
|
||||
serverProjects, _, err := projectRepo.List(offset, limit, true)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("listing server projects: %w", err)
|
||||
}
|
||||
if len(serverProjects) == 0 {
|
||||
break
|
||||
}
|
||||
|
||||
now := time.Now()
|
||||
for i := range serverProjects {
|
||||
project := serverProjects[i]
|
||||
|
||||
existing, getErr := s.localDB.GetProjectByUUID(project.UUID)
|
||||
if getErr != nil && !errors.Is(getErr, gorm.ErrRecordNotFound) {
|
||||
return nil, fmt.Errorf("getting local project %s: %w", project.UUID, getErr)
|
||||
}
|
||||
|
||||
if existing != nil && getErr == nil {
|
||||
// Keep unsynced local changes intact.
|
||||
if existing.SyncStatus == "pending" {
|
||||
result.Skipped++
|
||||
continue
|
||||
}
|
||||
|
||||
existing.OwnerUsername = project.OwnerUsername
|
||||
existing.Name = project.Name
|
||||
existing.TrackerURL = project.TrackerURL
|
||||
existing.IsActive = project.IsActive
|
||||
existing.IsSystem = project.IsSystem
|
||||
existing.CreatedAt = project.CreatedAt
|
||||
existing.UpdatedAt = project.UpdatedAt
|
||||
serverID := project.ID
|
||||
existing.ServerID = &serverID
|
||||
existing.SyncStatus = "synced"
|
||||
existing.SyncedAt = &now
|
||||
|
||||
if err := s.localDB.SaveProject(existing); err != nil {
|
||||
return nil, fmt.Errorf("saving local project %s: %w", project.UUID, err)
|
||||
}
|
||||
result.Updated++
|
||||
continue
|
||||
}
|
||||
|
||||
localProject := localdb.ProjectToLocal(&project)
|
||||
localProject.SyncStatus = "synced"
|
||||
localProject.SyncedAt = &now
|
||||
if err := s.localDB.SaveProject(localProject); err != nil {
|
||||
return nil, fmt.Errorf("saving local project %s: %w", project.UUID, err)
|
||||
}
|
||||
result.Imported++
|
||||
}
|
||||
|
||||
offset += len(serverProjects)
|
||||
}
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
// GetStatus returns the current sync status
|
||||
func (s *Service) GetStatus() (*SyncStatus, error) {
|
||||
lastSync := s.localDB.GetLastSyncTime()
|
||||
@@ -212,21 +292,28 @@ func (s *Service) NeedSync() (bool, error) {
|
||||
}
|
||||
|
||||
pricelistRepo := repository.NewPricelistRepository(mariaDB)
|
||||
latestServer, err := pricelistRepo.GetLatestActive()
|
||||
if err != nil {
|
||||
// If no pricelists on server, no need to sync
|
||||
return false, nil
|
||||
sources := []models.PricelistSource{
|
||||
models.PricelistSourceEstimate,
|
||||
models.PricelistSourceWarehouse,
|
||||
models.PricelistSourceCompetitor,
|
||||
}
|
||||
for _, source := range sources {
|
||||
latestServer, err := pricelistRepo.GetLatestActiveBySource(string(source))
|
||||
if err != nil {
|
||||
// No active pricelist for this source yet.
|
||||
continue
|
||||
}
|
||||
|
||||
latestLocal, err := s.localDB.GetLatestLocalPricelist()
|
||||
if err != nil {
|
||||
// No local pricelists, need to sync
|
||||
return true, nil
|
||||
}
|
||||
latestLocal, err := s.localDB.GetLatestLocalPricelistBySource(string(source))
|
||||
if err != nil {
|
||||
// No local pricelist for an existing source on server.
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// If server has newer pricelist, need sync
|
||||
if latestServer.ID != latestLocal.ServerID {
|
||||
return true, nil
|
||||
// If server has newer pricelist for this source, need sync.
|
||||
if latestServer.ID != latestLocal.ServerID {
|
||||
return true, nil
|
||||
}
|
||||
}
|
||||
|
||||
return false, nil
|
||||
@@ -252,16 +339,16 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
}
|
||||
|
||||
synced := 0
|
||||
var latestLocalID uint
|
||||
var latestServerID uint
|
||||
var latestEstimateLocalID uint
|
||||
var latestEstimateCreatedAt time.Time
|
||||
for _, pl := range serverPricelists {
|
||||
// Check if pricelist already exists locally
|
||||
existing, _ := s.localDB.GetLocalPricelistByServerID(pl.ID)
|
||||
if existing != nil {
|
||||
// Already synced, track latest by server ID
|
||||
if pl.ID > latestServerID {
|
||||
latestServerID = pl.ID
|
||||
latestLocalID = existing.ID
|
||||
// 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
|
||||
}
|
||||
@@ -269,6 +356,7 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
// Create local pricelist
|
||||
localPL := &localdb.LocalPricelist{
|
||||
ServerID: pl.ID,
|
||||
Source: pl.Source,
|
||||
Version: pl.Version,
|
||||
Name: pl.Notification, // Using notification as name
|
||||
CreatedAt: pl.CreatedAt,
|
||||
@@ -290,16 +378,16 @@ func (s *Service) SyncPricelists() (int, error) {
|
||||
slog.Debug("synced pricelist with items", "version", pl.Version, "items", itemCount)
|
||||
}
|
||||
|
||||
if pl.ID > latestServerID {
|
||||
latestServerID = pl.ID
|
||||
latestLocalID = localPL.ID
|
||||
if pl.Source == string(models.PricelistSourceEstimate) && (latestEstimateCreatedAt.IsZero() || pl.CreatedAt.After(latestEstimateCreatedAt)) {
|
||||
latestEstimateCreatedAt = pl.CreatedAt
|
||||
latestEstimateLocalID = localPL.ID
|
||||
}
|
||||
synced++
|
||||
}
|
||||
|
||||
// Update component prices from latest pricelist
|
||||
if latestLocalID > 0 {
|
||||
updated, err := s.localDB.UpdateComponentPricesFromPricelist(latestLocalID)
|
||||
// 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 {
|
||||
@@ -371,21 +459,85 @@ func (s *Service) ListUserSyncStatuses(onlineThreshold time.Duration) ([]UserSyn
|
||||
return nil, fmt.Errorf("load sync status rows: %w", err)
|
||||
}
|
||||
|
||||
activeUsers, err := s.listConnectedDBUsers(mariaDB)
|
||||
if err != nil {
|
||||
slog.Debug("sync status: failed to load connected DB users", "error", err)
|
||||
activeUsers = map[string]struct{}{}
|
||||
}
|
||||
|
||||
now := time.Now().UTC()
|
||||
result := make([]UserSyncStatus, 0, len(rows))
|
||||
result := make([]UserSyncStatus, 0, len(rows)+len(activeUsers))
|
||||
for i := range rows {
|
||||
r := rows[i]
|
||||
username := strings.TrimSpace(r.Username)
|
||||
if username == "" {
|
||||
continue
|
||||
}
|
||||
|
||||
isOnline := now.Sub(r.LastSyncAt) <= onlineThreshold
|
||||
if _, connected := activeUsers[username]; connected {
|
||||
isOnline = true
|
||||
delete(activeUsers, username)
|
||||
}
|
||||
|
||||
appVersion := strings.TrimSpace(r.AppVersion)
|
||||
|
||||
result = append(result, UserSyncStatus{
|
||||
Username: r.Username,
|
||||
Username: username,
|
||||
LastSyncAt: r.LastSyncAt,
|
||||
AppVersion: strings.TrimSpace(r.AppVersion),
|
||||
IsOnline: now.Sub(r.LastSyncAt) <= onlineThreshold,
|
||||
AppVersion: appVersion,
|
||||
IsOnline: isOnline,
|
||||
})
|
||||
}
|
||||
|
||||
for username := range activeUsers {
|
||||
result = append(result, UserSyncStatus{
|
||||
Username: username,
|
||||
LastSyncAt: now,
|
||||
AppVersion: "",
|
||||
IsOnline: true,
|
||||
})
|
||||
}
|
||||
|
||||
sort.SliceStable(result, func(i, j int) bool {
|
||||
if result[i].IsOnline != result[j].IsOnline {
|
||||
return result[i].IsOnline
|
||||
}
|
||||
if result[i].LastSyncAt.Equal(result[j].LastSyncAt) {
|
||||
return strings.ToLower(result[i].Username) < strings.ToLower(result[j].Username)
|
||||
}
|
||||
return result[i].LastSyncAt.After(result[j].LastSyncAt)
|
||||
})
|
||||
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func (s *Service) listConnectedDBUsers(mariaDB *gorm.DB) (map[string]struct{}, error) {
|
||||
type processUserRow struct {
|
||||
Username string `gorm:"column:username"`
|
||||
}
|
||||
|
||||
var rows []processUserRow
|
||||
if err := mariaDB.Raw(`
|
||||
SELECT DISTINCT TRIM(USER) AS username
|
||||
FROM information_schema.PROCESSLIST
|
||||
WHERE COALESCE(TRIM(USER), '') <> ''
|
||||
AND DB = DATABASE()
|
||||
`).Scan(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
users := make(map[string]struct{}, len(rows))
|
||||
for i := range rows {
|
||||
username := strings.TrimSpace(rows[i].Username)
|
||||
if username == "" {
|
||||
continue
|
||||
}
|
||||
users[username] = struct{}{}
|
||||
}
|
||||
return users, nil
|
||||
}
|
||||
|
||||
func ensureUserSyncStatusTable(db *gorm.DB) error {
|
||||
if err := db.Exec(`
|
||||
CREATE TABLE IF NOT EXISTS qt_pricelist_sync_status (
|
||||
@@ -614,20 +766,8 @@ func (s *Service) pushProjectChange(change *localdb.PendingChange) error {
|
||||
project := payload.Snapshot
|
||||
project.UUID = payload.ProjectUUID
|
||||
|
||||
serverProject, err := projectRepo.GetByUUID(project.UUID)
|
||||
if err != nil {
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
if createErr := projectRepo.Create(&project); createErr != nil {
|
||||
return fmt.Errorf("create project on server: %w", createErr)
|
||||
}
|
||||
} else {
|
||||
return fmt.Errorf("get project on server: %w", err)
|
||||
}
|
||||
} else {
|
||||
project.ID = serverProject.ID
|
||||
if updateErr := projectRepo.Update(&project); updateErr != nil {
|
||||
return fmt.Errorf("update project on server: %w", updateErr)
|
||||
}
|
||||
if err := projectRepo.UpsertByUUID(&project); err != nil {
|
||||
return fmt.Errorf("upsert project on server: %w", err)
|
||||
}
|
||||
|
||||
localProject, localErr := s.localDB.GetProjectByUUID(project.UUID)
|
||||
@@ -889,7 +1029,7 @@ func (s *Service) ensureConfigurationProject(mariaDB *gorm.DB, cfg *models.Confi
|
||||
if modelProject.OwnerUsername == "" {
|
||||
modelProject.OwnerUsername = cfg.OwnerUsername
|
||||
}
|
||||
if createErr := projectRepo.Create(modelProject); createErr != nil {
|
||||
if createErr := projectRepo.UpsertByUUID(modelProject); createErr != nil {
|
||||
return createErr
|
||||
}
|
||||
if modelProject.ID > 0 {
|
||||
|
||||
@@ -65,6 +65,54 @@ func TestPushPendingChangesProjectsBeforeConfigurations(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushPendingChangesProjectCreateThenUpdateBeforeFirstPush(t *testing.T) {
|
||||
local := newLocalDBForSyncTest(t)
|
||||
serverDB := newServerDBForSyncTest(t)
|
||||
|
||||
localSync := syncsvc.NewService(nil, local)
|
||||
projectService := services.NewProjectService(local)
|
||||
configService := services.NewLocalConfigurationService(local, localSync, &services.QuoteService{}, func() bool { return false })
|
||||
pushService := syncsvc.NewServiceWithDB(serverDB, local)
|
||||
|
||||
project, err := projectService.Create("tester", &services.CreateProjectRequest{Name: "Project v1"})
|
||||
if err != nil {
|
||||
t.Fatalf("create project: %v", err)
|
||||
}
|
||||
if _, err := projectService.Update(project.UUID, "tester", &services.UpdateProjectRequest{Name: "Project v2"}); err != nil {
|
||||
t.Fatalf("update project: %v", err)
|
||||
}
|
||||
|
||||
cfg, err := configService.Create("tester", &services.CreateConfigRequest{
|
||||
Name: "Cfg linked",
|
||||
Items: models.ConfigItems{{LotName: "CPU_A", Quantity: 1, UnitPrice: 1000}},
|
||||
ServerCount: 1,
|
||||
ProjectUUID: &project.UUID,
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("create config: %v", err)
|
||||
}
|
||||
|
||||
if _, err := pushService.PushPendingChanges(); err != nil {
|
||||
t.Fatalf("push pending changes: %v", err)
|
||||
}
|
||||
|
||||
var serverProject models.Project
|
||||
if err := serverDB.Where("uuid = ?", project.UUID).First(&serverProject).Error; err != nil {
|
||||
t.Fatalf("project not pushed to server: %v", err)
|
||||
}
|
||||
if serverProject.Name != "Project v2" {
|
||||
t.Fatalf("expected latest project name, got %q", serverProject.Name)
|
||||
}
|
||||
|
||||
var serverCfg models.Configuration
|
||||
if err := serverDB.Where("uuid = ?", cfg.UUID).First(&serverCfg).Error; err != nil {
|
||||
t.Fatalf("configuration not pushed to server: %v", err)
|
||||
}
|
||||
if serverCfg.ProjectUUID == nil || *serverCfg.ProjectUUID != project.UUID {
|
||||
t.Fatalf("expected project_uuid=%s on pushed config, got %v", project.UUID, serverCfg.ProjectUUID)
|
||||
}
|
||||
}
|
||||
|
||||
func TestPushPendingChangesSkipsStaleUpdateAndAppliesLatest(t *testing.T) {
|
||||
local := newLocalDBForSyncTest(t)
|
||||
serverDB := newServerDBForSyncTest(t)
|
||||
@@ -277,6 +325,7 @@ CREATE TABLE qt_projects (
|
||||
uuid TEXT NOT NULL UNIQUE,
|
||||
owner_username TEXT NOT NULL,
|
||||
name TEXT NOT NULL,
|
||||
tracker_url TEXT NULL,
|
||||
is_active INTEGER NOT NULL DEFAULT 1,
|
||||
is_system INTEGER NOT NULL DEFAULT 0,
|
||||
created_at DATETIME,
|
||||
|
||||
7
migrations/012_add_project_tracker_url.sql
Normal file
7
migrations/012_add_project_tracker_url.sql
Normal file
@@ -0,0 +1,7 @@
|
||||
ALTER TABLE qt_projects
|
||||
ADD COLUMN tracker_url VARCHAR(500) NULL AFTER name;
|
||||
|
||||
UPDATE qt_projects
|
||||
SET tracker_url = CONCAT('https://tracker.yandex.ru/', TRIM(name))
|
||||
WHERE (tracker_url IS NULL OR tracker_url = '')
|
||||
AND TRIM(COALESCE(name, '')) <> '';
|
||||
15
migrations/013_add_pricelist_source.sql
Normal file
15
migrations/013_add_pricelist_source.sql
Normal file
@@ -0,0 +1,15 @@
|
||||
ALTER TABLE qt_pricelists
|
||||
ADD COLUMN IF NOT EXISTS source ENUM('estimate', 'warehouse', 'competitor') NOT NULL DEFAULT 'estimate' AFTER id;
|
||||
|
||||
UPDATE qt_pricelists
|
||||
SET source = 'estimate'
|
||||
WHERE source IS NULL OR source = '';
|
||||
|
||||
ALTER TABLE qt_pricelists
|
||||
DROP INDEX IF EXISTS idx_qt_pricelists_version;
|
||||
|
||||
CREATE UNIQUE INDEX idx_qt_pricelists_source_version
|
||||
ON qt_pricelists(source, version);
|
||||
|
||||
CREATE INDEX idx_qt_pricelists_source_created_at
|
||||
ON qt_pricelists(source, created_at);
|
||||
@@ -63,10 +63,16 @@
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Проект</label>
|
||||
<select id="create-project-select"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<option value="">Без проекта</option>
|
||||
</select>
|
||||
<input id="create-project-input"
|
||||
list="create-project-options"
|
||||
placeholder="Начните вводить название проекта"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
<datalist id="create-project-options"></datalist>
|
||||
<div class="mt-2 flex justify-between items-center gap-3">
|
||||
<button type="button" onclick="clearCreateProjectInput()" class="text-sm text-gray-600 hover:text-gray-800">
|
||||
Без проекта
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -171,10 +177,10 @@
|
||||
<div id="create-project-on-move-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||
<h2 class="text-xl font-semibold mb-3">Проект не найден</h2>
|
||||
<p class="text-sm text-gray-600 mb-4">Проект "<span id="create-project-on-move-name" class="font-medium text-gray-900"></span>" не найден. Создать и привязать квоту?</p>
|
||||
<p class="text-sm text-gray-600 mb-4">Проект "<span id="create-project-on-move-name" class="font-medium text-gray-900"></span>" не найден. <span id="create-project-on-move-description">Создать и привязать квоту?</span></p>
|
||||
<div class="flex justify-end space-x-3">
|
||||
<button onclick="closeCreateProjectOnMoveModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
||||
<button onclick="confirmCreateProjectOnMove()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Создать и привязать</button>
|
||||
<button id="create-project-on-move-confirm-btn" onclick="confirmCreateProjectOnMove()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Создать и привязать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -190,6 +196,8 @@ let projectsCache = [];
|
||||
let projectNameByUUID = {};
|
||||
let pendingMoveConfigUUID = '';
|
||||
let pendingMoveProjectName = '';
|
||||
let pendingCreateConfigName = '';
|
||||
let pendingCreateProjectName = '';
|
||||
|
||||
function renderConfigs(configs) {
|
||||
const emptyText = configStatusMode === 'archived'
|
||||
@@ -407,6 +415,7 @@ async function cloneConfig() {
|
||||
|
||||
function openCreateModal() {
|
||||
document.getElementById('opportunity-number').value = '';
|
||||
document.getElementById('create-project-input').value = '';
|
||||
document.getElementById('create-modal').classList.remove('hidden');
|
||||
document.getElementById('create-modal').classList.add('flex');
|
||||
document.getElementById('opportunity-number').focus();
|
||||
@@ -425,8 +434,25 @@ async function createConfig() {
|
||||
return;
|
||||
}
|
||||
|
||||
const projectUUID = document.getElementById('create-project-select').value;
|
||||
const projectName = document.getElementById('create-project-input').value.trim();
|
||||
let projectUUID = '';
|
||||
|
||||
if (projectName) {
|
||||
const existingProject = projectsCache.find(p => p.is_active && p.name.toLowerCase() === projectName.toLowerCase());
|
||||
if (existingProject) {
|
||||
projectUUID = existingProject.uuid;
|
||||
} else {
|
||||
pendingCreateConfigName = name;
|
||||
pendingCreateProjectName = projectName;
|
||||
openCreateProjectOnCreateModal(projectName);
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await createConfigWithProject(name, projectUUID);
|
||||
}
|
||||
|
||||
async function createConfigWithProject(name, projectUUID) {
|
||||
try {
|
||||
const resp = await fetch('/api/configs', {
|
||||
method: 'POST',
|
||||
@@ -442,16 +468,17 @@ async function createConfig() {
|
||||
})
|
||||
});
|
||||
|
||||
const config = await resp.json();
|
||||
if (!resp.ok) {
|
||||
const err = await resp.json();
|
||||
alert('Ошибка: ' + (err.error || 'Не удалось создать'));
|
||||
return;
|
||||
alert('Ошибка: ' + (config.error || 'Не удалось создать'));
|
||||
return false;
|
||||
}
|
||||
|
||||
const config = await resp.json();
|
||||
window.location.href = '/configurator?uuid=' + config.uuid;
|
||||
return true;
|
||||
} catch(e) {
|
||||
alert('Ошибка создания конфигурации');
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -510,8 +537,22 @@ function clearMoveProjectInput() {
|
||||
document.getElementById('move-project-input').value = '';
|
||||
}
|
||||
|
||||
function clearCreateProjectInput() {
|
||||
document.getElementById('create-project-input').value = '';
|
||||
}
|
||||
|
||||
function openCreateProjectOnMoveModal(projectName) {
|
||||
document.getElementById('create-project-on-move-name').textContent = projectName;
|
||||
document.getElementById('create-project-on-move-description').textContent = 'Создать и привязать квоту?';
|
||||
document.getElementById('create-project-on-move-confirm-btn').textContent = 'Создать и привязать';
|
||||
document.getElementById('create-project-on-move-modal').classList.remove('hidden');
|
||||
document.getElementById('create-project-on-move-modal').classList.add('flex');
|
||||
}
|
||||
|
||||
function openCreateProjectOnCreateModal(projectName) {
|
||||
document.getElementById('create-project-on-move-name').textContent = projectName;
|
||||
document.getElementById('create-project-on-move-description').textContent = 'Создать и использовать для новой конфигурации?';
|
||||
document.getElementById('create-project-on-move-confirm-btn').textContent = 'Создать и использовать';
|
||||
document.getElementById('create-project-on-move-modal').classList.remove('hidden');
|
||||
document.getElementById('create-project-on-move-modal').classList.add('flex');
|
||||
}
|
||||
@@ -521,9 +562,43 @@ function closeCreateProjectOnMoveModal() {
|
||||
document.getElementById('create-project-on-move-modal').classList.remove('flex');
|
||||
pendingMoveConfigUUID = '';
|
||||
pendingMoveProjectName = '';
|
||||
pendingCreateConfigName = '';
|
||||
pendingCreateProjectName = '';
|
||||
}
|
||||
|
||||
async function confirmCreateProjectOnMove() {
|
||||
if (pendingCreateConfigName && pendingCreateProjectName) {
|
||||
const configName = pendingCreateConfigName;
|
||||
const projectName = pendingCreateProjectName;
|
||||
try {
|
||||
const createResp = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({ name: projectName })
|
||||
});
|
||||
if (!createResp.ok) {
|
||||
const err = await createResp.json();
|
||||
alert('Не удалось создать проект: ' + (err.error || 'ошибка'));
|
||||
return;
|
||||
}
|
||||
|
||||
const newProject = await createResp.json();
|
||||
pendingCreateConfigName = '';
|
||||
pendingCreateProjectName = '';
|
||||
await loadProjectsForConfigUI();
|
||||
const created = await createConfigWithProject(configName, newProject.uuid);
|
||||
if (created) {
|
||||
closeCreateProjectOnMoveModal();
|
||||
} else {
|
||||
closeCreateProjectOnMoveModal();
|
||||
document.getElementById('create-project-input').value = projectName;
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Ошибка создания проекта');
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
const configUUID = pendingMoveConfigUUID;
|
||||
const projectName = pendingMoveProjectName;
|
||||
if (!configUUID || !projectName) {
|
||||
@@ -544,10 +619,15 @@ async function confirmCreateProjectOnMove() {
|
||||
}
|
||||
|
||||
const newProject = await createResp.json();
|
||||
pendingMoveConfigUUID = '';
|
||||
pendingMoveProjectName = '';
|
||||
await loadProjectsForConfigUI();
|
||||
document.getElementById('move-project-input').value = projectName;
|
||||
const moved = await moveConfigToProject(configUUID, newProject.uuid);
|
||||
if (moved) {
|
||||
closeCreateProjectOnMoveModal();
|
||||
closeMoveProjectModal();
|
||||
} else {
|
||||
closeCreateProjectOnMoveModal();
|
||||
}
|
||||
} catch (e) {
|
||||
alert('Ошибка создания проекта');
|
||||
@@ -760,16 +840,18 @@ async function loadProjectsForConfigUI() {
|
||||
const data = await resp.json();
|
||||
projectsCache = (data.projects || []);
|
||||
|
||||
const select = document.getElementById('create-project-select');
|
||||
if (select) {
|
||||
select.innerHTML = '<option value="">Без проекта</option>';
|
||||
projectsCache.forEach(project => {
|
||||
projectNameByUUID[project.uuid] = project.name;
|
||||
});
|
||||
|
||||
const createOptions = document.getElementById('create-project-options');
|
||||
if (createOptions) {
|
||||
createOptions.innerHTML = '';
|
||||
projectsCache.forEach(project => {
|
||||
projectNameByUUID[project.uuid] = project.name;
|
||||
if (!project.is_active) return;
|
||||
const option = document.createElement('option');
|
||||
option.value = project.uuid;
|
||||
option.textContent = project.name;
|
||||
select.appendChild(option);
|
||||
option.value = project.name;
|
||||
createOptions.appendChild(option);
|
||||
});
|
||||
}
|
||||
} catch (e) {
|
||||
|
||||
@@ -15,9 +15,15 @@
|
||||
</h1>
|
||||
</div>
|
||||
<div id="save-buttons" class="hidden flex items-center space-x-2">
|
||||
<button onclick="refreshPrices()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
<button id="refresh-prices-btn" onclick="refreshPrices()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
Обновить цены
|
||||
</button>
|
||||
<button type="button"
|
||||
onclick="openPriceSettingsModal()"
|
||||
class="h-10 px-3 bg-gray-100 text-gray-700 rounded hover:bg-gray-200 border border-gray-300 inline-flex items-center justify-center"
|
||||
title="Настройки цен">
|
||||
Цены
|
||||
</button>
|
||||
<button onclick="saveConfig()" class="px-4 py-2 bg-green-600 text-white rounded hover:bg-green-700">
|
||||
Сохранить
|
||||
</button>
|
||||
@@ -34,13 +40,9 @@
|
||||
class="w-20 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||
onchange="updateServerCount()">
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Прайслист</label>
|
||||
<select id="pricelist-select"
|
||||
class="w-56 px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"
|
||||
onchange="updatePricelistSelection()">
|
||||
<option value="">Загрузка...</option>
|
||||
</select>
|
||||
<div class="text-sm text-gray-600">
|
||||
<div class="font-medium text-gray-700 mb-1">Прайслисты цен</div>
|
||||
<div id="pricelist-settings-summary">Estimate: авто, Склад: авто, Конкуренты: авто</div>
|
||||
</div>
|
||||
<div class="text-sm text-gray-500">
|
||||
<span id="server-count-info">Всего: <span id="total-server-count">1</span> сервер(а)</span>
|
||||
@@ -86,20 +88,37 @@
|
||||
</div>
|
||||
|
||||
<!-- Cart summary -->
|
||||
<div id="cart-summary" class="bg-white rounded-lg shadow p-4">
|
||||
<h3 class="font-semibold mb-3">Итого конфигурация</h3>
|
||||
<div id="cart-items" class="space-y-2 mb-4"></div>
|
||||
<div class="border-t pt-3 flex justify-between items-center">
|
||||
<div class="text-lg font-bold">
|
||||
Итого: <span id="cart-total">$0.00</span>
|
||||
<div id="cart-summary" class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<button type="button"
|
||||
onclick="toggleCartSummarySection()"
|
||||
class="w-full px-4 py-3 flex items-center justify-between text-blue-900 bg-gradient-to-r from-blue-100 to-blue-50 hover:from-blue-200 hover:to-blue-100 border-b border-blue-200">
|
||||
<span class="font-semibold">Итого конфигурация</span>
|
||||
<svg id="cart-summary-toggle-icon" class="w-5 h-5 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="cart-summary-content" class="p-4">
|
||||
<div id="cart-items" class="space-y-2 mb-4"></div>
|
||||
<div class="border-t pt-3 flex justify-between items-center">
|
||||
<div class="text-lg font-bold">
|
||||
Итого: <span id="cart-total">$0.00</span>
|
||||
</div>
|
||||
<button onclick="exportCSV()" class="px-3 py-1 bg-gray-200 text-gray-700 rounded text-sm hover:bg-gray-300">Экспорт CSV</button>
|
||||
</div>
|
||||
<button onclick="exportCSV()" class="px-3 py-1 bg-gray-200 text-gray-700 rounded text-sm hover:bg-gray-300">Экспорт CSV</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Custom price section -->
|
||||
<div id="custom-price-section" class="bg-white rounded-lg shadow p-4">
|
||||
<h3 class="font-semibold mb-3">Своя цена</h3>
|
||||
<div id="custom-price-section" class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<button type="button"
|
||||
onclick="toggleCustomPriceSection()"
|
||||
class="w-full px-4 py-3 flex items-center justify-between text-blue-900 bg-gradient-to-r from-blue-100 to-blue-50 hover:from-blue-200 hover:to-blue-100 border-b border-blue-200">
|
||||
<span class="font-semibold">Своя цена</span>
|
||||
<svg id="custom-price-toggle-icon" class="w-5 h-5 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="custom-price-content" class="p-4">
|
||||
<div class="flex items-center gap-4 mb-4">
|
||||
<div class="flex-1">
|
||||
<label class="block text-sm text-gray-600 mb-1">Введите целевую цену</label>
|
||||
@@ -155,6 +174,83 @@
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Sale price section -->
|
||||
<div id="sale-price-section" class="bg-white rounded-lg shadow overflow-hidden">
|
||||
<button type="button"
|
||||
onclick="toggleSalePriceSection()"
|
||||
class="w-full px-4 py-3 flex items-center justify-between text-blue-900 bg-gradient-to-r from-blue-100 to-blue-50 hover:from-blue-200 hover:to-blue-100 border-b border-blue-200">
|
||||
<span class="font-semibold">Цена продажи</span>
|
||||
<svg id="sale-price-toggle-icon" class="w-5 h-5 transition-transform duration-200" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7"></path>
|
||||
</svg>
|
||||
</button>
|
||||
<div id="sale-price-content" class="p-4">
|
||||
<div id="sale-prices" class="border-t pt-3">
|
||||
<div class="overflow-x-auto">
|
||||
<table class="w-full text-sm">
|
||||
<thead class="bg-gray-50">
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Артикул</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Кол-во</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Est. Price</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Склад</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase">Конкуренты</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody id="sale-prices-body" class="divide-y"></tbody>
|
||||
<tfoot class="bg-gray-50 font-medium">
|
||||
<tr>
|
||||
<td class="px-3 py-2">Итого</td>
|
||||
<td class="px-3 py-2 text-right">—</td>
|
||||
<td class="px-3 py-2 text-right" id="sale-total-est">$0.00</td>
|
||||
<td class="px-3 py-2 text-right" id="sale-total-warehouse">$0.00</td>
|
||||
<td class="px-3 py-2 text-right" id="sale-total-competitor">$0.00</td>
|
||||
</tr>
|
||||
</tfoot>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Price settings modal -->
|
||||
<div id="price-settings-modal" class="hidden fixed inset-0 z-50">
|
||||
<div class="absolute inset-0 bg-black/40" onclick="closePriceSettingsModal()"></div>
|
||||
<div class="relative max-w-xl mx-auto mt-24 bg-white rounded-lg shadow-xl border">
|
||||
<div class="px-5 py-4 border-b flex items-center justify-between">
|
||||
<h3 class="text-lg font-semibold text-gray-900">Настройки цен</h3>
|
||||
<button type="button" onclick="closePriceSettingsModal()" class="text-gray-500 hover:text-gray-700">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="px-5 py-4 space-y-4">
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Estimate</label>
|
||||
<select id="settings-pricelist-estimate" class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"></select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Склад</label>
|
||||
<select id="settings-pricelist-warehouse" class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"></select>
|
||||
</div>
|
||||
<div>
|
||||
<label class="block text-sm font-medium text-gray-700 mb-1">Конкуренты</label>
|
||||
<select id="settings-pricelist-competitor" class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500"></select>
|
||||
</div>
|
||||
<label class="flex items-center gap-2 text-sm text-gray-700">
|
||||
<input id="settings-disable-price-refresh" type="checkbox" class="rounded border-gray-300 text-blue-600 focus:ring-blue-500">
|
||||
<span>Не обновлять цены</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="px-5 py-4 border-t flex justify-end gap-2">
|
||||
<button type="button" onclick="closePriceSettingsModal()" class="px-4 py-2 bg-gray-100 text-gray-700 rounded hover:bg-gray-200">Отмена</button>
|
||||
<button type="button" onclick="applyPriceSettings()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Применить</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -232,7 +328,18 @@ let cart = [];
|
||||
let categoryOrderMap = {}; // Category code -> display_order mapping
|
||||
let autoSaveTimeout = null; // Timeout for debounced autosave
|
||||
let serverCount = 1; // Server count for the configuration
|
||||
let selectedPricelistId = null; // Selected pricelist (server ID)
|
||||
let selectedPricelistIds = {
|
||||
estimate: null,
|
||||
warehouse: null,
|
||||
competitor: null
|
||||
};
|
||||
let disablePriceRefresh = false;
|
||||
let activePricelistsBySource = {
|
||||
estimate: [],
|
||||
warehouse: [],
|
||||
competitor: []
|
||||
};
|
||||
let priceLevelsRequestSeq = 0;
|
||||
|
||||
// Autocomplete state
|
||||
let autocompleteInput = null;
|
||||
@@ -241,6 +348,101 @@ let autocompleteMode = null; // 'single', 'multi', 'section'
|
||||
let autocompleteIndex = -1;
|
||||
let autocompleteFiltered = [];
|
||||
|
||||
function getDisplayPrice(item) {
|
||||
if (typeof item.unit_price === 'number' && item.unit_price > 0) {
|
||||
return item.unit_price;
|
||||
}
|
||||
if (typeof item.estimate_price === 'number' && item.estimate_price > 0) {
|
||||
return item.estimate_price;
|
||||
}
|
||||
return 0;
|
||||
}
|
||||
|
||||
function formatNumberRu(value) {
|
||||
const rounded = Math.round(value);
|
||||
return rounded
|
||||
.toLocaleString('ru-RU', { minimumFractionDigits: 0, maximumFractionDigits: 0 })
|
||||
.replace(/[\u202f\u00a0 ]/g, '\u00A0');
|
||||
}
|
||||
|
||||
function formatMoney(value) {
|
||||
return '$\u00A0' + formatNumberRu(value);
|
||||
}
|
||||
|
||||
function formatPriceOrNA(value) {
|
||||
if (typeof value !== 'number' || value <= 0) {
|
||||
return 'N/A';
|
||||
}
|
||||
return formatMoney(value);
|
||||
}
|
||||
|
||||
function formatDelta(abs, pct) {
|
||||
if (typeof abs !== 'number') {
|
||||
return 'N/A';
|
||||
}
|
||||
const sign = abs > 0 ? '+' : abs < 0 ? '-' : '';
|
||||
const absValue = Math.abs(abs);
|
||||
if (typeof pct !== 'number') {
|
||||
return sign + formatMoney(absValue);
|
||||
}
|
||||
const pctSign = pct > 0 ? '+' : pct < 0 ? '-' : '';
|
||||
return sign + formatMoney(absValue) + ' (' + pctSign + Math.round(Math.abs(pct)) + '%)';
|
||||
}
|
||||
|
||||
async function refreshPriceLevels() {
|
||||
if (!configUUID || cart.length === 0 || disablePriceRefresh) {
|
||||
return;
|
||||
}
|
||||
|
||||
const seq = ++priceLevelsRequestSeq;
|
||||
try {
|
||||
const payload = {
|
||||
items: cart.map(item => ({
|
||||
lot_name: item.lot_name,
|
||||
quantity: item.quantity
|
||||
})),
|
||||
pricelist_ids: Object.fromEntries(
|
||||
Object.entries(selectedPricelistIds)
|
||||
.filter(([, id]) => typeof id === 'number' && id > 0)
|
||||
)
|
||||
};
|
||||
const resp = await fetch('/api/quote/price-levels', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(payload)
|
||||
});
|
||||
if (!resp.ok) {
|
||||
return;
|
||||
}
|
||||
const data = await resp.json();
|
||||
if (seq !== priceLevelsRequestSeq) {
|
||||
return;
|
||||
}
|
||||
const byLot = new Map((data.items || []).map(i => [i.lot_name, i]));
|
||||
cart = cart.map(item => {
|
||||
const levels = byLot.get(item.lot_name);
|
||||
if (!levels) return item;
|
||||
const next = { ...item, ...levels };
|
||||
if (typeof levels.estimate_price === 'number' && levels.estimate_price > 0) {
|
||||
next.unit_price = levels.estimate_price;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
if (data.resolved_pricelist_ids) {
|
||||
['estimate', 'warehouse', 'competitor'].forEach(source => {
|
||||
if (!selectedPricelistIds[source] && data.resolved_pricelist_ids[source]) {
|
||||
selectedPricelistIds[source] = data.resolved_pricelist_ids[source];
|
||||
}
|
||||
});
|
||||
syncPriceSettingsControls();
|
||||
renderPricelistSettingsSummary();
|
||||
persistLocalPriceSettings();
|
||||
}
|
||||
} catch(e) {
|
||||
console.error('Failed to refresh price levels', e);
|
||||
}
|
||||
}
|
||||
|
||||
// Load categories from API and update tab configuration
|
||||
async function loadCategoriesFromAPI() {
|
||||
try {
|
||||
@@ -305,13 +507,16 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
serverCount = config.server_count || 1;
|
||||
document.getElementById('server-count').value = serverCount;
|
||||
document.getElementById('total-server-count').textContent = serverCount;
|
||||
selectedPricelistId = config.pricelist_id || null;
|
||||
selectedPricelistIds.estimate = config.pricelist_id || null;
|
||||
|
||||
if (config.items && config.items.length > 0) {
|
||||
cart = config.items.map(item => ({
|
||||
lot_name: item.lot_name,
|
||||
quantity: item.quantity,
|
||||
unit_price: item.unit_price,
|
||||
estimate_price: item.unit_price,
|
||||
warehouse_price: null,
|
||||
competitor_price: null,
|
||||
description: item.description || '',
|
||||
category: item.category || getCategoryFromLotName(item.lot_name)
|
||||
}));
|
||||
@@ -332,8 +537,13 @@ document.addEventListener('DOMContentLoaded', async function() {
|
||||
return;
|
||||
}
|
||||
|
||||
restoreLocalPriceSettings();
|
||||
await loadActivePricelists();
|
||||
syncPriceSettingsControls();
|
||||
renderPricelistSettingsSummary();
|
||||
updateRefreshPricesButtonState();
|
||||
await loadAllComponents();
|
||||
await refreshPriceLevels();
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
|
||||
@@ -373,41 +583,148 @@ function updateServerCount() {
|
||||
}
|
||||
|
||||
async function loadActivePricelists() {
|
||||
const select = document.getElementById('pricelist-select');
|
||||
const sources = ['estimate', 'warehouse', 'competitor'];
|
||||
await Promise.all(sources.map(async source => {
|
||||
try {
|
||||
const resp = await fetch(`/api/pricelists?active_only=true&source=${source}&per_page=200`);
|
||||
const data = await resp.json();
|
||||
activePricelistsBySource[source] = data.pricelists || [];
|
||||
const existing = selectedPricelistIds[source];
|
||||
if (existing && activePricelistsBySource[source].some(pl => Number(pl.id) === Number(existing))) {
|
||||
return;
|
||||
}
|
||||
selectedPricelistIds[source] = activePricelistsBySource[source].length > 0
|
||||
? Number(activePricelistsBySource[source][0].id)
|
||||
: null;
|
||||
} catch (e) {
|
||||
activePricelistsBySource[source] = [];
|
||||
selectedPricelistIds[source] = null;
|
||||
}
|
||||
}));
|
||||
}
|
||||
|
||||
function renderPricelistSelectOptions(selectId, source) {
|
||||
const select = document.getElementById(selectId);
|
||||
if (!select) return;
|
||||
const pricelists = activePricelistsBySource[source] || [];
|
||||
if (pricelists.length === 0) {
|
||||
select.innerHTML = '<option value="">Нет активных прайслистов</option>';
|
||||
select.value = '';
|
||||
return;
|
||||
}
|
||||
select.innerHTML = `<option value="">Авто (последний активный)</option>` + pricelists.map(pl => {
|
||||
return `<option value="${pl.id}">${escapeHtml(pl.version)}</option>`;
|
||||
}).join('');
|
||||
const current = selectedPricelistIds[source];
|
||||
select.value = current ? String(current) : '';
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/pricelists?active_only=true&per_page=200');
|
||||
const data = await resp.json();
|
||||
const pricelists = data.pricelists || [];
|
||||
|
||||
if (pricelists.length === 0) {
|
||||
select.innerHTML = '<option value="">Нет активных прайслистов</option>';
|
||||
selectedPricelistId = null;
|
||||
return;
|
||||
}
|
||||
|
||||
select.innerHTML = pricelists.map(pl => {
|
||||
return `<option value="${pl.id}">${pl.version}</option>`;
|
||||
}).join('');
|
||||
|
||||
const existing = selectedPricelistId && pricelists.some(pl => Number(pl.id) === Number(selectedPricelistId));
|
||||
if (existing) {
|
||||
select.value = String(selectedPricelistId);
|
||||
} else {
|
||||
selectedPricelistId = Number(pricelists[0].id);
|
||||
select.value = String(selectedPricelistId);
|
||||
}
|
||||
} catch (e) {
|
||||
select.innerHTML = '<option value="">Ошибка загрузки</option>';
|
||||
function syncPriceSettingsControls() {
|
||||
renderPricelistSelectOptions('settings-pricelist-estimate', 'estimate');
|
||||
renderPricelistSelectOptions('settings-pricelist-warehouse', 'warehouse');
|
||||
renderPricelistSelectOptions('settings-pricelist-competitor', 'competitor');
|
||||
const disableCheckbox = document.getElementById('settings-disable-price-refresh');
|
||||
if (disableCheckbox) {
|
||||
disableCheckbox.checked = disablePriceRefresh;
|
||||
}
|
||||
}
|
||||
|
||||
function updatePricelistSelection() {
|
||||
const select = document.getElementById('pricelist-select');
|
||||
const next = parseInt(select.value);
|
||||
selectedPricelistId = Number.isFinite(next) && next > 0 ? next : null;
|
||||
triggerAutoSave();
|
||||
function getPricelistVersionById(source, id) {
|
||||
const pricelists = activePricelistsBySource[source] || [];
|
||||
const found = pricelists.find(pl => Number(pl.id) === Number(id));
|
||||
return found ? found.version : null;
|
||||
}
|
||||
|
||||
function renderPricelistSettingsSummary() {
|
||||
const summary = document.getElementById('pricelist-settings-summary');
|
||||
if (!summary) return;
|
||||
const estimate = selectedPricelistIds.estimate ? getPricelistVersionById('estimate', selectedPricelistIds.estimate) || `ID ${selectedPricelistIds.estimate}` : 'авто';
|
||||
const warehouse = selectedPricelistIds.warehouse ? getPricelistVersionById('warehouse', selectedPricelistIds.warehouse) || `ID ${selectedPricelistIds.warehouse}` : 'авто';
|
||||
const competitor = selectedPricelistIds.competitor ? getPricelistVersionById('competitor', selectedPricelistIds.competitor) || `ID ${selectedPricelistIds.competitor}` : 'авто';
|
||||
const refreshState = disablePriceRefresh ? ' | Обновление цен: выкл' : '';
|
||||
summary.textContent = `Estimate: ${estimate}, Склад: ${warehouse}, Конкуренты: ${competitor}${refreshState}`;
|
||||
}
|
||||
|
||||
function updateRefreshPricesButtonState() {
|
||||
const refreshBtn = document.getElementById('refresh-prices-btn');
|
||||
if (!refreshBtn) return;
|
||||
if (disablePriceRefresh) {
|
||||
refreshBtn.disabled = true;
|
||||
refreshBtn.className = 'px-4 py-2 bg-gray-300 text-gray-500 rounded cursor-not-allowed';
|
||||
refreshBtn.title = 'Обновление цен отключено в настройках';
|
||||
} else {
|
||||
refreshBtn.disabled = false;
|
||||
refreshBtn.className = 'px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700';
|
||||
refreshBtn.title = '';
|
||||
}
|
||||
}
|
||||
|
||||
function getPriceSettingsStorageKey() {
|
||||
return `qf_price_settings_${configUUID || 'default'}`;
|
||||
}
|
||||
|
||||
function persistLocalPriceSettings() {
|
||||
try {
|
||||
localStorage.setItem(getPriceSettingsStorageKey(), JSON.stringify({
|
||||
pricelist_ids: selectedPricelistIds,
|
||||
disable_price_refresh: disablePriceRefresh
|
||||
}));
|
||||
} catch (e) {
|
||||
// ignore localStorage failures
|
||||
}
|
||||
}
|
||||
|
||||
function restoreLocalPriceSettings() {
|
||||
try {
|
||||
const raw = localStorage.getItem(getPriceSettingsStorageKey());
|
||||
if (!raw) return;
|
||||
const parsed = JSON.parse(raw);
|
||||
if (parsed && parsed.pricelist_ids) {
|
||||
['estimate', 'warehouse', 'competitor'].forEach(source => {
|
||||
const next = parseInt(parsed.pricelist_ids[source]);
|
||||
if (Number.isFinite(next) && next > 0) {
|
||||
selectedPricelistIds[source] = next;
|
||||
}
|
||||
});
|
||||
}
|
||||
disablePriceRefresh = Boolean(parsed?.disable_price_refresh);
|
||||
} catch (e) {
|
||||
// ignore invalid localStorage payload
|
||||
}
|
||||
}
|
||||
|
||||
async function openPriceSettingsModal() {
|
||||
await loadActivePricelists();
|
||||
syncPriceSettingsControls();
|
||||
renderPricelistSettingsSummary();
|
||||
document.getElementById('price-settings-modal')?.classList.remove('hidden');
|
||||
}
|
||||
|
||||
function closePriceSettingsModal() {
|
||||
document.getElementById('price-settings-modal')?.classList.add('hidden');
|
||||
}
|
||||
|
||||
function applyPriceSettings() {
|
||||
const estimateVal = parseInt(document.getElementById('settings-pricelist-estimate')?.value || '');
|
||||
const warehouseVal = parseInt(document.getElementById('settings-pricelist-warehouse')?.value || '');
|
||||
const competitorVal = parseInt(document.getElementById('settings-pricelist-competitor')?.value || '');
|
||||
const disableVal = Boolean(document.getElementById('settings-disable-price-refresh')?.checked);
|
||||
|
||||
selectedPricelistIds.estimate = Number.isFinite(estimateVal) && estimateVal > 0 ? estimateVal : null;
|
||||
selectedPricelistIds.warehouse = Number.isFinite(warehouseVal) && warehouseVal > 0 ? warehouseVal : null;
|
||||
selectedPricelistIds.competitor = Number.isFinite(competitorVal) && competitorVal > 0 ? competitorVal : null;
|
||||
disablePriceRefresh = disableVal;
|
||||
|
||||
updateRefreshPricesButtonState();
|
||||
renderPricelistSettingsSummary();
|
||||
persistLocalPriceSettings();
|
||||
closePriceSettingsModal();
|
||||
|
||||
refreshPriceLevels().then(() => {
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
triggerAutoSave();
|
||||
});
|
||||
}
|
||||
|
||||
function getCategoryFromLotName(lotName) {
|
||||
@@ -482,7 +799,7 @@ function renderSingleSelectTab(categories) {
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase w-24">Тип</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Цена</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Estimate</th>
|
||||
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase w-20">Кол-во</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-28">Стоимость</th>
|
||||
<th class="px-3 py-2 w-10"></th>
|
||||
@@ -499,8 +816,9 @@ function renderSingleSelectTab(categories) {
|
||||
|
||||
const comp = selectedItem ? allComponents.find(c => c.lot_name === selectedItem.lot_name) : null;
|
||||
const price = comp?.current_price || 0;
|
||||
const estimate = selectedItem?.estimate_price ?? price;
|
||||
const qty = selectedItem?.quantity || 1;
|
||||
const total = price * qty;
|
||||
const total = (selectedItem ? getDisplayPrice(selectedItem) : price) * qty;
|
||||
|
||||
html += `
|
||||
<tr class="hover:bg-gray-50">
|
||||
@@ -518,14 +836,14 @@ function renderSingleSelectTab(categories) {
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs" id="desc-${cat}">${escapeHtml(comp?.description || '')}</td>
|
||||
<td class="px-3 py-2 text-sm text-right" id="price-${cat}">${price ? '$' + price.toFixed(2) : '—'}</td>
|
||||
<td class="px-3 py-2 text-sm text-right" id="price-${cat}">${formatPriceOrNA(estimate)}</td>
|
||||
<td class="px-3 py-2 text-center">
|
||||
<input type="number" min="1" value="${qty}"
|
||||
id="qty-${cat}"
|
||||
onchange="updateSingleQuantity('${cat}', this.value)"
|
||||
class="w-16 px-2 py-1 border rounded text-center text-sm">
|
||||
</td>
|
||||
<td class="px-3 py-2 text-sm text-right font-medium" id="total-${cat}">${total ? '$' + total.toFixed(2) : '—'}</td>
|
||||
<td class="px-3 py-2 text-sm text-right font-medium" id="total-${cat}">${total ? formatMoney(total) : '—'}</td>
|
||||
<td class="px-3 py-2 text-center">
|
||||
${selectedItem ? `
|
||||
<button onclick="clearSingleSelect('${cat}')" class="text-red-500 hover:text-red-700">
|
||||
@@ -557,7 +875,7 @@ function renderMultiSelectTab(components) {
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Цена</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Estimate</th>
|
||||
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase w-20">Кол-во</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-28">Стоимость</th>
|
||||
<th class="px-3 py-2 w-10"></th>
|
||||
@@ -569,19 +887,19 @@ function renderMultiSelectTab(components) {
|
||||
// Render existing cart items for this tab
|
||||
tabItems.forEach((item, idx) => {
|
||||
const comp = allComponents.find(c => c.lot_name === item.lot_name);
|
||||
const total = item.unit_price * item.quantity;
|
||||
const total = getDisplayPrice(item) * item.quantity;
|
||||
|
||||
html += `
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-3 py-2 text-sm font-mono">${escapeHtml(item.lot_name)}</td>
|
||||
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td>
|
||||
<td class="px-3 py-2 text-sm text-right">$${item.unit_price.toFixed(2)}</td>
|
||||
<td class="px-3 py-2 text-sm text-right">${formatPriceOrNA(item.estimate_price ?? item.unit_price)}</td>
|
||||
<td class="px-3 py-2 text-center">
|
||||
<input type="number" min="1" value="${item.quantity}"
|
||||
onchange="updateMultiQuantity('${item.lot_name}', this.value)"
|
||||
class="w-16 px-2 py-1 border rounded text-center text-sm">
|
||||
</td>
|
||||
<td class="px-3 py-2 text-sm text-right font-medium">$${total.toFixed(2)}</td>
|
||||
<td class="px-3 py-2 text-sm text-right font-medium">${formatMoney(total)}</td>
|
||||
<td class="px-3 py-2 text-center">
|
||||
<button onclick="removeFromCart('${item.lot_name}')" class="text-red-500 hover:text-red-700">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -607,7 +925,7 @@ function renderMultiSelectTab(components) {
|
||||
onkeydown="handleAutocompleteKeyMulti(event)">
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-sm text-right text-gray-400" id="new-price">—</td>
|
||||
<td class="px-3 py-2 text-sm text-right text-gray-400" id="new-price">N/A</td>
|
||||
<td class="px-3 py-2 text-center">
|
||||
<input type="number" min="1" value="1" id="new-qty"
|
||||
class="w-16 px-2 py-1 border rounded text-center text-sm">
|
||||
@@ -658,7 +976,7 @@ function renderMultiSelectTabWithSections(sections) {
|
||||
<tr>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">LOT</th>
|
||||
<th class="px-3 py-2 text-left text-xs font-medium text-gray-500 uppercase">Описание</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Цена</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-24">Estimate</th>
|
||||
<th class="px-3 py-2 text-center text-xs font-medium text-gray-500 uppercase w-20">Кол-во</th>
|
||||
<th class="px-3 py-2 text-right text-xs font-medium text-gray-500 uppercase w-28">Стоимость</th>
|
||||
<th class="px-3 py-2 w-10"></th>
|
||||
@@ -670,19 +988,19 @@ function renderMultiSelectTabWithSections(sections) {
|
||||
// Render existing cart items for this section
|
||||
sectionItems.forEach((item) => {
|
||||
const comp = allComponents.find(c => c.lot_name === item.lot_name);
|
||||
const total = item.unit_price * item.quantity;
|
||||
const total = getDisplayPrice(item) * item.quantity;
|
||||
|
||||
html += `
|
||||
<tr class="hover:bg-gray-50">
|
||||
<td class="px-3 py-2 text-sm font-mono">${escapeHtml(item.lot_name)}</td>
|
||||
<td class="px-3 py-2 text-sm text-gray-500 truncate max-w-xs">${escapeHtml(item.description || comp?.description || '')}</td>
|
||||
<td class="px-3 py-2 text-sm text-right">$${item.unit_price.toFixed(2)}</td>
|
||||
<td class="px-3 py-2 text-sm text-right">${formatPriceOrNA(item.estimate_price ?? item.unit_price)}</td>
|
||||
<td class="px-3 py-2 text-center">
|
||||
<input type="number" min="1" value="${item.quantity}"
|
||||
onchange="updateMultiQuantity('${item.lot_name}', this.value)"
|
||||
class="w-16 px-2 py-1 border rounded text-center text-sm">
|
||||
</td>
|
||||
<td class="px-3 py-2 text-sm text-right font-medium">$${total.toFixed(2)}</td>
|
||||
<td class="px-3 py-2 text-sm text-right font-medium">${formatMoney(total)}</td>
|
||||
<td class="px-3 py-2 text-center">
|
||||
<button onclick="removeFromCart('${item.lot_name}')" class="text-red-500 hover:text-red-700">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
@@ -711,7 +1029,7 @@ function renderMultiSelectTabWithSections(sections) {
|
||||
onkeydown="handleAutocompleteKeySection(event, '${sectionId}')">
|
||||
</div>
|
||||
</td>
|
||||
<td class="px-3 py-2 text-sm text-right text-gray-400" id="new-price-${sectionId}">—</td>
|
||||
<td class="px-3 py-2 text-sm text-right text-gray-400" id="new-price-${sectionId}">N/A</td>
|
||||
<td class="px-3 py-2 text-center">
|
||||
<input type="number" min="1" value="1" id="new-qty-${sectionId}"
|
||||
class="w-16 px-2 py-1 border rounded text-center text-sm">
|
||||
@@ -833,14 +1151,26 @@ function selectAutocompleteItem(index) {
|
||||
lot_name: comp.lot_name,
|
||||
quantity: qty,
|
||||
unit_price: comp.current_price,
|
||||
estimate_price: comp.current_price,
|
||||
warehouse_price: null,
|
||||
competitor_price: null,
|
||||
delta_wh_estimate_abs: null,
|
||||
delta_wh_estimate_pct: null,
|
||||
delta_comp_estimate_abs: null,
|
||||
delta_comp_estimate_pct: null,
|
||||
delta_comp_wh_abs: null,
|
||||
delta_comp_wh_pct: null,
|
||||
price_missing: ['warehouse', 'competitor'],
|
||||
description: comp.description || '',
|
||||
category: getComponentCategory(comp)
|
||||
});
|
||||
|
||||
hideAutocomplete();
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
triggerAutoSave();
|
||||
refreshPriceLevels().then(() => {
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
triggerAutoSave();
|
||||
});
|
||||
}
|
||||
|
||||
function hideAutocomplete() {
|
||||
@@ -913,14 +1243,26 @@ function selectAutocompleteItemMulti(index) {
|
||||
lot_name: comp.lot_name,
|
||||
quantity: qty,
|
||||
unit_price: comp.current_price,
|
||||
estimate_price: comp.current_price,
|
||||
warehouse_price: null,
|
||||
competitor_price: null,
|
||||
delta_wh_estimate_abs: null,
|
||||
delta_wh_estimate_pct: null,
|
||||
delta_comp_estimate_abs: null,
|
||||
delta_comp_estimate_pct: null,
|
||||
delta_comp_wh_abs: null,
|
||||
delta_comp_wh_pct: null,
|
||||
price_missing: ['warehouse', 'competitor'],
|
||||
description: comp.description || '',
|
||||
category: getComponentCategory(comp)
|
||||
});
|
||||
|
||||
hideAutocomplete();
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
triggerAutoSave();
|
||||
refreshPriceLevels().then(() => {
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
triggerAutoSave();
|
||||
});
|
||||
}
|
||||
|
||||
// Autocomplete for sectioned tabs (like storage with RAID and Disks sections)
|
||||
@@ -1000,6 +1342,16 @@ function selectAutocompleteItemSection(index, sectionId) {
|
||||
lot_name: comp.lot_name,
|
||||
quantity: qty,
|
||||
unit_price: comp.current_price,
|
||||
estimate_price: comp.current_price,
|
||||
warehouse_price: null,
|
||||
competitor_price: null,
|
||||
delta_wh_estimate_abs: null,
|
||||
delta_wh_estimate_pct: null,
|
||||
delta_comp_estimate_abs: null,
|
||||
delta_comp_estimate_pct: null,
|
||||
delta_comp_wh_abs: null,
|
||||
delta_comp_wh_pct: null,
|
||||
price_missing: ['warehouse', 'competitor'],
|
||||
description: comp.description || '',
|
||||
category: getComponentCategory(comp)
|
||||
});
|
||||
@@ -1013,9 +1365,11 @@ function selectAutocompleteItemSection(index, sectionId) {
|
||||
// Reset quantity to 1
|
||||
if (qtyInput) qtyInput.value = '1';
|
||||
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
triggerAutoSave();
|
||||
refreshPriceLevels().then(() => {
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
triggerAutoSave();
|
||||
});
|
||||
}
|
||||
|
||||
function clearSingleSelect(category) {
|
||||
@@ -1054,7 +1408,7 @@ function updateMultiQuantity(lotName, value) {
|
||||
if (row) {
|
||||
const totalCell = row.querySelector('td:nth-child(5)');
|
||||
if (totalCell) {
|
||||
totalCell.textContent = '$' + (item.unit_price * item.quantity).toFixed(2);
|
||||
totalCell.textContent = formatMoney(getDisplayPrice(item) * item.quantity);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1068,11 +1422,12 @@ function removeFromCart(lotName) {
|
||||
}
|
||||
|
||||
function updateCartUI() {
|
||||
const total = cart.reduce((sum, item) => sum + (item.unit_price * item.quantity), 0);
|
||||
document.getElementById('cart-total').textContent = '$' + total.toLocaleString('en-US', {minimumFractionDigits: 2});
|
||||
const total = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
|
||||
document.getElementById('cart-total').textContent = formatMoney(total);
|
||||
|
||||
// Recalculate custom price section if active
|
||||
calculateCustomPrice();
|
||||
renderSalePriceTable();
|
||||
|
||||
if (cart.length === 0) {
|
||||
document.getElementById('cart-items').innerHTML =
|
||||
@@ -1116,7 +1471,7 @@ function updateCartUI() {
|
||||
html += `<div class="mb-2"><div class="text-xs font-medium text-gray-400 uppercase mb-1">${tabLabel}</div>`;
|
||||
|
||||
items.forEach(item => {
|
||||
const itemTotal = item.unit_price * item.quantity;
|
||||
const itemTotal = getDisplayPrice(item) * item.quantity;
|
||||
html += `
|
||||
<div class="flex justify-between items-center py-1 text-sm">
|
||||
<div class="flex-1">
|
||||
@@ -1125,7 +1480,7 @@ function updateCartUI() {
|
||||
<span>${item.quantity}</span>
|
||||
</div>
|
||||
<div class="flex items-center space-x-2">
|
||||
<span>$${itemTotal.toLocaleString('en-US', {minimumFractionDigits: 2})}</span>
|
||||
<span>${formatMoney(itemTotal)}</span>
|
||||
<button onclick="removeFromCart('${item.lot_name}')" class="text-red-500 hover:text-red-700">
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
@@ -1183,7 +1538,7 @@ async function saveConfig(showNotification = true) {
|
||||
custom_price: customPrice,
|
||||
notes: '',
|
||||
server_count: serverCountValue,
|
||||
pricelist_id: selectedPricelistId
|
||||
pricelist_id: selectedPricelistIds.estimate
|
||||
})
|
||||
});
|
||||
|
||||
@@ -1226,12 +1581,143 @@ async function exportCSV() {
|
||||
}
|
||||
}
|
||||
|
||||
function formatLineTotalTooltip(qty, unitPrice) {
|
||||
if (typeof unitPrice !== 'number' || unitPrice <= 0) return '';
|
||||
const lineTotal = qty * unitPrice;
|
||||
return `${formatNumberRu(qty)} * ${formatMoney(unitPrice)} = ${formatMoney(lineTotal)}`;
|
||||
}
|
||||
|
||||
function toggleSection(contentId, iconId) {
|
||||
const content = document.getElementById(contentId);
|
||||
const icon = document.getElementById(iconId);
|
||||
if (!content || !icon) return;
|
||||
|
||||
const isHidden = content.classList.toggle('hidden');
|
||||
if (isHidden) {
|
||||
icon.classList.add('-rotate-90');
|
||||
} else {
|
||||
icon.classList.remove('-rotate-90');
|
||||
}
|
||||
}
|
||||
|
||||
function toggleCartSummarySection() {
|
||||
toggleSection('cart-summary-content', 'cart-summary-toggle-icon');
|
||||
}
|
||||
|
||||
function toggleCustomPriceSection() {
|
||||
toggleSection('custom-price-content', 'custom-price-toggle-icon');
|
||||
}
|
||||
|
||||
function toggleSalePriceSection() {
|
||||
toggleSection('sale-price-content', 'sale-price-toggle-icon');
|
||||
}
|
||||
|
||||
function formatDiffPercent(baseTotal, compareTotal, compareLabel) {
|
||||
if (typeof baseTotal !== 'number' || typeof compareTotal !== 'number' || compareTotal <= 0) {
|
||||
return `N/A от ${compareLabel}`;
|
||||
}
|
||||
const pct = ((baseTotal - compareTotal) / compareTotal) * 100;
|
||||
const sign = pct > 0 ? '+' : '';
|
||||
return `${sign}${pct.toFixed(1)}% от ${compareLabel}`;
|
||||
}
|
||||
|
||||
function getTotalClass(current, references) {
|
||||
const validRefs = references.filter(v => typeof v === 'number' && v > 0);
|
||||
if (typeof current !== 'number' || current <= 0 || validRefs.length === 0) {
|
||||
return 'text-gray-900';
|
||||
}
|
||||
const avg = validRefs.reduce((sum, v) => sum + v, 0) / validRefs.length;
|
||||
if (current < avg) return 'text-green-600';
|
||||
if (current > avg) return 'text-red-600';
|
||||
return 'text-gray-900';
|
||||
}
|
||||
|
||||
function renderSalePriceTable() {
|
||||
const body = document.getElementById('sale-prices-body');
|
||||
const totalEstEl = document.getElementById('sale-total-est');
|
||||
const totalWarehouseEl = document.getElementById('sale-total-warehouse');
|
||||
const totalCompetitorEl = document.getElementById('sale-total-competitor');
|
||||
if (!body || !totalEstEl || !totalWarehouseEl || !totalCompetitorEl) return;
|
||||
|
||||
if (cart.length === 0) {
|
||||
body.innerHTML = '<tr><td colspan="5" class="px-3 py-3 text-center text-gray-500">Конфигурация пуста</td></tr>';
|
||||
totalEstEl.textContent = '$0.00';
|
||||
totalWarehouseEl.textContent = '$0.00';
|
||||
totalCompetitorEl.textContent = '$0.00';
|
||||
totalEstEl.title = '';
|
||||
totalWarehouseEl.title = '';
|
||||
totalCompetitorEl.title = '';
|
||||
totalEstEl.className = 'px-3 py-2 text-right';
|
||||
totalWarehouseEl.className = 'px-3 py-2 text-right';
|
||||
totalCompetitorEl.className = 'px-3 py-2 text-right';
|
||||
return;
|
||||
}
|
||||
|
||||
const sortedCart = [...cart].sort((a, b) => {
|
||||
const catA = (a.category || getCategoryFromLotName(a.lot_name)).toUpperCase();
|
||||
const catB = (b.category || getCategoryFromLotName(b.lot_name)).toUpperCase();
|
||||
const orderA = categoryOrderMap[catA] || 9999;
|
||||
const orderB = categoryOrderMap[catB] || 9999;
|
||||
return orderA - orderB;
|
||||
});
|
||||
|
||||
let html = '';
|
||||
let totalEstimate = 0;
|
||||
let totalWarehouse = 0;
|
||||
let totalCompetitor = 0;
|
||||
|
||||
sortedCart.forEach(item => {
|
||||
const qty = item.quantity || 1;
|
||||
const estPrice = item.estimate_price;
|
||||
const warehousePrice = item.warehouse_price;
|
||||
const competitorPrice = item.competitor_price;
|
||||
|
||||
if (typeof estPrice === 'number' && estPrice > 0) totalEstimate += estPrice * qty;
|
||||
if (typeof warehousePrice === 'number' && warehousePrice > 0) totalWarehouse += warehousePrice * qty;
|
||||
if (typeof competitorPrice === 'number' && competitorPrice > 0) totalCompetitor += competitorPrice * qty;
|
||||
|
||||
const estTooltip = formatLineTotalTooltip(qty, estPrice);
|
||||
const warehouseTooltip = formatLineTotalTooltip(qty, warehousePrice);
|
||||
const competitorTooltip = formatLineTotalTooltip(qty, competitorPrice);
|
||||
|
||||
html += `
|
||||
<tr>
|
||||
<td class="px-3 py-2 font-mono">${escapeHtml(item.lot_name)}</td>
|
||||
<td class="px-3 py-2 text-right">${qty}</td>
|
||||
<td class="px-3 py-2 text-right" title="${escapeHtml(estTooltip)}">${formatPriceOrNA(estPrice)}</td>
|
||||
<td class="px-3 py-2 text-right" title="${escapeHtml(warehouseTooltip)}">${formatPriceOrNA(warehousePrice)}</td>
|
||||
<td class="px-3 py-2 text-right" title="${escapeHtml(competitorTooltip)}">${formatPriceOrNA(competitorPrice)}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
body.innerHTML = html;
|
||||
|
||||
totalEstEl.textContent = formatMoney(totalEstimate);
|
||||
totalWarehouseEl.textContent = formatMoney(totalWarehouse);
|
||||
totalCompetitorEl.textContent = formatMoney(totalCompetitor);
|
||||
|
||||
totalEstEl.title =
|
||||
`${formatDiffPercent(totalEstimate, totalWarehouse, 'склад')}\n` +
|
||||
`${formatDiffPercent(totalEstimate, totalCompetitor, 'конкуренты')}`;
|
||||
totalWarehouseEl.title =
|
||||
`${formatDiffPercent(totalWarehouse, totalEstimate, 'est.price')}\n` +
|
||||
`${formatDiffPercent(totalWarehouse, totalCompetitor, 'конкуренты')}`;
|
||||
totalCompetitorEl.title =
|
||||
`${formatDiffPercent(totalCompetitor, totalEstimate, 'est.price')}\n` +
|
||||
`${formatDiffPercent(totalCompetitor, totalWarehouse, 'склад')}`;
|
||||
|
||||
totalEstEl.className = `px-3 py-2 text-right ${getTotalClass(totalEstimate, [totalWarehouse, totalCompetitor])}`;
|
||||
totalWarehouseEl.className = `px-3 py-2 text-right ${getTotalClass(totalWarehouse, [totalEstimate, totalCompetitor])}`;
|
||||
totalCompetitorEl.className = `px-3 py-2 text-right ${getTotalClass(totalCompetitor, [totalEstimate, totalWarehouse])}`;
|
||||
}
|
||||
|
||||
// Custom price functionality
|
||||
function calculateCustomPrice() {
|
||||
const customPriceInput = document.getElementById('custom-price-input');
|
||||
const customPrice = parseFloat(customPriceInput.value) || 0;
|
||||
|
||||
const originalTotal = cart.reduce((sum, item) => sum + (item.unit_price * item.quantity), 0);
|
||||
const originalTotal = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
|
||||
|
||||
if (customPrice <= 0 || cart.length === 0 || originalTotal <= 0) {
|
||||
document.getElementById('adjusted-prices').classList.add('hidden');
|
||||
@@ -1272,7 +1758,7 @@ function calculateCustomPrice() {
|
||||
let totalNew = 0;
|
||||
|
||||
sortedCart.forEach(item => {
|
||||
const originalPrice = item.unit_price;
|
||||
const originalPrice = getDisplayPrice(item);
|
||||
const newPrice = originalPrice * coefficient;
|
||||
const itemOriginalTotal = originalPrice * item.quantity;
|
||||
const itemNewTotal = newPrice * item.quantity;
|
||||
@@ -1284,17 +1770,17 @@ function calculateCustomPrice() {
|
||||
<tr>
|
||||
<td class="px-3 py-2 font-mono">${escapeHtml(item.lot_name)}</td>
|
||||
<td class="px-3 py-2 text-right">${item.quantity}</td>
|
||||
<td class="px-3 py-2 text-right text-gray-500">$${originalPrice.toFixed(2)}</td>
|
||||
<td class="px-3 py-2 text-right text-green-600">$${newPrice.toFixed(2)}</td>
|
||||
<td class="px-3 py-2 text-right">$${itemNewTotal.toFixed(2)}</td>
|
||||
<td class="px-3 py-2 text-right text-gray-500">${formatMoney(originalPrice)}</td>
|
||||
<td class="px-3 py-2 text-right text-green-600">${formatMoney(newPrice)}</td>
|
||||
<td class="px-3 py-2 text-right">${formatMoney(itemNewTotal)}</td>
|
||||
</tr>
|
||||
`;
|
||||
});
|
||||
|
||||
document.getElementById('adjusted-prices-body').innerHTML = html;
|
||||
document.getElementById('adjusted-total-original').textContent = '$' + totalOriginal.toFixed(2);
|
||||
document.getElementById('adjusted-total-new').textContent = '$' + totalNew.toFixed(2);
|
||||
document.getElementById('adjusted-total-final').textContent = '$' + totalNew.toFixed(2);
|
||||
document.getElementById('adjusted-total-original').textContent = formatMoney(totalOriginal);
|
||||
document.getElementById('adjusted-total-new').textContent = formatMoney(totalNew);
|
||||
document.getElementById('adjusted-total-final').textContent = formatMoney(totalNew);
|
||||
document.getElementById('adjusted-prices').classList.remove('hidden');
|
||||
}
|
||||
|
||||
@@ -1309,7 +1795,7 @@ async function exportCSVWithCustomPrice() {
|
||||
if (cart.length === 0) return;
|
||||
|
||||
const customPrice = parseFloat(document.getElementById('custom-price-input').value) || 0;
|
||||
const originalTotal = cart.reduce((sum, item) => sum + (item.unit_price * item.quantity), 0);
|
||||
const originalTotal = cart.reduce((sum, item) => sum + (getDisplayPrice(item) * item.quantity), 0);
|
||||
|
||||
if (customPrice <= 0 || originalTotal <= 0) {
|
||||
showToast('Введите целевую цену', 'error');
|
||||
@@ -1321,7 +1807,7 @@ async function exportCSVWithCustomPrice() {
|
||||
// Create adjusted cart
|
||||
const adjustedCart = cart.map(item => ({
|
||||
...item,
|
||||
unit_price: parseFloat((item.unit_price * coefficient).toFixed(2))
|
||||
unit_price: parseFloat((getDisplayPrice(item) * coefficient).toFixed(2))
|
||||
}));
|
||||
|
||||
try {
|
||||
@@ -1346,6 +1832,10 @@ async function exportCSVWithCustomPrice() {
|
||||
async function refreshPrices() {
|
||||
// RBAC disabled - no token check required
|
||||
if (!configUUID) return;
|
||||
if (disablePriceRefresh) {
|
||||
showToast('Обновление цен отключено в настройках', 'error');
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const resp = await fetch('/api/configs/' + configUUID + '/refresh-prices', {
|
||||
@@ -1368,6 +1858,9 @@ async function refreshPrices() {
|
||||
lot_name: item.lot_name,
|
||||
quantity: item.quantity,
|
||||
unit_price: item.unit_price,
|
||||
estimate_price: item.unit_price,
|
||||
warehouse_price: null,
|
||||
competitor_price: null,
|
||||
description: item.description || '',
|
||||
category: item.category || getCategoryFromLotName(item.lot_name)
|
||||
}));
|
||||
@@ -1378,18 +1871,17 @@ async function refreshPrices() {
|
||||
updatePriceUpdateDate(config.price_updated_at);
|
||||
}
|
||||
if (config.pricelist_id) {
|
||||
selectedPricelistId = config.pricelist_id;
|
||||
const select = document.getElementById('pricelist-select');
|
||||
if (select) {
|
||||
const hasOption = Array.from(select.options).some(opt => Number(opt.value) === Number(selectedPricelistId));
|
||||
if (!hasOption) {
|
||||
await loadActivePricelists();
|
||||
}
|
||||
select.value = String(selectedPricelistId);
|
||||
selectedPricelistIds.estimate = config.pricelist_id;
|
||||
if (!activePricelistsBySource.estimate.some(opt => Number(opt.id) === Number(config.pricelist_id))) {
|
||||
await loadActivePricelists();
|
||||
}
|
||||
syncPriceSettingsControls();
|
||||
renderPricelistSettingsSummary();
|
||||
persistLocalPriceSettings();
|
||||
}
|
||||
|
||||
// Re-render UI
|
||||
await refreshPriceLevels();
|
||||
renderTab();
|
||||
updateCartUI();
|
||||
|
||||
|
||||
@@ -21,6 +21,11 @@
|
||||
Импорт квоты
|
||||
</button>
|
||||
</div>
|
||||
<div class="mt-2">
|
||||
<a id="tracker-link" href="https://tracker.yandex.ru/OPS-1933" target="_blank" rel="noopener noreferrer" class="text-sm text-blue-600 hover:text-blue-800 hover:underline">
|
||||
открыть в трекере
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<div class="mt-4 inline-flex rounded-lg border border-gray-200 overflow-hidden">
|
||||
<button id="status-active-btn" onclick="setConfigStatusMode('active')" class="px-4 py-2 text-sm font-medium bg-blue-600 text-white">
|
||||
@@ -120,6 +125,12 @@ function escapeHtml(text) {
|
||||
return div.innerHTML;
|
||||
}
|
||||
|
||||
function resolveProjectTrackerURL(projectData) {
|
||||
if (!projectData) return '';
|
||||
const explicitURL = (projectData.tracker_url || '').trim();
|
||||
return explicitURL;
|
||||
}
|
||||
|
||||
function setConfigStatusMode(mode) {
|
||||
if (mode !== 'active' && mode !== 'archived') return;
|
||||
configStatusMode = mode;
|
||||
@@ -218,6 +229,20 @@ async function loadProject() {
|
||||
}
|
||||
project = await resp.json();
|
||||
document.getElementById('project-title').textContent = project.name;
|
||||
const trackerLink = document.getElementById('tracker-link');
|
||||
if (trackerLink) {
|
||||
if (project && project.is_system) {
|
||||
trackerLink.classList.add('hidden');
|
||||
return true;
|
||||
}
|
||||
const trackerURL = resolveProjectTrackerURL(project);
|
||||
if (trackerURL) {
|
||||
trackerLink.href = trackerURL;
|
||||
trackerLink.classList.remove('hidden');
|
||||
} else {
|
||||
trackerLink.classList.add('hidden');
|
||||
}
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
|
||||
@@ -8,7 +8,7 @@
|
||||
<a href="/configs" class="px-4 py-2 bg-gray-200 text-gray-800 rounded hover:bg-gray-300">
|
||||
Все конфигурации
|
||||
</a>
|
||||
<button onclick="createProject()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
<button onclick="openCreateProjectModal()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">
|
||||
+ Новый проект
|
||||
</button>
|
||||
</div>
|
||||
@@ -27,6 +27,28 @@
|
||||
<div id="projects-table" class="bg-white rounded-lg shadow p-4 text-gray-500">Загрузка...</div>
|
||||
</div>
|
||||
|
||||
<div id="create-project-modal" class="fixed inset-0 bg-black bg-opacity-50 hidden items-center justify-center z-50">
|
||||
<div class="bg-white rounded-lg shadow-xl w-full max-w-md mx-4 p-6">
|
||||
<h2 class="text-xl font-semibold mb-4">Новый проект</h2>
|
||||
<div class="space-y-4">
|
||||
<div>
|
||||
<label for="create-project-code" class="block text-sm font-medium text-gray-700 mb-1">Код проекта</label>
|
||||
<input id="create-project-code" type="text" placeholder="Например: OPS-123"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
<div>
|
||||
<label for="create-project-tracker-url" class="block text-sm font-medium text-gray-700 mb-1">Ссылка на трекер</label>
|
||||
<input id="create-project-tracker-url" type="url" placeholder="https://tracker.yandex.ru/OPS-123"
|
||||
class="w-full px-3 py-2 border rounded focus:ring-2 focus:ring-blue-500 focus:border-blue-500">
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex justify-end gap-2 mt-6">
|
||||
<button type="button" onclick="closeCreateProjectModal()" class="px-4 py-2 text-gray-600 hover:text-gray-800">Отмена</button>
|
||||
<button type="button" onclick="createProject()" class="px-4 py-2 bg-blue-600 text-white rounded hover:bg-blue-700">Создать</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<script>
|
||||
let status = 'active';
|
||||
let projectsSearch = '';
|
||||
@@ -35,6 +57,10 @@ let currentPage = 1;
|
||||
let perPage = 10;
|
||||
let sortField = 'created_at';
|
||||
let sortDir = 'desc';
|
||||
let createProjectTrackerManuallyEdited = false;
|
||||
let createProjectLastAutoTrackerURL = '';
|
||||
|
||||
const trackerBaseURL = 'https://tracker.yandex.ru/';
|
||||
|
||||
function escapeHtml(text) {
|
||||
const div = document.createElement('div');
|
||||
@@ -218,18 +244,60 @@ function goToPage(page) {
|
||||
loadProjects();
|
||||
}
|
||||
|
||||
function buildTrackerURLFromProjectCode(projectCode) {
|
||||
const code = (projectCode || '').trim();
|
||||
if (!code) return '';
|
||||
return trackerBaseURL + encodeURIComponent(code);
|
||||
}
|
||||
|
||||
function openCreateProjectModal() {
|
||||
const codeInput = document.getElementById('create-project-code');
|
||||
const trackerInput = document.getElementById('create-project-tracker-url');
|
||||
codeInput.value = '';
|
||||
trackerInput.value = '';
|
||||
createProjectTrackerManuallyEdited = false;
|
||||
createProjectLastAutoTrackerURL = '';
|
||||
document.getElementById('create-project-modal').classList.remove('hidden');
|
||||
document.getElementById('create-project-modal').classList.add('flex');
|
||||
codeInput.focus();
|
||||
}
|
||||
|
||||
function closeCreateProjectModal() {
|
||||
document.getElementById('create-project-modal').classList.add('hidden');
|
||||
document.getElementById('create-project-modal').classList.remove('flex');
|
||||
}
|
||||
|
||||
function updateCreateProjectTrackerURL() {
|
||||
const codeInput = document.getElementById('create-project-code');
|
||||
const trackerInput = document.getElementById('create-project-tracker-url');
|
||||
const generatedURL = buildTrackerURLFromProjectCode(codeInput.value);
|
||||
if (!createProjectTrackerManuallyEdited || trackerInput.value.trim() === '' || trackerInput.value === createProjectLastAutoTrackerURL) {
|
||||
trackerInput.value = generatedURL;
|
||||
createProjectLastAutoTrackerURL = generatedURL;
|
||||
}
|
||||
}
|
||||
|
||||
async function createProject() {
|
||||
const name = prompt('Название проекта');
|
||||
if (!name || !name.trim()) return;
|
||||
const codeInput = document.getElementById('create-project-code');
|
||||
const trackerInput = document.getElementById('create-project-tracker-url');
|
||||
const name = (codeInput.value || '').trim();
|
||||
if (!name) {
|
||||
alert('Введите код проекта');
|
||||
return;
|
||||
}
|
||||
const resp = await fetch('/api/projects', {
|
||||
method: 'POST',
|
||||
headers: {'Content-Type': 'application/json'},
|
||||
body: JSON.stringify({name: name.trim()})
|
||||
body: JSON.stringify({
|
||||
name: name,
|
||||
tracker_url: (trackerInput.value || '').trim()
|
||||
})
|
||||
});
|
||||
if (!resp.ok) {
|
||||
alert('Не удалось создать проект');
|
||||
return;
|
||||
}
|
||||
closeCreateProjectModal();
|
||||
loadProjects();
|
||||
}
|
||||
|
||||
@@ -324,6 +392,34 @@ document.getElementById('projects-search').addEventListener('input', function(e)
|
||||
currentPage = 1;
|
||||
loadProjects();
|
||||
});
|
||||
|
||||
document.getElementById('create-project-code').addEventListener('input', function() {
|
||||
updateCreateProjectTrackerURL();
|
||||
});
|
||||
|
||||
document.getElementById('create-project-tracker-url').addEventListener('input', function(e) {
|
||||
createProjectTrackerManuallyEdited = (e.target.value || '').trim() !== createProjectLastAutoTrackerURL;
|
||||
});
|
||||
|
||||
document.getElementById('create-project-code').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
createProject();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('create-project-tracker-url').addEventListener('keydown', function(e) {
|
||||
if (e.key === 'Enter') {
|
||||
e.preventDefault();
|
||||
createProject();
|
||||
}
|
||||
});
|
||||
|
||||
document.getElementById('create-project-modal').addEventListener('click', function(e) {
|
||||
if (e.target === this) {
|
||||
closeCreateProjectModal();
|
||||
}
|
||||
});
|
||||
</script>
|
||||
{{end}}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user