Remove admin pricing stack and prepare v1.0.4 release

This commit is contained in:
2026-02-07 21:23:23 +03:00
parent 95b5f8bf65
commit 7523a7d887
28 changed files with 611 additions and 7584 deletions

View File

@@ -3,8 +3,10 @@ package handlers
import (
"net/http"
"strconv"
"strings"
"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"
"github.com/gin-gonic/gin"
@@ -25,6 +27,12 @@ func NewComponentHandler(componentService *services.ComponentService, localDB *l
func (h *ComponentHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 20
}
filter := repository.ComponentFilter{
Category: c.Query("category"),
@@ -33,73 +41,70 @@ func (h *ComponentHandler) List(c *gin.Context) {
ExcludeHidden: c.Query("include_hidden") != "true", // По умолчанию скрытые не показываются
}
result, err := h.componentService.List(filter, page, perPage)
localFilter := localdb.ComponentFilter{
Category: filter.Category,
Search: filter.Search,
HasPrice: filter.HasPrice,
}
offset := (page - 1) * perPage
localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// If offline mode (empty result), fallback to local components
isOffline := false
if v, ok := c.Get("is_offline"); ok {
if b, ok := v.(bool); ok {
isOffline = b
}
}
if isOffline && result.Total == 0 && h.localDB != nil {
localFilter := localdb.ComponentFilter{
Category: filter.Category,
Search: filter.Search,
HasPrice: filter.HasPrice,
}
offset := (page - 1) * perPage
localComps, total, err := h.localDB.ListComponents(localFilter, offset, perPage)
if err == nil && len(localComps) > 0 {
// Convert local components to ComponentView format
components := make([]services.ComponentView, len(localComps))
for i, lc := range localComps {
components[i] = services.ComponentView{
LotName: lc.LotName,
Description: lc.LotDescription,
Category: lc.Category,
CategoryName: lc.Category, // No translation in local mode
Model: lc.Model,
CurrentPrice: lc.CurrentPrice,
}
}
c.JSON(http.StatusOK, &services.ComponentListResult{
Components: components,
Total: total,
Page: page,
PerPage: perPage,
})
return
components := make([]services.ComponentView, len(localComps))
for i, lc := range localComps {
components[i] = services.ComponentView{
LotName: lc.LotName,
Description: lc.LotDescription,
Category: lc.Category,
CategoryName: lc.Category,
Model: lc.Model,
CurrentPrice: lc.CurrentPrice,
}
}
c.JSON(http.StatusOK, result)
c.JSON(http.StatusOK, &services.ComponentListResult{
Components: components,
Total: total,
Page: page,
PerPage: perPage,
})
}
func (h *ComponentHandler) Get(c *gin.Context) {
lotName := c.Param("lot_name")
component, err := h.componentService.GetByLotName(lotName)
component, err := h.localDB.GetLocalComponent(lotName)
if err != nil {
c.JSON(http.StatusNotFound, gin.H{"error": "component not found"})
return
}
c.JSON(http.StatusOK, component)
c.JSON(http.StatusOK, services.ComponentView{
LotName: component.LotName,
Description: component.LotDescription,
Category: component.Category,
CategoryName: component.Category,
Model: component.Model,
CurrentPrice: component.CurrentPrice,
})
}
func (h *ComponentHandler) GetCategories(c *gin.Context) {
categories, err := h.componentService.GetCategories()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
codes, err := h.localDB.GetLocalComponentCategories()
if err == nil && len(codes) > 0 {
categories := make([]models.Category, 0, len(codes))
for _, code := range codes {
trimmed := strings.TrimSpace(code)
if trimmed == "" {
continue
}
categories = append(categories, models.Category{Code: trimmed, Name: trimmed})
}
c.JSON(http.StatusOK, categories)
return
}
c.JSON(http.StatusOK, categories)
c.JSON(http.StatusOK, models.DefaultCategories)
}

View File

@@ -1,121 +1,25 @@
package handlers
import (
"errors"
"fmt"
"io"
"net/http"
"sort"
"strconv"
"strings"
"time"
"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"
)
type PricelistHandler struct {
service *pricelist.Service
localDB *localdb.LocalDB
}
func NewPricelistHandler(service *pricelist.Service, localDB *localdb.LocalDB) *PricelistHandler {
return &PricelistHandler{service: service, localDB: localDB}
func NewPricelistHandler(localDB *localdb.LocalDB) *PricelistHandler {
return &PricelistHandler{localDB: localDB}
}
// refreshLocalPricelistCacheFromServer rehydrates local metadata + items for one server pricelist.
func (h *PricelistHandler) refreshLocalPricelistCacheFromServer(serverID uint, onProgress func(synced, total int, message string)) error {
if h.localDB == nil {
return nil
}
report := func(synced, total int, message string) {
if onProgress != nil {
onProgress(synced, total, message)
}
}
report(0, 0, "Подготовка локального кэша")
pl, err := h.service.GetByID(serverID)
if err != nil {
return err
}
if existing, err := h.localDB.GetLocalPricelistByServerID(serverID); err == nil {
if err := h.localDB.DeleteLocalPricelist(existing.ID); err != nil {
return err
}
}
localPL := &localdb.LocalPricelist{
ServerID: pl.ID,
Source: pl.Source,
Version: pl.Version,
Name: pl.Notification,
CreatedAt: pl.CreatedAt,
SyncedAt: time.Now(),
IsUsed: false,
}
if err := h.localDB.SaveLocalPricelist(localPL); err != nil {
return err
}
report(0, 0, "Локальный кэш обновлён")
// Ensure we use persisted local row id (upsert path may not populate struct ID reliably).
persistedLocalPL, err := h.localDB.GetLocalPricelistByServerID(serverID)
if err != nil {
return err
}
if persistedLocalPL.ID == 0 {
return fmt.Errorf("local pricelist id is zero after save (server_id=%d)", serverID)
}
const perPage = 2000
synced := 0
totalItems := 0
gotTotal := false
for page := 1; ; page++ {
items, total, err := h.service.GetItems(serverID, page, perPage, "")
if err != nil {
return err
}
if !gotTotal {
totalItems = int(total)
gotTotal = true
}
if len(items) == 0 {
break
}
localItems := make([]localdb.LocalPricelistItem, 0, len(items))
for _, item := range items {
partnumbers := make(localdb.LocalStringList, 0, len(item.Partnumbers))
partnumbers = append(partnumbers, item.Partnumbers...)
localItems = append(localItems, localdb.LocalPricelistItem{
PricelistID: persistedLocalPL.ID,
LotName: item.LotName,
Price: item.Price,
AvailableQty: item.AvailableQty,
Partnumbers: partnumbers,
})
}
if err := h.localDB.SaveLocalPricelistItems(localItems); err != nil {
return err
}
synced += len(localItems)
report(synced, totalItems, "Синхронизация позиций в локальный кэш")
if int64(page*perPage) >= total {
break
}
}
report(synced, totalItems, "Локальный кэш синхронизирован")
return nil
}
// List returns all pricelists with pagination
// List returns all pricelists with pagination.
func (h *PricelistHandler) List(c *gin.Context) {
page, _ := strconv.Atoi(c.DefaultQuery("page", "1"))
perPage, _ := strconv.Atoi(c.DefaultQuery("per_page", "20"))
@@ -184,7 +88,7 @@ func (h *PricelistHandler) List(c *gin.Context) {
})
}
// Get returns a single pricelist by ID
// Get returns a single pricelist by ID.
func (h *PricelistHandler) Get(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
@@ -211,299 +115,7 @@ func (h *PricelistHandler) Get(c *gin.Context) {
})
}
// Create creates a new pricelist from current prices
func (h *PricelistHandler) Create(c *gin.Context) {
canWrite, debugInfo := h.service.CanWriteDebug()
if !canWrite {
c.JSON(http.StatusForbidden, gin.H{
"error": "pricelist write is not allowed",
"debug": debugInfo,
})
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.CreateForSourceWithProgress(createdBy, source, sourceItems, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
// Keep local cache consistent for local-first reads (metadata + items).
if err := h.refreshLocalPricelistCacheFromServer(pl.ID, nil); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "pricelist created on server but failed to refresh local cache: " + err.Error(),
})
return
}
c.JSON(http.StatusCreated, pl)
}
// CreateWithProgress creates a pricelist and streams progress updates over SSE.
func (h *PricelistHandler) CreateWithProgress(c *gin.Context) {
canWrite, debugInfo := h.service.CanWriteDebug()
if !canWrite {
c.JSON(http.StatusForbidden, gin.H{
"error": "pricelist write is not allowed",
"debug": debugInfo,
})
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")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
flusher, ok := c.Writer.(http.Flusher)
if !ok {
pl, err := h.service.CreateForSourceWithProgress(createdBy, source, sourceItems, nil)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()})
return
}
c.JSON(http.StatusCreated, pl)
return
}
sendProgress := func(payload gin.H) {
c.SSEvent("progress", payload)
flusher.Flush()
}
sendProgress(gin.H{"current": 0, "total": 4, "status": "starting", "message": "Запуск..."})
pl, err := h.service.CreateForSourceWithProgress(createdBy, source, sourceItems, func(p pricelist.CreateProgress) {
// Composite progress: 0-85% server creation, 86-99% local cache sync.
current := int(float64(p.Current) * 0.85)
if p.Status == "completed" {
current = 85
}
status := p.Status
if status == "completed" {
status = "server_completed"
}
sendProgress(gin.H{
"current": current,
"total": p.Total,
"status": status,
"message": p.Message,
"updated": p.Updated,
"errors": p.Errors,
"lot_name": p.LotName,
})
})
if err != nil {
sendProgress(gin.H{
"current": 0,
"total": 4,
"status": "error",
"message": fmt.Sprintf("Ошибка: %v", err),
})
return
}
if err := h.refreshLocalPricelistCacheFromServer(pl.ID, func(synced, total int, message string) {
current := 86
if total > 0 {
progressPart := int(float64(synced) / float64(total) * 13.0) // 86..99
if progressPart > 13 {
progressPart = 13
}
current = 86 + progressPart
}
if current > 99 {
current = 99
}
sendProgress(gin.H{
"current": current,
"total": 100,
"status": "sync_local_cache",
"message": message,
})
}); err != nil {
sendProgress(gin.H{
"current": 4,
"total": 4,
"status": "error",
"message": fmt.Sprintf("Прайслист создан, но локальный кэш не обновлён: %v", err),
})
return
}
sendProgress(gin.H{
"current": 4,
"total": 4,
"status": "completed",
"message": "Готово",
"pricelist": pl,
})
}
// Delete deletes a pricelist by ID
func (h *PricelistHandler) Delete(c *gin.Context) {
canWrite, debugInfo := h.service.CanWriteDebug()
if !canWrite {
c.JSON(http.StatusForbidden, gin.H{
"error": "pricelist write is not allowed",
"debug": debugInfo,
})
return
}
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
return
}
if err := h.service.Delete(uint(id)); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Local-first UI reads pricelists from SQLite cache. Keep cache in sync right away.
if h.localDB != nil {
if localPL, err := h.localDB.GetLocalPricelistByServerID(uint(id)); err == nil {
if err := h.localDB.DeleteLocalPricelist(localPL.ID); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "pricelist deleted on server but failed to update local cache: " + err.Error(),
})
return
}
}
}
c.JSON(http.StatusOK, gin.H{"message": "pricelist deleted"})
}
// SetActive toggles active flag on a pricelist.
func (h *PricelistHandler) SetActive(c *gin.Context) {
canWrite, debugInfo := h.service.CanWriteDebug()
if !canWrite {
c.JSON(http.StatusForbidden, gin.H{
"error": "pricelist write is not allowed",
"debug": debugInfo,
})
return
}
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
if err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "invalid pricelist ID"})
return
}
var req struct {
IsActive bool `json:"is_active"`
}
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
if err := h.service.SetActive(uint(id), req.IsActive); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
return
}
// Local-first table stores only active snapshots. Reflect toggles immediately.
if h.localDB != nil {
localPL, err := h.localDB.GetLocalPricelistByServerID(uint(id))
if err == nil {
if req.IsActive {
// Ensure local active row has complete cache (metadata + items).
if h.localDB.CountLocalPricelistItems(localPL.ID) == 0 {
if err := h.refreshLocalPricelistCacheFromServer(uint(id), nil); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "updated on server but failed to refresh local cache: " + err.Error(),
})
return
}
} else {
localPL.SyncedAt = time.Now()
if saveErr := h.localDB.SaveLocalPricelist(localPL); saveErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "updated on server but failed to update local cache: " + saveErr.Error(),
})
return
}
}
} else {
// Inactive entries should disappear from local active cache list.
if delErr := h.localDB.DeleteLocalPricelist(localPL.ID); delErr != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "updated on server but failed to update local cache: " + delErr.Error(),
})
return
}
}
} else if req.IsActive {
if err := h.refreshLocalPricelistCacheFromServer(uint(id), nil); err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "updated on server but failed to seed local cache: " + err.Error(),
})
return
}
}
}
c.JSON(http.StatusOK, gin.H{"message": "updated", "is_active": req.IsActive})
}
// GetItems returns items for a pricelist with pagination
// GetItems returns items for a pricelist with pagination.
func (h *PricelistHandler) GetItems(c *gin.Context) {
idStr := c.Param("id")
id, err := strconv.ParseUint(idStr, 10, 32)
@@ -598,13 +210,7 @@ func (h *PricelistHandler) GetLotNames(c *gin.Context) {
})
}
// CanWrite returns whether the current user can create pricelists
func (h *PricelistHandler) CanWrite(c *gin.Context) {
canWrite, debugInfo := h.service.CanWriteDebug()
c.JSON(http.StatusOK, gin.H{"can_write": canWrite, "debug": debugInfo})
}
// GetLatest returns the most recent active pricelist
// 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))

File diff suppressed because it is too large Load Diff

View File

@@ -1,12 +1,14 @@
package handlers
import (
"errors"
"fmt"
"html/template"
"log/slog"
"net/http"
"os"
"path/filepath"
stdsync "sync"
"time"
qfassets "git.mchus.pro/mchus/quoteforge"
@@ -24,6 +26,9 @@ type SyncHandler struct {
autoSyncInterval time.Duration
onlineGraceFactor float64
tmpl *template.Template
readinessMu stdsync.Mutex
readinessCached *sync.SyncReadiness
readinessCachedAt time.Time
}
// NewSyncHandler creates a new sync handler
@@ -53,14 +58,24 @@ func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr
// SyncStatusResponse represents the sync status
type SyncStatusResponse struct {
LastComponentSync *time.Time `json:"last_component_sync"`
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
IsOnline bool `json:"is_online"`
ComponentsCount int64 `json:"components_count"`
PricelistsCount int64 `json:"pricelists_count"`
ServerPricelists int `json:"server_pricelists"`
NeedComponentSync bool `json:"need_component_sync"`
NeedPricelistSync bool `json:"need_pricelist_sync"`
LastComponentSync *time.Time `json:"last_component_sync"`
LastPricelistSync *time.Time `json:"last_pricelist_sync"`
IsOnline bool `json:"is_online"`
ComponentsCount int64 `json:"components_count"`
PricelistsCount int64 `json:"pricelists_count"`
ServerPricelists int `json:"server_pricelists"`
NeedComponentSync bool `json:"need_component_sync"`
NeedPricelistSync bool `json:"need_pricelist_sync"`
Readiness *sync.SyncReadiness `json:"readiness,omitempty"`
}
type SyncReadinessResponse struct {
Status string `json:"status"`
Blocked bool `json:"blocked"`
ReasonCode string `json:"reason_code,omitempty"`
ReasonText string `json:"reason_text,omitempty"`
RequiredMinAppVersion *string `json:"required_min_app_version,omitempty"`
LastCheckedAt *time.Time `json:"last_checked_at,omitempty"`
}
// GetStatus returns current sync status
@@ -90,6 +105,7 @@ func (h *SyncHandler) GetStatus(c *gin.Context) {
// Check if component sync is needed (older than 24 hours)
needComponentSync := h.localDB.NeedComponentSync(24)
readiness := h.getReadinessCached(10 * time.Second)
c.JSON(http.StatusOK, SyncStatusResponse{
LastComponentSync: lastComponentSync,
@@ -100,9 +116,63 @@ func (h *SyncHandler) GetStatus(c *gin.Context) {
ServerPricelists: serverPricelists,
NeedComponentSync: needComponentSync,
NeedPricelistSync: needPricelistSync,
Readiness: readiness,
})
}
// GetReadiness returns sync readiness guard status.
// GET /api/sync/readiness
func (h *SyncHandler) GetReadiness(c *gin.Context) {
readiness, err := h.syncService.GetReadiness()
if err != nil && readiness == nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": err.Error(),
})
return
}
if readiness == nil {
c.JSON(http.StatusOK, SyncReadinessResponse{Status: sync.ReadinessUnknown, Blocked: false})
return
}
c.JSON(http.StatusOK, SyncReadinessResponse{
Status: readiness.Status,
Blocked: readiness.Blocked,
ReasonCode: readiness.ReasonCode,
ReasonText: readiness.ReasonText,
RequiredMinAppVersion: readiness.RequiredMinAppVersion,
LastCheckedAt: readiness.LastCheckedAt,
})
}
func (h *SyncHandler) ensureSyncReadiness(c *gin.Context) bool {
readiness, err := h.syncService.EnsureReadinessForSync()
if err == nil {
return true
}
blocked := &sync.SyncBlockedError{}
if errors.As(err, &blocked) {
c.JSON(http.StatusLocked, gin.H{
"success": false,
"error": blocked.Error(),
"reason_code": blocked.Readiness.ReasonCode,
"reason_text": blocked.Readiness.ReasonText,
"required_min_app_version": blocked.Readiness.RequiredMinAppVersion,
"status": blocked.Readiness.Status,
"blocked": true,
"last_checked_at": blocked.Readiness.LastCheckedAt,
})
return false
}
c.JSON(http.StatusInternalServerError, gin.H{
"success": false,
"error": err.Error(),
})
_ = readiness
return false
}
// SyncResultResponse represents sync operation result
type SyncResultResponse struct {
Success bool `json:"success"`
@@ -114,11 +184,7 @@ type SyncResultResponse struct {
// SyncComponents syncs components from MariaDB to local SQLite
// POST /api/sync/components
func (h *SyncHandler) SyncComponents(c *gin.Context) {
if !h.checkOnline() {
c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"error": "Database is offline",
})
if !h.ensureSyncReadiness(c) {
return
}
@@ -153,11 +219,7 @@ func (h *SyncHandler) SyncComponents(c *gin.Context) {
// SyncPricelists syncs pricelists from MariaDB to local SQLite
// POST /api/sync/pricelists
func (h *SyncHandler) SyncPricelists(c *gin.Context) {
if !h.checkOnline() {
c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"error": "Database is offline",
})
if !h.ensureSyncReadiness(c) {
return
}
@@ -202,11 +264,7 @@ type SyncAllResponse struct {
// - pull components, pricelists, projects, and configurations from server
// POST /api/sync/all
func (h *SyncHandler) SyncAll(c *gin.Context) {
if !h.checkOnline() {
c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"error": "Database is offline",
})
if !h.ensureSyncReadiness(c) {
return
}
@@ -312,11 +370,7 @@ func (h *SyncHandler) checkOnline() bool {
// PushPendingChanges pushes all pending changes to the server
// POST /api/sync/push
func (h *SyncHandler) PushPendingChanges(c *gin.Context) {
if !h.checkOnline() {
c.JSON(http.StatusServiceUnavailable, gin.H{
"success": false,
"error": "Database is offline",
})
if !h.ensureSyncReadiness(c) {
return
}
@@ -377,9 +431,9 @@ type SyncInfoResponse struct {
LastSyncAt *time.Time `json:"last_sync_at"`
// Statistics
LotCount int64 `json:"lot_count"`
LotLogCount int64 `json:"lot_log_count"`
ConfigCount int64 `json:"config_count"`
LotCount int64 `json:"lot_count"`
LotLogCount int64 `json:"lot_log_count"`
ConfigCount int64 `json:"config_count"`
ProjectCount int64 `json:"project_count"`
// Pending changes
@@ -388,6 +442,9 @@ type SyncInfoResponse struct {
// Errors
ErrorCount int `json:"error_count"`
Errors []SyncError `json:"errors,omitempty"`
// Readiness guard
Readiness *sync.SyncReadiness `json:"readiness,omitempty"`
}
type SyncUsersStatusResponse struct {
@@ -459,6 +516,8 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
syncErrors = syncErrors[:10]
}
readiness := h.getReadinessCached(10 * time.Second)
c.JSON(http.StatusOK, SyncInfoResponse{
DBHost: dbHost,
DBUser: dbUser,
@@ -472,6 +531,7 @@ func (h *SyncHandler) GetInfo(c *gin.Context) {
PendingChanges: changes,
ErrorCount: errorCount,
Errors: syncErrors,
Readiness: readiness,
})
}
@@ -528,12 +588,21 @@ func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
// Get pending count
pendingCount := h.localDB.GetPendingCount()
readiness := h.getReadinessCached(10 * time.Second)
isBlocked := readiness != nil && readiness.Blocked
slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount)
slog.Debug("rendering sync status", "is_offline", isOffline, "pending_count", pendingCount, "sync_blocked", isBlocked)
data := gin.H{
"IsOffline": isOffline,
"PendingCount": pendingCount,
"IsBlocked": isBlocked,
"BlockedReason": func() string {
if readiness == nil {
return ""
}
return readiness.ReasonText
}(),
}
c.Header("Content-Type", "text/html; charset=utf-8")
@@ -542,3 +611,24 @@ func (h *SyncHandler) SyncStatusPartial(c *gin.Context) {
c.String(http.StatusInternalServerError, "Template error: "+err.Error())
}
}
func (h *SyncHandler) getReadinessCached(maxAge time.Duration) *sync.SyncReadiness {
h.readinessMu.Lock()
if h.readinessCached != nil && time.Since(h.readinessCachedAt) < maxAge {
cached := *h.readinessCached
h.readinessMu.Unlock()
return &cached
}
h.readinessMu.Unlock()
readiness, err := h.syncService.GetReadiness()
if err != nil && readiness == nil {
return nil
}
h.readinessMu.Lock()
h.readinessCached = readiness
h.readinessCachedAt = time.Now()
h.readinessMu.Unlock()
return readiness
}

View File

@@ -67,7 +67,7 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
}
// Load each page template with base
simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "admin_pricing.html", "pricelists.html", "pricelist_detail.html"}
simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html"}
for _, page := range simplePages {
pagePath := filepath.Join(templatesPath, page)
var tmpl *template.Template
@@ -197,10 +197,6 @@ func (h *WebHandler) ProjectDetail(c *gin.Context) {
})
}
func (h *WebHandler) AdminPricing(c *gin.Context) {
h.render(c, "admin_pricing.html", gin.H{"ActivePage": "admin"})
}
func (h *WebHandler) Pricelists(c *gin.Context) {
h.render(c, "pricelists.html", gin.H{"ActivePage": "pricelists"})
}

View File

@@ -66,6 +66,8 @@ func New(dbPath string) (*LocalDB, error) {
&LocalPricelistItem{},
&LocalComponent{},
&AppSetting{},
&LocalRemoteMigrationApplied{},
&LocalSyncGuardState{},
&PendingChange{},
); err != nil {
return nil, fmt.Errorf("migrating sqlite database: %w", err)
@@ -418,6 +420,37 @@ func (l *LocalDB) GetConfigurationByUUID(uuid string) (*LocalConfiguration, erro
return &config, err
}
// ListConfigurationsWithFilters returns configurations with DB-level filtering and pagination.
func (l *LocalDB) ListConfigurationsWithFilters(status string, search string, offset, limit int) ([]LocalConfiguration, int64, error) {
query := l.db.Model(&LocalConfiguration{})
switch status {
case "active":
query = query.Where("is_active = ?", true)
case "archived":
query = query.Where("is_active = ?", false)
case "all", "":
// no-op
default:
query = query.Where("is_active = ?", true)
}
search = strings.TrimSpace(search)
if search != "" {
query = query.Where("LOWER(name) LIKE ?", "%"+strings.ToLower(search)+"%")
}
var total int64
if err := query.Count(&total).Error; err != nil {
return nil, 0, err
}
var configs []LocalConfiguration
if err := query.Order("created_at DESC").Offset(offset).Limit(limit).Find(&configs).Error; err != nil {
return nil, 0, err
}
return configs, total, nil
}
// DeleteConfiguration deletes a configuration by UUID
func (l *LocalDB) DeleteConfiguration(uuid string) error {
return l.DeactivateConfiguration(uuid)
@@ -772,3 +805,71 @@ func (l *LocalDB) PurgeOrphanConfigurationPendingChanges() (int64, error) {
func (l *LocalDB) GetPendingCount() int64 {
return l.CountPendingChanges()
}
// GetRemoteMigrationApplied returns a locally applied remote migration by ID.
func (l *LocalDB) GetRemoteMigrationApplied(id string) (*LocalRemoteMigrationApplied, error) {
var migration LocalRemoteMigrationApplied
if err := l.db.Where("id = ?", id).First(&migration).Error; err != nil {
return nil, err
}
return &migration, nil
}
// UpsertRemoteMigrationApplied writes applied migration metadata.
func (l *LocalDB) UpsertRemoteMigrationApplied(id, checksum, appVersion string, appliedAt time.Time) error {
record := &LocalRemoteMigrationApplied{
ID: id,
Checksum: checksum,
AppVersion: appVersion,
AppliedAt: appliedAt,
}
return l.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"checksum": checksum,
"app_version": appVersion,
"applied_at": appliedAt,
}),
}).Create(record).Error
}
// GetLatestAppliedRemoteMigrationID returns last applied remote migration id.
func (l *LocalDB) GetLatestAppliedRemoteMigrationID() (string, error) {
var record LocalRemoteMigrationApplied
if err := l.db.Order("applied_at DESC").First(&record).Error; err != nil {
return "", err
}
return record.ID, nil
}
// GetSyncGuardState returns the latest readiness guard state.
func (l *LocalDB) GetSyncGuardState() (*LocalSyncGuardState, error) {
var state LocalSyncGuardState
if err := l.db.Order("id DESC").First(&state).Error; err != nil {
return nil, err
}
return &state, nil
}
// SetSyncGuardState upserts readiness guard state (single-row logical table).
func (l *LocalDB) SetSyncGuardState(status, reasonCode, reasonText string, requiredMinAppVersion *string, checkedAt *time.Time) error {
state := &LocalSyncGuardState{
ID: 1,
Status: status,
ReasonCode: reasonCode,
ReasonText: reasonText,
RequiredMinAppVersion: requiredMinAppVersion,
LastCheckedAt: checkedAt,
}
return l.db.Clauses(clause.OnConflict{
Columns: []clause.Column{{Name: "id"}},
DoUpdates: clause.Assignments(map[string]interface{}{
"status": status,
"reason_code": reasonCode,
"reason_text": reasonText,
"required_min_app_version": requiredMinAppVersion,
"last_checked_at": checkedAt,
"updated_at": time.Now(),
}),
}).Create(state).Error
}

View File

@@ -8,7 +8,6 @@ import (
"time"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/warehouse"
"gorm.io/gorm"
)
@@ -244,13 +243,6 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear
}
}
var pl models.Pricelist
if err := r.db.Select("source").Where("id = ?", pricelistID).First(&pl).Error; err == nil && pl.Source == string(models.PricelistSourceWarehouse) {
if err := r.enrichWarehouseItems(items); err != nil {
return nil, 0, fmt.Errorf("enriching warehouse items: %w", err)
}
}
return items, total, nil
}
@@ -267,42 +259,6 @@ func (r *PricelistRepository) GetLotNames(pricelistID uint) ([]string, error) {
return lotNames, nil
}
func (r *PricelistRepository) enrichWarehouseItems(items []models.PricelistItem) error {
if len(items) == 0 {
return nil
}
lots := make([]string, 0, len(items))
seen := make(map[string]struct{}, len(items))
for _, item := range items {
lot := strings.TrimSpace(item.LotName)
if lot == "" {
continue
}
if _, ok := seen[lot]; ok {
continue
}
seen[lot] = struct{}{}
lots = append(lots, lot)
}
if len(lots) == 0 {
return nil
}
qtyByLot, partnumbersByLot, err := warehouse.LoadLotMetrics(r.db, lots, true)
if err != nil {
return err
}
for i := range items {
if qty, ok := qtyByLot[items[i].LotName]; ok {
q := qty
items[i].AvailableQty = &q
}
items[i].Partnumbers = partnumbersByLot[items[i].LotName]
}
return nil
}
// GetPriceForLot returns item price for a lot within a pricelist.
func (r *PricelistRepository) GetPriceForLot(pricelistID uint, lotName string) (float64, error) {
var item models.PricelistItem

View File

@@ -1,199 +0,0 @@
package alerts
import (
"fmt"
"time"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
)
type Service struct {
alertRepo *repository.AlertRepository
componentRepo *repository.ComponentRepository
priceRepo *repository.PriceRepository
statsRepo *repository.StatsRepository
config config.AlertsConfig
pricingConfig config.PricingConfig
}
func NewService(
alertRepo *repository.AlertRepository,
componentRepo *repository.ComponentRepository,
priceRepo *repository.PriceRepository,
statsRepo *repository.StatsRepository,
alertCfg config.AlertsConfig,
pricingCfg config.PricingConfig,
) *Service {
return &Service{
alertRepo: alertRepo,
componentRepo: componentRepo,
priceRepo: priceRepo,
statsRepo: statsRepo,
config: alertCfg,
pricingConfig: pricingCfg,
}
}
func (s *Service) List(filter repository.AlertFilter, page, perPage int) ([]models.PricingAlert, int64, error) {
if page < 1 {
page = 1
}
if perPage < 1 || perPage > 100 {
perPage = 20
}
offset := (page - 1) * perPage
return s.alertRepo.List(filter, offset, perPage)
}
func (s *Service) Acknowledge(id uint) error {
return s.alertRepo.UpdateStatus(id, models.AlertStatusAcknowledged)
}
func (s *Service) Resolve(id uint) error {
return s.alertRepo.UpdateStatus(id, models.AlertStatusResolved)
}
func (s *Service) Ignore(id uint) error {
return s.alertRepo.UpdateStatus(id, models.AlertStatusIgnored)
}
func (s *Service) GetNewAlertsCount() (int64, error) {
return s.alertRepo.CountByStatus(models.AlertStatusNew)
}
// CheckAndGenerateAlerts scans components and creates alerts
func (s *Service) CheckAndGenerateAlerts() error {
if !s.config.Enabled {
return nil
}
// Get top components by usage
topComponents, err := s.statsRepo.GetTopComponents(100)
if err != nil {
return err
}
for _, stats := range topComponents {
component, err := s.componentRepo.GetByLotName(stats.LotName)
if err != nil {
continue
}
// Check high demand + stale price
if err := s.checkHighDemandStalePrice(component, &stats); err != nil {
continue
}
// Check trending without price
if err := s.checkTrendingNoPrice(component, &stats); err != nil {
continue
}
// Check no recent quotes
if err := s.checkNoRecentQuotes(component, &stats); err != nil {
continue
}
}
return nil
}
func (s *Service) checkHighDemandStalePrice(comp *models.LotMetadata, stats *models.ComponentUsageStats) error {
// high_demand_stale_price: >= 5 quotes/month AND price > 60 days old
if stats.QuotesLast30d < s.config.HighDemandThreshold {
return nil
}
if comp.PriceUpdatedAt == nil {
return nil
}
daysSinceUpdate := int(time.Since(*comp.PriceUpdatedAt).Hours() / 24)
if daysSinceUpdate <= s.pricingConfig.FreshnessYellowDays {
return nil
}
// Check if alert already exists
exists, _ := s.alertRepo.ExistsByLotAndType(comp.LotName, models.AlertHighDemandStalePrice)
if exists {
return nil
}
alert := &models.PricingAlert{
LotName: comp.LotName,
AlertType: models.AlertHighDemandStalePrice,
Severity: models.SeverityCritical,
Message: fmt.Sprintf("Компонент %s: высокий спрос (%d КП/мес), но цена устарела (%d дней)", comp.LotName, stats.QuotesLast30d, daysSinceUpdate),
Details: models.AlertDetails{
"quotes_30d": stats.QuotesLast30d,
"days_since_update": daysSinceUpdate,
},
}
return s.alertRepo.Create(alert)
}
func (s *Service) checkTrendingNoPrice(comp *models.LotMetadata, stats *models.ComponentUsageStats) error {
// trending_no_price: trend > 50% AND no price
if stats.TrendDirection != models.TrendUp || stats.TrendPercent < float64(s.config.TrendingThresholdPercent) {
return nil
}
if comp.CurrentPrice != nil && *comp.CurrentPrice > 0 {
return nil
}
exists, _ := s.alertRepo.ExistsByLotAndType(comp.LotName, models.AlertTrendingNoPrice)
if exists {
return nil
}
alert := &models.PricingAlert{
LotName: comp.LotName,
AlertType: models.AlertTrendingNoPrice,
Severity: models.SeverityHigh,
Message: fmt.Sprintf("Компонент %s: рост спроса +%.0f%%, но цена не установлена", comp.LotName, stats.TrendPercent),
Details: models.AlertDetails{
"trend_percent": stats.TrendPercent,
},
}
return s.alertRepo.Create(alert)
}
func (s *Service) checkNoRecentQuotes(comp *models.LotMetadata, stats *models.ComponentUsageStats) error {
// no_recent_quotes: popular component, no supplier quotes > 90 days
if stats.QuotesLast30d < 3 {
return nil
}
quoteCount, err := s.priceRepo.GetQuoteCount(comp.LotName, s.pricingConfig.FreshnessRedDays)
if err != nil {
return err
}
if quoteCount > 0 {
return nil
}
exists, _ := s.alertRepo.ExistsByLotAndType(comp.LotName, models.AlertNoRecentQuotes)
if exists {
return nil
}
alert := &models.PricingAlert{
LotName: comp.LotName,
AlertType: models.AlertNoRecentQuotes,
Severity: models.SeverityMedium,
Message: fmt.Sprintf("Компонент %s: популярный (%d КП), но нет новых котировок >%d дней", comp.LotName, stats.QuotesLast30d, s.pricingConfig.FreshnessRedDays),
Details: models.AlertDetails{
"quotes_30d": stats.QuotesLast30d,
"no_quotes_days": s.pricingConfig.FreshnessRedDays,
},
}
return s.alertRepo.Create(alert)
}

View File

@@ -600,26 +600,6 @@ func (s *LocalConfigurationService) ListAll(page, perPage int) ([]models.Configu
// ListAllWithStatus returns configurations filtered by status: active|archived|all.
func (s *LocalConfigurationService) ListAllWithStatus(page, perPage int, status string, search string) ([]models.Configuration, int64, error) {
localConfigs, err := s.localDB.GetConfigurations()
if err != nil {
return nil, 0, err
}
search = strings.ToLower(strings.TrimSpace(search))
configs := make([]models.Configuration, len(localConfigs))
configs = configs[:0]
for _, lc := range localConfigs {
if !matchesConfigStatus(lc.IsActive, status) {
continue
}
if search != "" && !strings.Contains(strings.ToLower(lc.Name), search) {
continue
}
configs = append(configs, *localdb.LocalToConfiguration(&lc))
}
total := int64(len(configs))
// Apply pagination
if page < 1 {
page = 1
@@ -628,17 +608,15 @@ func (s *LocalConfigurationService) ListAllWithStatus(page, perPage int, status
perPage = 20
}
offset := (page - 1) * perPage
start := offset
if start > len(configs) {
start = len(configs)
localConfigs, total, err := s.localDB.ListConfigurationsWithFilters(status, search, offset, perPage)
if err != nil {
return nil, 0, err
}
end := start + perPage
if end > len(configs) {
end = len(configs)
configs := make([]models.Configuration, 0, len(localConfigs))
for _, lc := range localConfigs {
configs = append(configs, *localdb.LocalToConfiguration(&lc))
}
return configs[start:end], total, nil
return configs, total, nil
}
// ListTemplates returns all template configurations

View File

@@ -1,371 +0,0 @@
package pricelist
import (
"errors"
"fmt"
"log/slog"
"strings"
"time"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"git.mchus.pro/mchus/quoteforge/internal/services/pricing"
"git.mchus.pro/mchus/quoteforge/internal/warehouse"
"gorm.io/gorm"
)
type Service struct {
repo *repository.PricelistRepository
componentRepo *repository.ComponentRepository
pricingSvc *pricing.Service
db *gorm.DB
}
type CreateProgress struct {
Current int
Total int
Status string
Message string
Updated int
Errors int
LotName string
}
type CreateItemInput struct {
LotName string
Price float64
PriceMethod string
}
func NewService(db *gorm.DB, repo *repository.PricelistRepository, componentRepo *repository.ComponentRepository, pricingSvc *pricing.Service) *Service {
return &Service{
repo: repo,
componentRepo: componentRepo,
pricingSvc: pricingSvc,
db: db,
}
}
// CreateFromCurrentPrices creates a new pricelist by taking a snapshot of current prices
func (s *Service) CreateFromCurrentPrices(createdBy string) (*models.Pricelist, error) {
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, 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 {
onProgress(p)
}
}
report(CreateProgress{Current: 0, Total: 100, Status: "starting", Message: "Подготовка"})
updated, errs := 0, 0
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 {
return
}
phaseCurrent := 1 + int(float64(p.Current)/float64(p.Total)*90.0)
if phaseCurrent > 91 {
phaseCurrent = 91
}
report(CreateProgress{
Current: phaseCurrent,
Total: 100,
Status: "recalculating",
Message: "Обновление цен компонентов",
Updated: p.Updated,
Errors: p.Errors,
LotName: p.LotName,
})
})
}
report(CreateProgress{Current: 92, Total: 100, Status: "recalculated", Message: "Цены обновлены", Updated: updated, Errors: errs})
report(CreateProgress{Current: 95, Total: 100, Status: "snapshot", Message: "Создание снимка прайслиста"})
expiresAt := time.Now().AddDate(1, 0, 0) // +1 year
const maxCreateAttempts = 5
var pricelist *models.Pricelist
for attempt := 1; attempt <= maxCreateAttempts; attempt++ {
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,
ExpiresAt: &expiresAt,
}
if err := s.repo.Create(pricelist); err != nil {
if isVersionConflictError(err) && attempt < maxCreateAttempts {
slog.Warn("pricelist version conflict, retrying",
"attempt", attempt,
"version", version,
"error", err,
)
time.Sleep(time.Duration(attempt*25) * time.Millisecond)
continue
}
return nil, fmt.Errorf("creating pricelist: %w", err)
}
break
}
items := make([]models.PricelistItem, 0)
if len(sourceItems) == 0 && source == string(models.PricelistSourceWarehouse) {
warehouseItems, err := warehouse.ComputePricelistItemsFromStockLog(s.db)
if err != nil {
_ = s.repo.Delete(pricelist.ID)
return nil, fmt.Errorf("building warehouse pricelist from stock_log: %w", err)
}
sourceItems = make([]CreateItemInput, 0, len(warehouseItems))
for _, item := range warehouseItems {
sourceItems = append(sourceItems, CreateItemInput{
LotName: item.LotName,
Price: item.Price,
PriceMethod: item.PriceMethod,
})
}
}
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,
PriceMethod: strings.TrimSpace(srcItem.PriceMethod),
})
}
} 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,
})
}
}
if len(items) == 0 {
_ = s.repo.Delete(pricelist.ID)
return nil, fmt.Errorf("cannot create empty pricelist for source %q", source)
}
if err := s.repo.CreateItems(items); err != nil {
// Clean up the pricelist if items creation fails
s.repo.Delete(pricelist.ID)
return nil, fmt.Errorf("creating pricelist items: %w", err)
}
pricelist.ItemCount = len(items)
slog.Info("pricelist created",
"id", pricelist.ID,
"version", pricelist.Version,
"items", len(items),
"created_by", createdBy,
)
report(CreateProgress{Current: 100, Total: 100, Status: "completed", Message: "Прайслист создан", Updated: updated, Errors: errs})
return pricelist, nil
}
func isVersionConflictError(err error) bool {
if errors.Is(err, gorm.ErrDuplicatedKey) {
return true
}
msg := strings.ToLower(err.Error())
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
}
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 20
}
offset := (page - 1) * 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
}
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 20
}
offset := (page - 1) * perPage
return s.repo.ListActiveBySource(source, offset, perPage)
}
// GetByID returns a pricelist by ID
func (s *Service) GetByID(id uint) (*models.Pricelist, error) {
if s.repo == nil {
return nil, fmt.Errorf("offline mode: pricelist service not available")
}
return s.repo.GetByID(id)
}
// GetItems returns pricelist items with pagination
func (s *Service) GetItems(pricelistID uint, page, perPage int, search string) ([]models.PricelistItem, int64, error) {
if s.repo == nil {
return []models.PricelistItem{}, 0, nil
}
if page < 1 {
page = 1
}
if perPage < 1 {
perPage = 50
}
offset := (page - 1) * perPage
return s.repo.GetItems(pricelistID, offset, perPage, search)
}
func (s *Service) GetLotNames(pricelistID uint) ([]string, error) {
if s.repo == nil {
return []string{}, nil
}
return s.repo.GetLotNames(pricelistID)
}
// Delete deletes a pricelist by ID
func (s *Service) Delete(id uint) error {
if s.repo == nil {
return fmt.Errorf("offline mode: cannot delete pricelists")
}
return s.repo.Delete(id)
}
// SetActive toggles active state for a pricelist.
func (s *Service) SetActive(id uint, isActive bool) error {
if s.repo == nil {
return fmt.Errorf("offline mode: cannot update pricelists")
}
return s.repo.SetActive(id, isActive)
}
// GetPriceForLot returns price by pricelist/lot.
func (s *Service) GetPriceForLot(pricelistID uint, lotName string) (float64, error) {
if s.repo == nil {
return 0, fmt.Errorf("offline mode: pricelist service not available")
}
return s.repo.GetPriceForLot(pricelistID, lotName)
}
// CanWrite returns true if the user can create pricelists
func (s *Service) CanWrite() bool {
if s.repo == nil {
return false
}
return s.repo.CanWrite()
}
// CanWriteDebug returns write permission status with debug info
func (s *Service) CanWriteDebug() (bool, string) {
if s.repo == nil {
return false, "offline mode"
}
return s.repo.CanWriteDebug()
}
// 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.GetLatestActiveBySource(source)
}
// CleanupExpired deletes expired and unused pricelists
func (s *Service) CleanupExpired() (int, error) {
if s.repo == nil {
return 0, fmt.Errorf("offline mode: cleanup not available")
}
expired, err := s.repo.GetExpiredUnused()
if err != nil {
return 0, err
}
deleted := 0
for _, pl := range expired {
if err := s.repo.Delete(pl.ID); err != nil {
slog.Warn("failed to delete expired pricelist", "id", pl.ID, "error", err)
continue
}
deleted++
}
slog.Info("cleaned up expired pricelists", "deleted", deleted)
return deleted, nil
}

View File

@@ -1,72 +0,0 @@
package pricelist
import (
"math"
"testing"
"time"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestCreateWarehousePricelistFromStockLog(t *testing.T) {
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
if err := db.AutoMigrate(
&models.Pricelist{},
&models.PricelistItem{},
&models.StockLog{},
&models.Lot{},
&models.LotPartnumber{},
); err != nil {
t.Fatalf("automigrate: %v", err)
}
if err := db.Create(&models.Lot{LotName: "CPU_X", LotDescription: "CPU"}).Error; err != nil {
t.Fatalf("seed lot: %v", err)
}
if err := db.Create(&models.LotPartnumber{Partnumber: "PN-CPU-X", LotName: "CPU_X"}).Error; err != nil {
t.Fatalf("seed mapping: %v", err)
}
qty1 := 2.0
qty2 := 8.0
now := time.Now()
rows := []models.StockLog{
{Partnumber: "PN-CPU-X", Date: now, Price: 100, Qty: &qty1},
{Partnumber: "PN-CPU-X", Date: now, Price: 200, Qty: &qty2},
}
if err := db.Create(&rows).Error; err != nil {
t.Fatalf("seed stock log: %v", err)
}
repo := repository.NewPricelistRepository(db)
svc := NewService(db, repo, nil, nil)
pl, err := svc.CreateForSourceWithProgress("tester", string(models.PricelistSourceWarehouse), nil, nil)
if err != nil {
t.Fatalf("create warehouse pricelist: %v", err)
}
if pl.Source != string(models.PricelistSourceWarehouse) {
t.Fatalf("unexpected source: %s", pl.Source)
}
var items []models.PricelistItem
if err := db.Where("pricelist_id = ?", pl.ID).Find(&items).Error; err != nil {
t.Fatalf("load pricelist items: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 item, got %d", len(items))
}
if items[0].LotName != "CPU_X" {
t.Fatalf("unexpected lot name: %s", items[0].LotName)
}
if math.Abs(items[0].Price-200) > 0.001 {
t.Fatalf("expected weighted median price 200, got %f", items[0].Price)
}
}

View File

@@ -1,121 +0,0 @@
package pricing
import (
"math"
"sort"
"time"
"git.mchus.pro/mchus/quoteforge/internal/repository"
)
// CalculateMedian returns the median of prices
func CalculateMedian(prices []float64) float64 {
if len(prices) == 0 {
return 0
}
sorted := make([]float64, len(prices))
copy(sorted, prices)
sort.Float64s(sorted)
n := len(sorted)
if n%2 == 0 {
return (sorted[n/2-1] + sorted[n/2]) / 2
}
return sorted[n/2]
}
// CalculateAverage returns the arithmetic mean of prices
func CalculateAverage(prices []float64) float64 {
if len(prices) == 0 {
return 0
}
var sum float64
for _, p := range prices {
sum += p
}
return sum / float64(len(prices))
}
// CalculateWeightedMedian calculates median with exponential decay weights
// More recent prices have higher weight
func CalculateWeightedMedian(points []repository.PricePoint, decayDays int) float64 {
if len(points) == 0 {
return 0
}
type weightedPrice struct {
price float64
weight float64
}
now := time.Now()
weighted := make([]weightedPrice, len(points))
var totalWeight float64
for i, p := range points {
daysSince := now.Sub(p.Date).Hours() / 24
// weight = e^(-days / decay_days)
weight := math.Exp(-daysSince / float64(decayDays))
weighted[i] = weightedPrice{price: p.Price, weight: weight}
totalWeight += weight
}
// Sort by price
sort.Slice(weighted, func(i, j int) bool {
return weighted[i].price < weighted[j].price
})
// Find weighted median
targetWeight := totalWeight / 2
var cumulativeWeight float64
for _, wp := range weighted {
cumulativeWeight += wp.weight
if cumulativeWeight >= targetWeight {
return wp.price
}
}
return weighted[len(weighted)-1].price
}
// CalculatePercentile calculates the nth percentile of prices
func CalculatePercentile(prices []float64, percentile float64) float64 {
if len(prices) == 0 {
return 0
}
sorted := make([]float64, len(prices))
copy(sorted, prices)
sort.Float64s(sorted)
index := (percentile / 100) * float64(len(sorted)-1)
lower := int(math.Floor(index))
upper := int(math.Ceil(index))
if lower == upper {
return sorted[lower]
}
fraction := index - float64(lower)
return sorted[lower]*(1-fraction) + sorted[upper]*fraction
}
// CalculateStdDev calculates standard deviation
func CalculateStdDev(prices []float64) float64 {
if len(prices) < 2 {
return 0
}
mean := CalculateAverage(prices)
var sumSquares float64
for _, p := range prices {
diff := p - mean
sumSquares += diff * diff
}
return math.Sqrt(sumSquares / float64(len(prices)-1))
}

View File

@@ -1,378 +0,0 @@
package pricing
import (
"strings"
"time"
"git.mchus.pro/mchus/quoteforge/internal/config"
"git.mchus.pro/mchus/quoteforge/internal/models"
"git.mchus.pro/mchus/quoteforge/internal/repository"
"gorm.io/gorm"
)
type Service struct {
componentRepo *repository.ComponentRepository
priceRepo *repository.PriceRepository
config config.PricingConfig
db *gorm.DB
}
type RecalculateProgress struct {
Current int
Total int
LotName string
Updated int
Errors int
}
func NewService(
componentRepo *repository.ComponentRepository,
priceRepo *repository.PriceRepository,
cfg config.PricingConfig,
) *Service {
var db *gorm.DB
if componentRepo != nil {
db = componentRepo.DB()
}
return &Service{
componentRepo: componentRepo,
priceRepo: priceRepo,
config: cfg,
db: db,
}
}
// GetEffectivePrice returns the current effective price for a component
// Priority: active override > calculated price > nil
func (s *Service) GetEffectivePrice(lotName string) (*float64, error) {
// Check for active override first
override, err := s.priceRepo.GetPriceOverride(lotName)
if err == nil && override != nil {
return &override.Price, nil
}
// Get component metadata
component, err := s.componentRepo.GetByLotName(lotName)
if err != nil {
return nil, err
}
return component.CurrentPrice, nil
}
// CalculatePrice calculates price using the specified method
func (s *Service) CalculatePrice(lotName string, method models.PriceMethod, periodDays int) (float64, error) {
if periodDays == 0 {
periodDays = s.config.DefaultPeriodDays
}
points, err := s.priceRepo.GetPriceHistory(lotName, periodDays)
if err != nil {
return 0, err
}
if len(points) == 0 {
return 0, nil
}
prices := make([]float64, len(points))
for i, p := range points {
prices[i] = p.Price
}
switch method {
case models.PriceMethodAverage:
return CalculateAverage(prices), nil
case models.PriceMethodWeightedMedian:
return CalculateWeightedMedian(points, periodDays), nil
case models.PriceMethodMedian:
fallthrough
default:
return CalculateMedian(prices), nil
}
}
// UpdateComponentPrice recalculates and updates the price for a component
func (s *Service) UpdateComponentPrice(lotName string) error {
component, err := s.componentRepo.GetByLotName(lotName)
if err != nil {
return err
}
price, err := s.CalculatePrice(lotName, component.PriceMethod, component.PricePeriodDays)
if err != nil {
return err
}
now := time.Now()
if price > 0 {
component.CurrentPrice = &price
component.PriceUpdatedAt = &now
}
return s.componentRepo.Update(component)
}
// SetManualPrice sets a manual price override
func (s *Service) SetManualPrice(lotName string, price float64, reason string, userID uint) error {
override := &models.PriceOverride{
LotName: lotName,
Price: price,
ValidFrom: time.Now(),
Reason: reason,
CreatedBy: userID,
}
return s.priceRepo.CreatePriceOverride(override)
}
// UpdatePriceMethod changes the pricing method for a component
func (s *Service) UpdatePriceMethod(lotName string, method models.PriceMethod, periodDays int) error {
component, err := s.componentRepo.GetByLotName(lotName)
if err != nil {
return err
}
component.PriceMethod = method
if periodDays > 0 {
component.PricePeriodDays = periodDays
}
if err := s.componentRepo.Update(component); err != nil {
return err
}
return s.UpdateComponentPrice(lotName)
}
// GetPriceStats returns statistics for a component's price history
func (s *Service) GetPriceStats(lotName string, periodDays int) (*PriceStats, error) {
if periodDays == 0 {
periodDays = s.config.DefaultPeriodDays
}
points, err := s.priceRepo.GetPriceHistory(lotName, periodDays)
if err != nil {
return nil, err
}
if len(points) == 0 {
return &PriceStats{QuoteCount: 0}, nil
}
prices := make([]float64, len(points))
for i, p := range points {
prices[i] = p.Price
}
return &PriceStats{
QuoteCount: len(points),
MinPrice: CalculatePercentile(prices, 0),
MaxPrice: CalculatePercentile(prices, 100),
MedianPrice: CalculateMedian(prices),
AveragePrice: CalculateAverage(prices),
StdDeviation: CalculateStdDev(prices),
LatestPrice: points[0].Price,
LatestDate: points[0].Date,
OldestDate: points[len(points)-1].Date,
Percentile25: CalculatePercentile(prices, 25),
Percentile75: CalculatePercentile(prices, 75),
}, nil
}
type PriceStats struct {
QuoteCount int `json:"quote_count"`
MinPrice float64 `json:"min_price"`
MaxPrice float64 `json:"max_price"`
MedianPrice float64 `json:"median_price"`
AveragePrice float64 `json:"average_price"`
StdDeviation float64 `json:"std_deviation"`
LatestPrice float64 `json:"latest_price"`
LatestDate time.Time `json:"latest_date"`
OldestDate time.Time `json:"oldest_date"`
Percentile25 float64 `json:"percentile_25"`
Percentile75 float64 `json:"percentile_75"`
}
// RecalculateAllPrices recalculates prices for all components
func (s *Service) RecalculateAllPrices() (updated int, errors int) {
return s.RecalculateAllPricesWithProgress(nil)
}
// RecalculateAllPricesWithProgress recalculates prices and reports progress.
func (s *Service) RecalculateAllPricesWithProgress(onProgress func(RecalculateProgress)) (updated int, errors int) {
if s.db == nil {
return 0, 0
}
// Logic mirrors "Обновить цены" in admin pricing.
var components []models.LotMetadata
if err := s.db.Find(&components).Error; err != nil {
return 0, len(components)
}
total := len(components)
var allLotNames []string
_ = s.db.Model(&models.LotMetadata{}).Pluck("lot_name", &allLotNames).Error
type lotDate struct {
Lot string
Date time.Time
}
var latestDates []lotDate
_ = s.db.Raw(`SELECT lot, MAX(date) as date FROM lot_log GROUP BY lot`).Scan(&latestDates).Error
lotLatestDate := make(map[string]time.Time, len(latestDates))
for _, ld := range latestDates {
lotLatestDate[ld.Lot] = ld.Date
}
var skipped, manual, unchanged int
now := time.Now()
current := 0
for _, comp := range components {
current++
reportProgress := func() {
if onProgress != nil && (current%10 == 0 || current == total) {
onProgress(RecalculateProgress{
Current: current,
Total: total,
LotName: comp.LotName,
Updated: updated,
Errors: errors,
})
}
}
if comp.ManualPrice != nil && *comp.ManualPrice > 0 {
manual++
reportProgress()
continue
}
method := comp.PriceMethod
if method == "" {
method = models.PriceMethodMedian
}
var sourceLots []string
if comp.MetaPrices != "" {
sourceLots = expandMetaPricesWithCache(comp.MetaPrices, comp.LotName, allLotNames)
} else {
sourceLots = []string{comp.LotName}
}
if len(sourceLots) == 0 {
skipped++
reportProgress()
continue
}
if comp.PriceUpdatedAt != nil {
hasNewData := false
for _, lot := range sourceLots {
if latestDate, ok := lotLatestDate[lot]; ok && latestDate.After(*comp.PriceUpdatedAt) {
hasNewData = true
break
}
}
if !hasNewData {
unchanged++
reportProgress()
continue
}
}
var prices []float64
if comp.PricePeriodDays > 0 {
_ = s.db.Raw(
`SELECT price FROM lot_log WHERE lot IN ? AND date >= DATE_SUB(NOW(), INTERVAL ? DAY) ORDER BY price`,
sourceLots, comp.PricePeriodDays,
).Pluck("price", &prices).Error
} else {
_ = s.db.Raw(
`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`,
sourceLots,
).Pluck("price", &prices).Error
}
if len(prices) == 0 && comp.PricePeriodDays > 0 {
_ = s.db.Raw(`SELECT price FROM lot_log WHERE lot IN ? ORDER BY price`, sourceLots).Pluck("price", &prices).Error
}
if len(prices) == 0 {
skipped++
reportProgress()
continue
}
var basePrice float64
switch method {
case models.PriceMethodAverage:
basePrice = CalculateAverage(prices)
default:
basePrice = CalculateMedian(prices)
}
if basePrice <= 0 {
skipped++
reportProgress()
continue
}
finalPrice := basePrice
if comp.PriceCoefficient != 0 {
finalPrice = finalPrice * (1 + comp.PriceCoefficient/100)
}
if err := s.db.Model(&models.LotMetadata{}).
Where("lot_name = ?", comp.LotName).
Updates(map[string]interface{}{
"current_price": finalPrice,
"price_updated_at": now,
}).Error; err != nil {
errors++
} else {
updated++
}
reportProgress()
}
if onProgress != nil && total == 0 {
onProgress(RecalculateProgress{
Current: 0,
Total: 0,
LotName: "",
Updated: updated,
Errors: errors,
})
}
return updated, errors
}
func expandMetaPricesWithCache(metaPrices, excludeLot string, allLotNames []string) []string {
sources := strings.Split(metaPrices, ",")
var result []string
seen := make(map[string]bool)
for _, source := range sources {
source = strings.TrimSpace(source)
if source == "" || source == excludeLot {
continue
}
if strings.HasSuffix(source, "*") {
prefix := strings.TrimSuffix(source, "*")
for _, lot := range allLotNames {
if strings.HasPrefix(lot, prefix) && lot != excludeLot && !seen[lot] {
result = append(result, lot)
seen[lot] = true
}
}
} else if !seen[source] {
result = append(result, source)
seen[source] = true
}
}
return result
}

View File

@@ -9,7 +9,6 @@ import (
"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"
)
var (
@@ -23,18 +22,22 @@ type QuoteService struct {
statsRepo *repository.StatsRepository
pricelistRepo *repository.PricelistRepository
localDB *localdb.LocalDB
pricingService *pricing.Service
pricingService priceResolver
cacheMu sync.RWMutex
priceCache map[string]cachedLotPrice
cacheTTL time.Duration
}
type priceResolver interface {
GetEffectivePrice(lotName string) (*float64, error)
}
func NewQuoteService(
componentRepo *repository.ComponentRepository,
statsRepo *repository.StatsRepository,
pricelistRepo *repository.PricelistRepository,
localDB *localdb.LocalDB,
pricingService *pricing.Service,
pricingService priceResolver,
) *QuoteService {
return &QuoteService{
componentRepo: componentRepo,
@@ -110,8 +113,53 @@ func (s *QuoteService) ValidateAndCalculate(req *QuoteRequest) (*QuoteValidation
if len(req.Items) == 0 {
return nil, ErrEmptyQuote
}
// Strict local-first path: calculations use local SQLite snapshot regardless of online status.
if s.localDB != nil {
result := &QuoteValidationResult{
Valid: true,
Items: make([]QuoteItem, 0, len(req.Items)),
Errors: make([]string, 0),
Warnings: make([]string, 0),
}
var total float64
for _, reqItem := range req.Items {
localComp, err := s.localDB.GetLocalComponent(reqItem.LotName)
if err != nil {
result.Valid = false
result.Errors = append(result.Errors, "Component not found: "+reqItem.LotName)
continue
}
item := QuoteItem{
LotName: reqItem.LotName,
Quantity: reqItem.Quantity,
Description: localComp.LotDescription,
Category: localComp.Category,
HasPrice: false,
UnitPrice: 0,
TotalPrice: 0,
}
if localComp.CurrentPrice != nil && *localComp.CurrentPrice > 0 {
item.UnitPrice = *localComp.CurrentPrice
item.TotalPrice = *localComp.CurrentPrice * float64(reqItem.Quantity)
item.HasPrice = true
total += item.TotalPrice
} else {
result.Warnings = append(result.Warnings, "No price available for: "+reqItem.LotName)
}
result.Items = append(result.Items, item)
}
result.Total = total
return result, nil
}
if s.componentRepo == nil || s.pricingService == nil {
return nil, errors.New("offline mode: quote calculation not available")
return nil, errors.New("quote calculation not available")
}
result := &QuoteValidationResult{

File diff suppressed because it is too large Load Diff

View File

@@ -1,384 +0,0 @@
package services
import (
"archive/zip"
"bytes"
"strings"
"testing"
"time"
"git.mchus.pro/mchus/quoteforge/internal/lotmatch"
"git.mchus.pro/mchus/quoteforge/internal/models"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestParseMXLRows(t *testing.T) {
content := strings.Join([]string{
`MOXCEL`,
`{16,2,{1,1,{"ru","Папка"}},0},1,`,
`{16,2,{1,1,{"ru","Артикул"}},0},2,`,
`{16,2,{1,1,{"ru","Описание"}},0},3,`,
`{16,2,{1,1,{"ru","Вендор"}},0},4,`,
`{16,2,{1,1,{"ru","Стоимость"}},0},5,`,
`{16,2,{1,1,{"ru","Свободно"}},0},6,`,
`{16,2,{1,1,{"ru","Серверы"}},0},1,`,
`{16,2,{1,1,{"ru","CPU_X"}},0},2,`,
`{16,2,{1,1,{"ru","Процессор"}},0},3,`,
`{16,2,{1,1,{"ru","AMD"}},0},4,`,
`{16,2,{1,1,{"ru","125,50"}},0},5,`,
`{16,2,{1,1,{"ru","10"}},0},6,`,
}, "\n")
rows, err := parseMXLRows([]byte(content))
if err != nil {
t.Fatalf("parseMXLRows: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
if rows[0].Article != "CPU_X" {
t.Fatalf("unexpected article: %s", rows[0].Article)
}
if rows[0].Price != 125.50 {
t.Fatalf("unexpected price: %v", rows[0].Price)
}
if rows[0].Qty != 10 {
t.Fatalf("unexpected qty: %v", rows[0].Qty)
}
}
func TestParseMXLRows_EmptyQtyMarkedInvalid(t *testing.T) {
content := strings.Join([]string{
`MOXCEL`,
`{16,2,{1,1,{"ru","Папка"}},0},1,`,
`{16,2,{1,1,{"ru","Артикул"}},0},2,`,
`{16,2,{1,1,{"ru","Описание"}},0},3,`,
`{16,2,{1,1,{"ru","Вендор"}},0},4,`,
`{16,2,{1,1,{"ru","Стоимость"}},0},5,`,
`{16,2,{1,1,{"ru","Свободно"}},0},6,`,
`{16,2,{1,1,{"ru","Серверы"}},0},1,`,
`{16,2,{1,1,{"ru","CPU_X"}},0},2,`,
`{16,2,{1,1,{"ru","Процессор"}},0},3,`,
`{16,2,{1,1,{"ru","AMD"}},0},4,`,
`{16,2,{1,1,{"ru","125,50"}},0},5,`,
`{16,2,{1,1,{"ru",""}},0},6,`,
}, "\n")
rows, err := parseMXLRows([]byte(content))
if err != nil {
t.Fatalf("parseMXLRows: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
if !rows[0].QtyInvalid {
t.Fatalf("expected QtyInvalid=true for empty qty")
}
}
func TestParseXLSXRows(t *testing.T) {
xlsx := buildMinimalXLSX(t, []string{
"Папка", "Артикул", "Описание", "Вендор", "Стоимость", "Свободно",
}, []string{
"Серверы", "CPU_A", "Процессор", "AMD", "99,25", "7",
})
rows, err := parseXLSXRows(xlsx)
if err != nil {
t.Fatalf("parseXLSXRows: %v", err)
}
if len(rows) != 1 {
t.Fatalf("expected 1 row, got %d", len(rows))
}
if rows[0].Article != "CPU_A" {
t.Fatalf("unexpected article: %s", rows[0].Article)
}
if rows[0].Price != 99.25 {
t.Fatalf("unexpected price: %v", rows[0].Price)
}
}
func TestLotResolverPrecedenceAndConflicts(t *testing.T) {
resolver := lotmatch.NewLotResolver(
[]models.LotPartnumber{
{Partnumber: "pn-1", LotName: "LOT_MAPPED"},
{Partnumber: "pn-conflict", LotName: "LOT_A"},
{Partnumber: "pn-conflict", LotName: "LOT_B"},
},
[]models.Lot{
{LotName: "CPU_A_LONG"},
{LotName: "CPU_A"},
{LotName: "ABC "},
{LotName: "ABC\t"},
},
)
lot, typ, err := resolver.Resolve("pn-1")
if err != nil || lot != "LOT_MAPPED" || typ != "mapping_table" {
t.Fatalf("mapping_table mismatch: lot=%s typ=%s err=%v", lot, typ, err)
}
lot, typ, err = resolver.Resolve("cpu_a")
if err != nil || lot != "CPU_A" || typ != "article_exact" {
t.Fatalf("article_exact mismatch: lot=%s typ=%s err=%v", lot, typ, err)
}
lot, typ, err = resolver.Resolve("cpu_a_long_suffix")
if err != nil || lot != "CPU_A_LONG" || typ != "prefix" {
t.Fatalf("prefix mismatch: lot=%s typ=%s err=%v", lot, typ, err)
}
_, _, err = resolver.Resolve("abx")
if err == nil || err != lotmatch.ErrResolveNotFound {
t.Fatalf("expected not found error, got %v", err)
}
_, _, err = resolver.Resolve("pn-conflict")
if err == nil || err != lotmatch.ErrResolveConflict {
t.Fatalf("expected conflict, got %v", err)
}
}
func TestImportNoValidRowsKeepsStockLog(t *testing.T) {
db := openTestDB(t)
if err := db.AutoMigrate(&models.StockLog{}); err != nil {
t.Fatalf("automigrate stock_log: %v", err)
}
existing := models.StockLog{
Partnumber: "CPU_A",
Date: time.Now(),
Price: 10,
}
if err := db.Create(&existing).Error; err != nil {
t.Fatalf("seed stock_log: %v", err)
}
svc := NewStockImportService(db, nil)
headerOnly := []byte(strings.Join([]string{
`MOXCEL`,
`{16,2,{1,1,{"ru","Папка"}},0},1,`,
`{16,2,{1,1,{"ru","Артикул"}},0},2,`,
`{16,2,{1,1,{"ru","Описание"}},0},3,`,
`{16,2,{1,1,{"ru","Вендор"}},0},4,`,
`{16,2,{1,1,{"ru","Стоимость"}},0},5,`,
`{16,2,{1,1,{"ru","Свободно"}},0},6,`,
}, "\n"))
if _, err := svc.Import("test.mxl", headerOnly, time.Now(), "tester", nil); err == nil {
t.Fatalf("expected import error")
}
var count int64
if err := db.Model(&models.StockLog{}).Count(&count).Error; err != nil {
t.Fatalf("count stock_log: %v", err)
}
if count != 1 {
t.Fatalf("expected stock_log unchanged, got %d rows", count)
}
}
func TestReplaceStockLogs(t *testing.T) {
db := openTestDB(t)
if err := db.AutoMigrate(&models.StockLog{}); err != nil {
t.Fatalf("automigrate stock_log: %v", err)
}
if err := db.Create(&models.StockLog{Partnumber: "OLD", Date: time.Now(), Price: 1}).Error; err != nil {
t.Fatalf("seed old row: %v", err)
}
svc := NewStockImportService(db, nil)
records := []models.StockLog{
{Partnumber: "NEW_1", Date: time.Now(), Price: 2},
{Partnumber: "NEW_2", Date: time.Now(), Price: 3},
}
deleted, inserted, err := svc.replaceStockLogs(records)
if err != nil {
t.Fatalf("replaceStockLogs: %v", err)
}
if deleted != 1 || inserted != 2 {
t.Fatalf("unexpected replace stats deleted=%d inserted=%d", deleted, inserted)
}
var rows []models.StockLog
if err := db.Order("partnumber").Find(&rows).Error; err != nil {
t.Fatalf("read rows: %v", err)
}
if len(rows) != 2 || rows[0].Partnumber != "NEW_1" || rows[1].Partnumber != "NEW_2" {
t.Fatalf("unexpected rows after replace: %#v", rows)
}
}
func TestWeightedMedian(t *testing.T) {
got := weightedMedian([]weightedPricePoint{
{price: 10, weight: 1},
{price: 20, weight: 3},
{price: 50, weight: 1},
})
if got != 20 {
t.Fatalf("expected weighted median 20, got %v", got)
}
}
func TestWeightedMedianFallbackToMedianWhenNoWeights(t *testing.T) {
got := weightedMedian([]weightedPricePoint{
{price: 10, weight: 0},
{price: 20, weight: 0},
{price: 30, weight: 0},
})
if got != 20 {
t.Fatalf("expected fallback median 20, got %v", got)
}
}
func TestBuildWarehousePricelistItems_UsesPrefixResolver(t *testing.T) {
db := openTestDB(t)
if err := db.AutoMigrate(&models.StockLog{}, &models.Lot{}, &models.LotPartnumber{}); err != nil {
t.Fatalf("automigrate: %v", err)
}
if err := db.Create(&models.Lot{LotName: "CPU_A"}).Error; err != nil {
t.Fatalf("seed lot: %v", err)
}
qty1 := 3.0
qty2 := 1.0
now := time.Now()
rows := []models.StockLog{
{Partnumber: "CPU_A-001", Date: now, Price: 100, Qty: &qty1},
{Partnumber: "CPU_A-XYZ", Date: now, Price: 120, Qty: &qty2},
}
if err := db.Create(&rows).Error; err != nil {
t.Fatalf("seed stock_log: %v", err)
}
svc := NewStockImportService(db, nil)
items, err := svc.buildWarehousePricelistItems()
if err != nil {
t.Fatalf("buildWarehousePricelistItems: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 item, got %d", len(items))
}
if items[0].LotName != "CPU_A" {
t.Fatalf("expected lot CPU_A, got %s", items[0].LotName)
}
if items[0].Price != 100 {
t.Fatalf("expected weighted median 100, got %v", items[0].Price)
}
}
func TestPartnumberMappings_WildcardMatch(t *testing.T) {
db := openTestDB(t)
if err := db.AutoMigrate(&models.LotPartnumber{}, &models.Lot{}); err != nil {
t.Fatalf("automigrate: %v", err)
}
mappings := []models.LotPartnumber{
{Partnumber: "R750*", LotName: "SERVER_R750"},
{Partnumber: "HDD-01", LotName: "HDD_01"},
}
if err := db.Create(&mappings).Error; err != nil {
t.Fatalf("seed mappings: %v", err)
}
if err := db.Create(&models.Lot{LotName: "MEM_DDR5_16G_4800"}).Error; err != nil {
t.Fatalf("seed lot: %v", err)
}
resolver, err := lotmatch.NewMappingMatcherFromDB(db)
if err != nil {
t.Fatalf("NewMappingMatcherFromDB: %v", err)
}
if got := resolver.MatchLots("R750XD"); len(got) != 1 || got[0] != "SERVER_R750" {
t.Fatalf("expected wildcard match SERVER_R750, got %#v", got)
}
if got := resolver.MatchLots("HDD-01"); len(got) != 1 || got[0] != "HDD_01" {
t.Fatalf("expected exact match HDD_01, got %#v", got)
}
if got := resolver.MatchLots("UNKNOWN"); len(got) != 0 {
t.Fatalf("expected no matches, got %#v", got)
}
if got := resolver.MatchLots("MEM_DDR5_16G_4800"); len(got) != 1 || got[0] != "MEM_DDR5_16G_4800" {
t.Fatalf("expected exact lot fallback, got %#v", got)
}
}
func openTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
return db
}
func buildMinimalXLSX(t *testing.T, headers, values []string) []byte {
t.Helper()
var buf bytes.Buffer
zw := zip.NewWriter(&buf)
write := func(name, body string) {
w, err := zw.Create(name)
if err != nil {
t.Fatalf("create zip entry %s: %v", name, err)
}
if _, err := w.Write([]byte(body)); err != nil {
t.Fatalf("write zip entry %s: %v", name, err)
}
}
write("[Content_Types].xml", `<?xml version="1.0" encoding="UTF-8"?>
<Types xmlns="http://schemas.openxmlformats.org/package/2006/content-types">
<Default Extension="rels" ContentType="application/vnd.openxmlformats-package.relationships+xml"/>
<Default Extension="xml" ContentType="application/xml"/>
<Override PartName="/xl/workbook.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.sheet.main+xml"/>
<Override PartName="/xl/worksheets/sheet1.xml" ContentType="application/vnd.openxmlformats-officedocument.spreadsheetml.worksheet+xml"/>
</Types>`)
write("_rels/.rels", `<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/officeDocument" Target="xl/workbook.xml"/>
</Relationships>`)
write("xl/workbook.xml", `<?xml version="1.0" encoding="UTF-8"?>
<workbook xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheets>
<sheet name="Sheet1" sheetId="1" r:id="rId1" xmlns:r="http://schemas.openxmlformats.org/officeDocument/2006/relationships"/>
</sheets>
</workbook>`)
write("xl/_rels/workbook.xml.rels", `<?xml version="1.0" encoding="UTF-8"?>
<Relationships xmlns="http://schemas.openxmlformats.org/package/2006/relationships">
<Relationship Id="rId1" Type="http://schemas.openxmlformats.org/officeDocument/2006/relationships/worksheet" Target="worksheets/sheet1.xml"/>
</Relationships>`)
makeCell := func(ref, value string) string {
escaped := strings.ReplaceAll(value, "&", "&amp;")
escaped = strings.ReplaceAll(escaped, "<", "&lt;")
escaped = strings.ReplaceAll(escaped, ">", "&gt;")
return `<c r="` + ref + `" t="inlineStr"><is><t>` + escaped + `</t></is></c>`
}
cols := []string{"A", "B", "C", "D", "E", "F"}
var headerCells, valueCells strings.Builder
for i := 0; i < len(cols) && i < len(headers); i++ {
headerCells.WriteString(makeCell(cols[i]+"1", headers[i]))
}
for i := 0; i < len(cols) && i < len(values); i++ {
valueCells.WriteString(makeCell(cols[i]+"2", values[i]))
}
write("xl/worksheets/sheet1.xml", `<?xml version="1.0" encoding="UTF-8"?>
<worksheet xmlns="http://schemas.openxmlformats.org/spreadsheetml/2006/main">
<sheetData>
<row r="1">`+headerCells.String()+`</row>
<row r="2">`+valueCells.String()+`</row>
</sheetData>
</worksheet>`)
if err := zw.Close(); err != nil {
t.Fatalf("close zip: %v", err)
}
return buf.Bytes()
}

View File

@@ -71,6 +71,15 @@ func (w *Worker) runSync() {
return
}
if readiness, err := w.service.EnsureReadinessForSync(); err != nil {
w.logger.Warn("background sync: blocked by readiness guard",
"error", err,
"reason_code", readiness.ReasonCode,
"reason_text", readiness.ReasonText,
)
return
}
// Push pending changes first
pushed, err := w.service.PushPendingChanges()
if err != nil {

View File

@@ -1,219 +0,0 @@
package warehouse
import (
"sort"
"strings"
"git.mchus.pro/mchus/quoteforge/internal/lotmatch"
"git.mchus.pro/mchus/quoteforge/internal/models"
"gorm.io/gorm"
)
type SnapshotItem struct {
LotName string
Price float64
PriceMethod string
}
type weightedPricePoint struct {
price float64
weight float64
}
// ComputePricelistItemsFromStockLog builds warehouse snapshot items from stock_log.
func ComputePricelistItemsFromStockLog(db *gorm.DB) ([]SnapshotItem, error) {
type stockRow struct {
Partnumber string `gorm:"column:partnumber"`
Price float64 `gorm:"column:price"`
Qty *float64 `gorm:"column:qty"`
}
var rows []stockRow
if err := db.Table(models.StockLog{}.TableName()).Select("partnumber, price, qty").Where("price > 0").Scan(&rows).Error; err != nil {
return nil, err
}
resolver, err := lotmatch.NewLotResolverFromDB(db)
if err != nil {
return nil, err
}
grouped := make(map[string][]weightedPricePoint)
for _, row := range rows {
pn := strings.TrimSpace(row.Partnumber)
if pn == "" || row.Price <= 0 {
continue
}
lot, _, err := resolver.Resolve(pn)
if err != nil || strings.TrimSpace(lot) == "" {
continue
}
weight := 0.0
if row.Qty != nil && *row.Qty > 0 {
weight = *row.Qty
}
grouped[lot] = append(grouped[lot], weightedPricePoint{price: row.Price, weight: weight})
}
items := make([]SnapshotItem, 0, len(grouped))
for lot, values := range grouped {
price := weightedMedian(values)
if price <= 0 {
continue
}
items = append(items, SnapshotItem{LotName: lot, Price: price, PriceMethod: "weighted_median"})
}
sort.Slice(items, func(i, j int) bool { return items[i].LotName < items[j].LotName })
return items, nil
}
// LoadLotMetrics returns stock qty and partnumbers for selected lots.
// If latestOnly is true, qty/partnumbers from stock_log are calculated only for latest import date.
func LoadLotMetrics(db *gorm.DB, lotNames []string, latestOnly bool) (map[string]float64, map[string][]string, error) {
qtyByLot := make(map[string]float64, len(lotNames))
partnumbersByLot := make(map[string][]string, len(lotNames))
if len(lotNames) == 0 {
return qtyByLot, partnumbersByLot, nil
}
lotSet := make(map[string]struct{}, len(lotNames))
for _, lot := range lotNames {
trimmed := strings.TrimSpace(lot)
if trimmed == "" {
continue
}
lotSet[trimmed] = struct{}{}
}
resolver, err := lotmatch.NewLotResolverFromDB(db)
if err != nil {
return nil, nil, err
}
seenPN := make(map[string]map[string]struct{}, len(lotSet))
addPartnumber := func(lotName, partnumber string) {
lotName = strings.TrimSpace(lotName)
partnumber = strings.TrimSpace(partnumber)
if lotName == "" || partnumber == "" {
return
}
if _, ok := lotSet[lotName]; !ok {
return
}
if _, ok := seenPN[lotName]; !ok {
seenPN[lotName] = map[string]struct{}{}
}
key := strings.ToLower(partnumber)
if _, ok := seenPN[lotName][key]; ok {
return
}
seenPN[lotName][key] = struct{}{}
partnumbersByLot[lotName] = append(partnumbersByLot[lotName], partnumber)
}
var mappingRows []models.LotPartnumber
if err := db.Select("partnumber, lot_name").Find(&mappingRows).Error; err != nil {
return nil, nil, err
}
for _, row := range mappingRows {
addPartnumber(row.LotName, row.Partnumber)
}
type stockRow struct {
Partnumber string `gorm:"column:partnumber"`
Qty *float64 `gorm:"column:qty"`
}
var stockRows []stockRow
if latestOnly {
err = db.Raw(`
SELECT sl.partnumber, sl.qty
FROM stock_log sl
INNER JOIN (SELECT MAX(date) AS max_date FROM stock_log) md ON sl.date = md.max_date
`).Scan(&stockRows).Error
} else {
err = db.Table(models.StockLog{}.TableName()).Select("partnumber, qty").Scan(&stockRows).Error
}
if err != nil {
return nil, nil, err
}
for _, row := range stockRows {
pn := strings.TrimSpace(row.Partnumber)
if pn == "" {
continue
}
lot, _, err := resolver.Resolve(pn)
if err != nil {
continue
}
if _, exists := lotSet[lot]; !exists {
continue
}
if row.Qty != nil {
qtyByLot[lot] += *row.Qty
}
addPartnumber(lot, pn)
}
for lot := range partnumbersByLot {
sort.Slice(partnumbersByLot[lot], func(i, j int) bool {
return strings.ToLower(partnumbersByLot[lot][i]) < strings.ToLower(partnumbersByLot[lot][j])
})
}
return qtyByLot, partnumbersByLot, nil
}
func weightedMedian(values []weightedPricePoint) float64 {
if len(values) == 0 {
return 0
}
type pair struct {
price float64
weight float64
}
items := make([]pair, 0, len(values))
totalWeight := 0.0
prices := make([]float64, 0, len(values))
for _, v := range values {
if v.price <= 0 {
continue
}
prices = append(prices, v.price)
if v.weight > 0 {
items = append(items, pair{price: v.price, weight: v.weight})
totalWeight += v.weight
}
}
if totalWeight <= 0 {
return median(prices)
}
sort.Slice(items, func(i, j int) bool {
if items[i].price == items[j].price {
return items[i].weight < items[j].weight
}
return items[i].price < items[j].price
})
threshold := totalWeight / 2.0
acc := 0.0
for _, it := range items {
acc += it.weight
if acc >= threshold {
return it.price
}
}
return items[len(items)-1].price
}
func median(values []float64) float64 {
if len(values) == 0 {
return 0
}
cp := append([]float64(nil), values...)
sort.Float64s(cp)
n := len(cp)
if n%2 == 0 {
return (cp[n/2-1] + cp[n/2]) / 2
}
return cp[n/2]
}

View File

@@ -1,103 +0,0 @@
package warehouse
import (
"math"
"slices"
"testing"
"time"
"git.mchus.pro/mchus/quoteforge/internal/models"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
)
func TestComputePricelistItemsFromStockLog(t *testing.T) {
db := openTestDB(t)
if err := db.AutoMigrate(&models.Lot{}, &models.LotPartnumber{}, &models.StockLog{}); err != nil {
t.Fatalf("automigrate: %v", err)
}
if err := db.Create(&models.Lot{LotName: "CPU_X"}).Error; err != nil {
t.Fatalf("seed lot: %v", err)
}
if err := db.Create(&models.LotPartnumber{Partnumber: "PN-CPU-X", LotName: "CPU_X"}).Error; err != nil {
t.Fatalf("seed mapping: %v", err)
}
qtySmall := 1.0
qtyBig := 9.0
now := time.Now()
rows := []models.StockLog{
{Partnumber: "PN CPU X", Date: now, Price: 100, Qty: &qtySmall},
{Partnumber: "CPU_X-EXTRA", Date: now, Price: 200, Qty: &qtyBig},
}
if err := db.Create(&rows).Error; err != nil {
t.Fatalf("seed stock rows: %v", err)
}
items, err := ComputePricelistItemsFromStockLog(db)
if err != nil {
t.Fatalf("ComputePricelistItemsFromStockLog: %v", err)
}
if len(items) != 1 {
t.Fatalf("expected 1 item, got %d", len(items))
}
if items[0].LotName != "CPU_X" {
t.Fatalf("expected lot CPU_X, got %s", items[0].LotName)
}
if math.Abs(items[0].Price-200) > 0.001 {
t.Fatalf("expected weighted median 200, got %f", items[0].Price)
}
}
func TestLoadLotMetricsLatestOnlyIncludesPartnumbers(t *testing.T) {
db := openTestDB(t)
if err := db.AutoMigrate(&models.Lot{}, &models.LotPartnumber{}, &models.StockLog{}); err != nil {
t.Fatalf("automigrate: %v", err)
}
if err := db.Create(&models.Lot{LotName: "CPU_X"}).Error; err != nil {
t.Fatalf("seed lot: %v", err)
}
if err := db.Create(&models.LotPartnumber{Partnumber: "PN-MAPPED", LotName: "CPU_X"}).Error; err != nil {
t.Fatalf("seed mapping: %v", err)
}
oldDate := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC)
newDate := time.Date(2026, 1, 2, 0, 0, 0, 0, time.UTC)
oldQty := 10.0
newQty := 3.0
rows := []models.StockLog{
{Partnumber: "CPU_X-001", Date: oldDate, Price: 100, Qty: &oldQty},
{Partnumber: "CPU_X-001", Date: newDate, Price: 100, Qty: &newQty},
}
if err := db.Create(&rows).Error; err != nil {
t.Fatalf("seed stock rows: %v", err)
}
qtyByLot, pnsByLot, err := LoadLotMetrics(db, []string{"CPU_X"}, true)
if err != nil {
t.Fatalf("LoadLotMetrics: %v", err)
}
if got := qtyByLot["CPU_X"]; math.Abs(got-3.0) > 0.001 {
t.Fatalf("expected latest qty 3, got %f", got)
}
pns := pnsByLot["CPU_X"]
if !slices.Contains(pns, "PN-MAPPED") {
t.Fatalf("expected mapped PN-MAPPED in partnumbers, got %v", pns)
}
if !slices.Contains(pns, "CPU_X-001") {
t.Fatalf("expected stock CPU_X-001 in partnumbers, got %v", pns)
}
}
func openTestDB(t *testing.T) *gorm.DB {
t.Helper()
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{})
if err != nil {
t.Fatalf("open sqlite: %v", err)
}
return db
}