709 lines
22 KiB
Go
709 lines
22 KiB
Go
package main
|
|
|
|
import (
|
|
"context"
|
|
"flag"
|
|
"fmt"
|
|
"log/slog"
|
|
"net"
|
|
"net/http"
|
|
"os"
|
|
"os/exec"
|
|
"os/signal"
|
|
"path/filepath"
|
|
"runtime"
|
|
"strconv"
|
|
"strings"
|
|
"syscall"
|
|
"time"
|
|
|
|
qfassets "git.mchus.pro/mchus/priceforge"
|
|
"git.mchus.pro/mchus/priceforge/internal/appmeta"
|
|
"git.mchus.pro/mchus/priceforge/internal/appstate"
|
|
"git.mchus.pro/mchus/priceforge/internal/config"
|
|
"git.mchus.pro/mchus/priceforge/internal/db"
|
|
"git.mchus.pro/mchus/priceforge/internal/handlers"
|
|
"git.mchus.pro/mchus/priceforge/internal/middleware"
|
|
"git.mchus.pro/mchus/priceforge/internal/models"
|
|
"git.mchus.pro/mchus/priceforge/internal/repository"
|
|
"git.mchus.pro/mchus/priceforge/internal/scheduler"
|
|
"git.mchus.pro/mchus/priceforge/internal/services"
|
|
"git.mchus.pro/mchus/priceforge/internal/services/alerts"
|
|
"git.mchus.pro/mchus/priceforge/internal/services/pricelist"
|
|
"git.mchus.pro/mchus/priceforge/internal/services/pricing"
|
|
"git.mchus.pro/mchus/priceforge/internal/tasks"
|
|
"github.com/gin-gonic/gin"
|
|
mysqlDriver "github.com/go-sql-driver/mysql"
|
|
"gopkg.in/yaml.v3"
|
|
"gorm.io/driver/mysql"
|
|
"gorm.io/gorm"
|
|
"gorm.io/gorm/logger"
|
|
)
|
|
|
|
// Version is set via ldflags during build
|
|
var Version = "dev"
|
|
|
|
const backgroundSyncInterval = 5 * time.Minute
|
|
|
|
func main() {
|
|
configPath := flag.String("config", "", "path to config file (default: user state dir or QFS_CONFIG_PATH)")
|
|
migrate := flag.Bool("migrate", false, "run database migrations")
|
|
version := flag.Bool("version", false, "show version information")
|
|
flag.Parse()
|
|
|
|
// Show version if requested
|
|
if *version {
|
|
fmt.Printf("pfs version %s\n", Version)
|
|
os.Exit(0)
|
|
}
|
|
|
|
exePath, _ := os.Executable()
|
|
slog.Info("starting pfs", "version", Version, "executable", exePath)
|
|
appmeta.SetVersion(Version)
|
|
|
|
resolvedConfigPath, err := appstate.ResolveConfigPath(*configPath)
|
|
if err != nil {
|
|
slog.Error("failed to resolve config path", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
// Migrate legacy project-local config path to the user state directory when using defaults.
|
|
if *configPath == "" && os.Getenv("QFS_CONFIG_PATH") == "" {
|
|
migratedFrom, migrateErr := appstate.MigrateLegacyFile(resolvedConfigPath, []string{"config.yaml"})
|
|
if migrateErr != nil {
|
|
slog.Warn("failed to migrate legacy config file", "error", migrateErr)
|
|
} else if migratedFrom != "" {
|
|
slog.Info("migrated legacy config file", "from", migratedFrom, "to", resolvedConfigPath)
|
|
}
|
|
}
|
|
|
|
// Load config for server settings
|
|
cfg, err := config.Load(resolvedConfigPath)
|
|
if err != nil {
|
|
slog.Error("failed to load config", "path", resolvedConfigPath, "error", err)
|
|
os.Exit(1)
|
|
}
|
|
setConfigDefaults(cfg)
|
|
cfg.Server.Host = normalizeLocalHost(cfg.Server.Host)
|
|
if !isLoopbackHost(cfg.Server.Host) {
|
|
slog.Error(
|
|
"server host must be loopback-only for local mode",
|
|
"host", cfg.Server.Host,
|
|
"allowed", "127.0.0.1/localhost/::1",
|
|
"config_path", resolvedConfigPath,
|
|
)
|
|
os.Exit(1)
|
|
}
|
|
slog.Info("resolved runtime files", "config_path", resolvedConfigPath)
|
|
|
|
setupLogger(cfg.Logging)
|
|
|
|
dsn := cfg.Database.DSN()
|
|
dsnHost := net.JoinHostPort(cfg.Database.Host, fmt.Sprintf("%d", cfg.Database.Port))
|
|
connMgr := db.NewConnectionManager(dsn, dsnHost)
|
|
dbUser := cfg.Database.User
|
|
|
|
// Fail-fast mode: MariaDB must be available on startup.
|
|
mariaDB, err := connMgr.GetDB()
|
|
if err != nil {
|
|
slog.Error("failed to connect to MariaDB on startup", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
slog.Info("successfully connected to MariaDB on startup")
|
|
|
|
slog.Info("starting PriceForge server",
|
|
"version", Version,
|
|
"host", cfg.Server.Host,
|
|
"port", cfg.Server.Port,
|
|
"db_user", dbUser,
|
|
"online", true,
|
|
)
|
|
|
|
if *migrate {
|
|
slog.Info("running database migrations...")
|
|
if err := models.Migrate(mariaDB); err != nil {
|
|
slog.Error("migration failed", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
if err := models.SeedCategories(mariaDB); err != nil {
|
|
slog.Error("seeding categories failed", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
slog.Info("gorm migrations completed")
|
|
|
|
// Also apply SQL migrations in migrate-only mode.
|
|
sqlMigrationsPath := filepath.Join("migrations")
|
|
needsMigrations, err := models.NeedsSQLMigrations(mariaDB, sqlMigrationsPath)
|
|
if err != nil {
|
|
if models.IsMigrationPermissionError(err) {
|
|
slog.Info("SQL migrations skipped: insufficient database privileges", "path", sqlMigrationsPath, "error", err)
|
|
} else {
|
|
slog.Error("SQL migrations check failed", "path", sqlMigrationsPath, "error", err)
|
|
os.Exit(1)
|
|
}
|
|
} else if needsMigrations {
|
|
if err := models.RunSQLMigrations(mariaDB, sqlMigrationsPath); err != nil {
|
|
if models.IsMigrationPermissionError(err) {
|
|
slog.Info("SQL migrations skipped: insufficient database privileges", "path", sqlMigrationsPath, "error", err)
|
|
} else {
|
|
slog.Error("SQL migrations failed", "path", sqlMigrationsPath, "error", err)
|
|
os.Exit(1)
|
|
}
|
|
} else {
|
|
slog.Info("SQL migrations applied", "path", sqlMigrationsPath)
|
|
}
|
|
} else {
|
|
slog.Info("SQL migrations not needed", "path", sqlMigrationsPath)
|
|
}
|
|
|
|
slog.Info("migrate-only mode completed; exiting without starting web server")
|
|
return
|
|
}
|
|
|
|
// Always apply SQL migrations on startup when database is available.
|
|
// This keeps schema in sync for long-running installations without manual steps.
|
|
// If current DB user does not have enough privileges, continue startup in normal mode.
|
|
sqlMigrationsPath := filepath.Join("migrations")
|
|
needsMigrations, err := models.NeedsSQLMigrations(mariaDB, sqlMigrationsPath)
|
|
if err != nil {
|
|
if models.IsMigrationPermissionError(err) {
|
|
slog.Info("startup SQL migrations skipped: insufficient database privileges", "path", sqlMigrationsPath, "error", err)
|
|
} else if needsMigrations {
|
|
slog.Error("startup SQL migrations check failed", "path", sqlMigrationsPath, "error", err)
|
|
os.Exit(1)
|
|
}
|
|
} else if needsMigrations {
|
|
if err := models.RunSQLMigrations(mariaDB, sqlMigrationsPath); err != nil {
|
|
if models.IsMigrationPermissionError(err) {
|
|
slog.Info("startup SQL migrations skipped: insufficient database privileges", "path", sqlMigrationsPath, "error", err)
|
|
} else {
|
|
slog.Error("startup SQL migrations failed", "path", sqlMigrationsPath, "error", err)
|
|
os.Exit(1)
|
|
}
|
|
} else {
|
|
slog.Info("startup SQL migrations applied", "path", sqlMigrationsPath)
|
|
}
|
|
} else {
|
|
slog.Debug("startup SQL migrations not needed", "path", sqlMigrationsPath)
|
|
}
|
|
|
|
gin.SetMode(cfg.Server.Mode)
|
|
router, err := setupRouter(cfg, resolvedConfigPath, connMgr, mariaDB, dbUser)
|
|
if err != nil {
|
|
slog.Error("failed to setup router", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
appCtx, appCancel := context.WithCancel(context.Background())
|
|
defer appCancel()
|
|
startEmbeddedScheduler(appCtx, mariaDB, cfg)
|
|
|
|
srv := &http.Server{
|
|
Addr: cfg.Address(),
|
|
Handler: router,
|
|
ReadTimeout: cfg.Server.ReadTimeout,
|
|
WriteTimeout: cfg.Server.WriteTimeout,
|
|
}
|
|
|
|
go func() {
|
|
slog.Info("server listening", "address", cfg.Address())
|
|
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
|
|
slog.Error("server error", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
}()
|
|
|
|
// Automatically open browser after server starts (with a small delay)
|
|
go func() {
|
|
time.Sleep(1 * time.Second)
|
|
// Always use localhost for browser, even if server binds to 0.0.0.0
|
|
browserURL := fmt.Sprintf("http://127.0.0.1:%d", cfg.Server.Port)
|
|
slog.Info("Opening browser to", "url", browserURL)
|
|
err := openBrowser(browserURL)
|
|
if err != nil {
|
|
slog.Warn("Failed to open browser", "error", err)
|
|
}
|
|
}()
|
|
|
|
quit := make(chan os.Signal, 1)
|
|
signal.Notify(quit, syscall.SIGINT, syscall.SIGTERM)
|
|
|
|
<-quit
|
|
slog.Info("shutting down server...")
|
|
appCancel()
|
|
|
|
// Shutdown HTTP server
|
|
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
|
|
defer cancel()
|
|
|
|
if err := srv.Shutdown(ctx); err != nil {
|
|
slog.Error("server forced to shutdown", "error", err)
|
|
}
|
|
|
|
slog.Info("server stopped")
|
|
|
|
}
|
|
|
|
func startEmbeddedScheduler(ctx context.Context, mariaDB *gorm.DB, cfg *config.Config) {
|
|
if mariaDB == nil || cfg == nil || !cfg.Scheduler.Enabled {
|
|
return
|
|
}
|
|
statsRepo := repository.NewStatsRepository(mariaDB)
|
|
alertRepo := repository.NewAlertRepository(mariaDB)
|
|
componentRepo := repository.NewComponentRepository(mariaDB)
|
|
priceRepo := repository.NewPriceRepository(mariaDB)
|
|
|
|
alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
|
|
pricingService := pricing.NewService(componentRepo, priceRepo, cfg.Pricing)
|
|
embeddedScheduler := scheduler.New(mariaDB, alertService, pricingService, statsRepo, cfg.Scheduler)
|
|
go embeddedScheduler.Start(ctx)
|
|
}
|
|
|
|
func setConfigDefaults(cfg *config.Config) {
|
|
if cfg.Server.Host == "" {
|
|
cfg.Server.Host = "127.0.0.1"
|
|
}
|
|
if cfg.Server.Port == 0 {
|
|
cfg.Server.Port = 8084
|
|
}
|
|
if cfg.Server.Mode == "" {
|
|
cfg.Server.Mode = "release"
|
|
}
|
|
if cfg.Server.ReadTimeout == 0 {
|
|
cfg.Server.ReadTimeout = 30 * time.Second
|
|
}
|
|
if cfg.Server.WriteTimeout == 0 {
|
|
cfg.Server.WriteTimeout = 30 * time.Second
|
|
}
|
|
if cfg.Pricing.DefaultMethod == "" {
|
|
cfg.Pricing.DefaultMethod = "weighted_median"
|
|
}
|
|
if cfg.Pricing.DefaultPeriodDays == 0 {
|
|
cfg.Pricing.DefaultPeriodDays = 90
|
|
}
|
|
if cfg.Pricing.FreshnessGreenDays == 0 {
|
|
cfg.Pricing.FreshnessGreenDays = 30
|
|
}
|
|
if cfg.Pricing.FreshnessYellowDays == 0 {
|
|
cfg.Pricing.FreshnessYellowDays = 60
|
|
}
|
|
if cfg.Pricing.FreshnessRedDays == 0 {
|
|
cfg.Pricing.FreshnessRedDays = 90
|
|
}
|
|
if cfg.Pricing.MinQuotesForMedian == 0 {
|
|
cfg.Pricing.MinQuotesForMedian = 3
|
|
}
|
|
if cfg.Scheduler.PollInterval == 0 {
|
|
cfg.Scheduler.PollInterval = time.Minute
|
|
}
|
|
if cfg.Alerts.CheckInterval == 0 {
|
|
cfg.Alerts.CheckInterval = time.Hour
|
|
}
|
|
if cfg.Scheduler.AlertsInterval == 0 {
|
|
cfg.Scheduler.AlertsInterval = cfg.Alerts.CheckInterval
|
|
}
|
|
if cfg.Scheduler.UpdatePricesInterval == 0 {
|
|
cfg.Scheduler.UpdatePricesInterval = 24 * time.Hour
|
|
}
|
|
if cfg.Scheduler.UpdatePopularityInterval == 0 {
|
|
cfg.Scheduler.UpdatePopularityInterval = 24 * time.Hour
|
|
}
|
|
if cfg.Scheduler.ResetWeeklyCountersInterval == 0 {
|
|
cfg.Scheduler.ResetWeeklyCountersInterval = 7 * 24 * time.Hour
|
|
}
|
|
if cfg.Scheduler.ResetMonthlyCountersInterval == 0 {
|
|
cfg.Scheduler.ResetMonthlyCountersInterval = 30 * 24 * time.Hour
|
|
}
|
|
}
|
|
|
|
func isLoopbackHost(host string) bool {
|
|
h := strings.TrimSpace(strings.ToLower(host))
|
|
if h == "" {
|
|
return false
|
|
}
|
|
if h == "localhost" {
|
|
return true
|
|
}
|
|
ip := net.ParseIP(h)
|
|
return ip != nil && ip.IsLoopback()
|
|
}
|
|
|
|
func normalizeLocalHost(host string) string {
|
|
h := strings.TrimSpace(strings.ToLower(host))
|
|
switch h {
|
|
case "0.0.0.0", "::":
|
|
slog.Warn("non-loopback bind address overridden for local mode", "from", host, "to", "127.0.0.1")
|
|
return "127.0.0.1"
|
|
default:
|
|
return host
|
|
}
|
|
}
|
|
|
|
func setupLogger(cfg config.LoggingConfig) {
|
|
var level slog.Level
|
|
switch cfg.Level {
|
|
case "debug":
|
|
level = slog.LevelDebug
|
|
case "warn":
|
|
level = slog.LevelWarn
|
|
case "error":
|
|
level = slog.LevelError
|
|
default:
|
|
level = slog.LevelInfo
|
|
}
|
|
|
|
opts := &slog.HandlerOptions{Level: level}
|
|
|
|
var handler slog.Handler
|
|
if cfg.Format == "json" {
|
|
handler = slog.NewJSONHandler(os.Stdout, opts)
|
|
} else {
|
|
handler = slog.NewTextHandler(os.Stdout, opts)
|
|
}
|
|
|
|
slog.SetDefault(slog.New(handler))
|
|
}
|
|
|
|
func setupDatabaseFromDSN(dsn string) (*gorm.DB, error) {
|
|
gormLogger := logger.Default.LogMode(logger.Silent)
|
|
|
|
db, err := gorm.Open(mysql.Open(dsn), &gorm.Config{
|
|
Logger: gormLogger,
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sqlDB, err := db.DB()
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
sqlDB.SetMaxOpenConns(25)
|
|
sqlDB.SetMaxIdleConns(5)
|
|
sqlDB.SetConnMaxLifetime(5 * time.Minute)
|
|
|
|
return db, nil
|
|
}
|
|
|
|
func setupRouter(cfg *config.Config, configPath string, connMgr *db.ConnectionManager, mariaDB *gorm.DB, dbUser string) (*gin.Engine, error) {
|
|
var componentRepo *repository.ComponentRepository
|
|
var categoryRepo *repository.CategoryRepository
|
|
var priceRepo *repository.PriceRepository
|
|
var alertRepo *repository.AlertRepository
|
|
var statsRepo *repository.StatsRepository
|
|
var pricelistRepo *repository.PricelistRepository
|
|
|
|
if mariaDB != nil {
|
|
componentRepo = repository.NewComponentRepository(mariaDB)
|
|
categoryRepo = repository.NewCategoryRepository(mariaDB)
|
|
priceRepo = repository.NewPriceRepository(mariaDB)
|
|
alertRepo = repository.NewAlertRepository(mariaDB)
|
|
statsRepo = repository.NewStatsRepository(mariaDB)
|
|
pricelistRepo = repository.NewPricelistRepository(mariaDB)
|
|
}
|
|
|
|
pricingService := pricing.NewService(componentRepo, priceRepo, cfg.Pricing)
|
|
componentService := services.NewComponentService(componentRepo, categoryRepo, statsRepo)
|
|
alertService := alerts.NewService(alertRepo, componentRepo, priceRepo, statsRepo, cfg.Alerts, cfg.Pricing)
|
|
pricelistService := pricelist.NewService(mariaDB, pricelistRepo, componentRepo, pricingService)
|
|
stockImportService := services.NewStockImportService(mariaDB, pricelistService)
|
|
vendorMappingService := services.NewVendorMappingService(mariaDB)
|
|
partnumberBookService := services.NewPartnumberBookService(mariaDB)
|
|
|
|
// Create task manager
|
|
taskManager := tasks.NewManager()
|
|
|
|
templatesPath := filepath.Join("web", "templates")
|
|
componentHandler := handlers.NewComponentHandler(componentService)
|
|
pricingHandler := handlers.NewPricingHandler(
|
|
mariaDB,
|
|
pricingService,
|
|
alertService,
|
|
componentRepo,
|
|
componentService,
|
|
priceRepo,
|
|
statsRepo,
|
|
stockImportService,
|
|
vendorMappingService,
|
|
partnumberBookService,
|
|
cfg.Scheduler,
|
|
dbUser,
|
|
taskManager,
|
|
)
|
|
pricelistHandler := handlers.NewPricelistHandler(pricelistService, dbUser, taskManager)
|
|
taskHandler := tasks.NewHandler(taskManager)
|
|
setupHandler, err := handlers.NewSetupHandler(connMgr, templatesPath, cfg, func(nextCfg *config.Config) error {
|
|
return saveConfig(configPath, nextCfg)
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
webHandler, err := handlers.NewWebHandler(templatesPath, componentService)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
router := gin.New()
|
|
router.MaxMultipartMemory = 26 << 20 // 26MB; stock import handler enforces 25MB payload limit
|
|
router.Use(gin.Recovery())
|
|
router.Use(requestLogger())
|
|
router.Use(middleware.CORS())
|
|
router.Use(middleware.OfflineDetector(connMgr))
|
|
|
|
staticPath := filepath.Join("web", "static")
|
|
if stat, err := os.Stat(staticPath); err == nil && stat.IsDir() {
|
|
router.Static("/static", staticPath)
|
|
} else if staticFS, err := qfassets.StaticFS(); err == nil {
|
|
router.StaticFS("/static", http.FS(staticFS))
|
|
}
|
|
|
|
router.GET("/health", func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"status": "ok",
|
|
"time": time.Now().UTC().Format(time.RFC3339),
|
|
})
|
|
})
|
|
|
|
router.POST("/api/restart", func(c *gin.Context) {
|
|
slog.Info("Restart requested via API")
|
|
go func() {
|
|
time.Sleep(100 * time.Millisecond)
|
|
restartProcess()
|
|
}()
|
|
c.JSON(http.StatusOK, gin.H{"message": "restarting..."})
|
|
})
|
|
|
|
router.GET("/api/db-status", func(c *gin.Context) {
|
|
var lotCount, lotLogCount, metadataCount int64
|
|
var dbOK bool
|
|
var dbError string
|
|
includeCounts := c.Query("include_counts") == "true"
|
|
|
|
status := connMgr.GetStatus()
|
|
dbOK = status.IsConnected
|
|
if !status.IsConnected {
|
|
dbError = "Database not connected (offline mode)"
|
|
if status.LastError != "" {
|
|
dbError = status.LastError
|
|
}
|
|
}
|
|
|
|
if includeCounts && status.IsConnected {
|
|
if db, err := connMgr.GetDB(); err == nil && db != nil {
|
|
_ = db.Table("lot").Count(&lotCount)
|
|
_ = db.Table("lot_log").Count(&lotLogCount)
|
|
_ = db.Table("qt_lot_metadata").Count(&metadataCount)
|
|
} else if err != nil {
|
|
dbOK = false
|
|
dbError = err.Error()
|
|
} else {
|
|
dbOK = false
|
|
dbError = "Database not connected (offline mode)"
|
|
}
|
|
}
|
|
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"connected": dbOK,
|
|
"error": dbError,
|
|
"lot_count": lotCount,
|
|
"lot_log_count": lotLogCount,
|
|
"metadata_count": metadataCount,
|
|
"db_user": dbUser,
|
|
"db_host": status.DSNHost,
|
|
})
|
|
})
|
|
|
|
router.GET("/api/current-user", func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"username": dbUser,
|
|
"role": "db_user",
|
|
})
|
|
})
|
|
|
|
router.GET("/", webHandler.Index)
|
|
router.GET("/setup", setupHandler.ShowSetup)
|
|
router.POST("/setup", setupHandler.SaveConnection)
|
|
router.POST("/setup/test", setupHandler.TestConnection)
|
|
router.GET("/lot", webHandler.Lot)
|
|
router.GET("/pricelists", webHandler.Pricelists)
|
|
router.GET("/pricelists/:id", webHandler.PricelistDetail)
|
|
router.GET("/admin/pricing", webHandler.AdminPricing)
|
|
router.GET("/vendor-mappings", webHandler.VendorMappings)
|
|
|
|
partials := router.Group("/partials")
|
|
{
|
|
partials.GET("/components", webHandler.ComponentsPartial)
|
|
}
|
|
|
|
api := router.Group("/api")
|
|
{
|
|
api.GET("/ping", func(c *gin.Context) {
|
|
c.JSON(http.StatusOK, gin.H{"message": "pong"})
|
|
})
|
|
|
|
api.GET("/tasks", taskHandler.List)
|
|
api.GET("/tasks/:id", taskHandler.Get)
|
|
|
|
components := api.Group("/components")
|
|
{
|
|
components.GET("", componentHandler.List)
|
|
components.GET("/:lot_name", componentHandler.Get)
|
|
}
|
|
api.GET("/categories", componentHandler.GetCategories)
|
|
|
|
pricelists := api.Group("/pricelists")
|
|
{
|
|
pricelists.GET("", pricelistHandler.List)
|
|
pricelists.GET("/can-write", pricelistHandler.CanWrite)
|
|
pricelists.GET("/latest", pricelistHandler.GetLatest)
|
|
pricelists.GET("/:id", pricelistHandler.Get)
|
|
pricelists.GET("/:id/price-changes", pricelistHandler.GetPriceChanges)
|
|
pricelists.GET("/:id/items", pricelistHandler.GetItems)
|
|
pricelists.GET("/:id/lots", pricelistHandler.GetLotNames)
|
|
pricelists.GET("/:id/export-csv", pricelistHandler.ExportCSV)
|
|
pricelists.POST("", pricelistHandler.Create)
|
|
pricelists.POST("/create-with-progress", pricelistHandler.CreateWithProgress)
|
|
pricelists.PATCH("/:id/active", pricelistHandler.SetActive)
|
|
pricelists.DELETE("/:id", pricelistHandler.Delete)
|
|
}
|
|
|
|
pricingAdmin := api.Group("/admin/pricing")
|
|
{
|
|
pricingAdmin.GET("/stats", pricingHandler.GetStats)
|
|
pricingAdmin.GET("/components", pricingHandler.ListComponents)
|
|
pricingAdmin.GET("/components/:lot_name", pricingHandler.GetComponentPricing)
|
|
pricingAdmin.POST("/update", pricingHandler.UpdatePrice)
|
|
pricingAdmin.POST("/preview", pricingHandler.PreviewPrice)
|
|
pricingAdmin.POST("/recalculate-all", pricingHandler.RecalculateAll)
|
|
pricingAdmin.GET("/lots", pricingHandler.ListLots)
|
|
pricingAdmin.GET("/lots-table", pricingHandler.ListLotsTable)
|
|
pricingAdmin.GET("/lots/:lot_name/stats", pricingHandler.GetLotStats)
|
|
pricingAdmin.POST("/lots", pricingHandler.CreateLot)
|
|
pricingAdmin.POST("/lots/sync-metadata", pricingHandler.SyncLotsMetadata)
|
|
pricingAdmin.POST("/stock/import", pricingHandler.ImportStockLog)
|
|
pricingAdmin.GET("/stock/mappings", pricingHandler.ListStockMappings)
|
|
pricingAdmin.GET("/stock/unmapped-partnumbers", pricingHandler.GetUnmappedPartnumbers)
|
|
pricingAdmin.POST("/stock/mappings", pricingHandler.UpsertStockMapping)
|
|
pricingAdmin.DELETE("/stock/mappings/:partnumber", pricingHandler.DeleteStockMapping)
|
|
pricingAdmin.GET("/vendor-mappings", pricingHandler.ListVendorMappings)
|
|
pricingAdmin.GET("/vendor-mappings/detail", pricingHandler.GetVendorMappingDetail)
|
|
pricingAdmin.POST("/vendor-mappings", pricingHandler.UpsertVendorMapping)
|
|
pricingAdmin.POST("/vendor-mappings/import-csv", pricingHandler.ImportVendorMappingsCSV)
|
|
pricingAdmin.GET("/vendor-mappings/export-unmapped-csv", pricingHandler.ExportUnmappedVendorMappingsCSV)
|
|
pricingAdmin.DELETE("/vendor-mappings", pricingHandler.DeleteVendorMapping)
|
|
pricingAdmin.POST("/vendor-mappings/ignore", pricingHandler.IgnoreVendorMapping)
|
|
pricingAdmin.POST("/vendor-mappings/unignore", pricingHandler.UnignoreVendorMapping)
|
|
pricingAdmin.GET("/alerts", pricingHandler.ListAlerts)
|
|
pricingAdmin.POST("/alerts/:id/acknowledge", pricingHandler.AcknowledgeAlert)
|
|
pricingAdmin.POST("/alerts/:id/resolve", pricingHandler.ResolveAlert)
|
|
pricingAdmin.POST("/alerts/:id/ignore", pricingHandler.IgnoreAlert)
|
|
pricingAdmin.GET("/scheduler-runs", pricingHandler.ListSchedulerRuns)
|
|
pricingAdmin.GET("/partnumber-books", pricingHandler.ListPartnumberBooks)
|
|
pricingAdmin.POST("/partnumber-books", pricingHandler.CreatePartnumberBook)
|
|
}
|
|
|
|
}
|
|
|
|
return router, nil
|
|
}
|
|
|
|
// restartProcess restarts the current process with the same arguments
|
|
func restartProcess() {
|
|
executable, err := os.Executable()
|
|
if err != nil {
|
|
slog.Error("failed to get executable path", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
|
|
args := os.Args
|
|
env := os.Environ()
|
|
|
|
slog.Info("executing restart", "executable", executable, "args", args)
|
|
|
|
err = syscall.Exec(executable, args, env)
|
|
if err != nil {
|
|
slog.Error("failed to restart process", "error", err)
|
|
os.Exit(1)
|
|
}
|
|
}
|
|
|
|
func openBrowser(url string) error {
|
|
var cmd string
|
|
var args []string
|
|
|
|
switch runtime.GOOS {
|
|
case "windows":
|
|
cmd = "cmd"
|
|
args = []string{"/c", "start", url}
|
|
case "darwin":
|
|
cmd = "open"
|
|
args = []string{url}
|
|
default: // "linux", "freebsd", "openbsd", "netbsd"
|
|
cmd = "xdg-open"
|
|
args = []string{url}
|
|
}
|
|
|
|
return exec.Command(cmd, args...).Start()
|
|
}
|
|
|
|
func requestLogger() gin.HandlerFunc {
|
|
// Skip logging for frequent polling endpoints
|
|
skipPaths := map[string]bool{
|
|
"/api/tasks": true,
|
|
"/api/db-status": true,
|
|
}
|
|
|
|
return func(c *gin.Context) {
|
|
start := time.Now()
|
|
path := c.Request.URL.Path
|
|
query := c.Request.URL.RawQuery
|
|
|
|
c.Next()
|
|
|
|
// Skip logging for frequent polling endpoints
|
|
if skipPaths[path] {
|
|
return
|
|
}
|
|
|
|
latency := time.Since(start)
|
|
status := c.Writer.Status()
|
|
|
|
slog.Info("request",
|
|
"method", c.Request.Method,
|
|
"path", path,
|
|
"query", query,
|
|
"status", status,
|
|
"latency", latency,
|
|
"ip", c.ClientIP(),
|
|
)
|
|
}
|
|
}
|
|
|
|
func buildMySQLDSN(host string, port int, database, user, password string, timeout time.Duration) string {
|
|
cfg := mysqlDriver.NewConfig()
|
|
cfg.User = user
|
|
cfg.Passwd = password
|
|
cfg.Net = "tcp"
|
|
cfg.Addr = net.JoinHostPort(host, strconv.Itoa(port))
|
|
cfg.DBName = database
|
|
cfg.ParseTime = true
|
|
cfg.Loc = time.Local
|
|
cfg.Timeout = timeout
|
|
cfg.Params = map[string]string{
|
|
"charset": "utf8mb4",
|
|
}
|
|
return cfg.FormatDSN()
|
|
}
|
|
|
|
func saveConfig(path string, cfg *config.Config) error {
|
|
data, err := yaml.Marshal(cfg)
|
|
if err != nil {
|
|
return fmt.Errorf("marshal config: %w", err)
|
|
}
|
|
if err := os.WriteFile(path, data, 0600); err != nil {
|
|
return fmt.Errorf("write config: %w", err)
|
|
}
|
|
return nil
|
|
}
|