fix(pricelists): tolerate restricted DB grants and use embedded assets only

This commit is contained in:
Mikhail Chusavitin
2026-02-24 15:09:12 +03:00
parent 8a138327a3
commit af83818564
6 changed files with 40 additions and 69 deletions

View File

@@ -100,6 +100,8 @@ Database: `RFQ_LOG`
| `qt_categories` | Component categories | SELECT |
| `qt_pricelists` | Pricelists | SELECT |
| `qt_pricelist_items` | Pricelist line items | SELECT |
| `lot_partnumbers` | Partnumber → lot mapping (pricelist enrichment) | SELECT |
| `stock_log` | Latest stock qty by partnumber (pricelist enrichment) | SELECT |
| `qt_configurations` | Saved configurations (includes `line_no`) | SELECT, INSERT, UPDATE |
| `qt_projects` | Projects | SELECT, INSERT, UPDATE |
| `qt_client_local_migrations` | Migration catalog | SELECT only |
@@ -116,6 +118,8 @@ GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_categories TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelists TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.lot_partnumbers TO '<DB_USER>'@'%';
GRANT SELECT ON RFQ_LOG.stock_log TO '<DB_USER>'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO '<DB_USER>'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO '<DB_USER>'@'%';
@@ -140,6 +144,8 @@ GRANT SELECT ON RFQ_LOG.qt_lot_metadata TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_categories TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelists TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_pricelist_items TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.lot_partnumbers TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.stock_log TO 'quote_user'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_configurations TO 'quote_user'@'%';
GRANT SELECT, INSERT, UPDATE ON RFQ_LOG.qt_projects TO 'quote_user'@'%';
GRANT SELECT ON RFQ_LOG.qt_client_local_migrations TO 'quote_user'@'%';
@@ -152,6 +158,8 @@ FLUSH PRIVILEGES;
SHOW GRANTS FOR 'quote_user'@'%';
```
**Note:** If pricelists sync but show `0` positions (or logs contain `enriching pricelist items with stock` + `SELECT denied`), verify `SELECT` on `lot_partnumbers` and `stock_log` in addition to `qt_pricelist_items`.
**Note:** If you see `Access denied for user ...@'<ip>'`, check for conflicting user entries (user@localhost vs user@'%').
---

View File

@@ -555,11 +555,11 @@ func runSetupMode(local *localdb.LocalDB) {
router := gin.New()
router.Use(gin.Recovery())
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 {
if staticFS, err := qfassets.StaticFS(); err == nil {
router.StaticFS("/static", http.FS(staticFS))
} else {
slog.Error("failed to load embedded static assets", "error", err)
os.Exit(1)
}
// Setup routes only
@@ -847,11 +847,10 @@ func setupRouter(cfg *config.Config, local *localdb.LocalDB, connMgr *db.Connect
router.Use(middleware.OfflineDetector(connMgr, local))
// Static files (use filepath.Join for Windows compatibility)
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 {
if staticFS, err := qfassets.StaticFS(); err == nil {
router.StaticFS("/static", http.FS(staticFS))
} else {
return nil, nil, fmt.Errorf("load embedded static assets: %w", err)
}
// Health check

View File

@@ -6,8 +6,6 @@ import (
"log/slog"
"net"
"net/http"
"os"
"path/filepath"
"strconv"
"time"
@@ -28,7 +26,7 @@ type SetupHandler struct {
restartSig chan struct{}
}
func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, templatesPath string, restartSig chan struct{}) (*SetupHandler, error) {
func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, _ string, restartSig chan struct{}) (*SetupHandler, error) {
funcMap := template.FuncMap{
"sub": func(a, b int) int { return a - b },
"add": func(a, b int) int { return a + b },
@@ -37,14 +35,9 @@ func NewSetupHandler(localDB *localdb.LocalDB, connMgr *db.ConnectionManager, te
templates := make(map[string]*template.Template)
// Load setup template (standalone, no base needed)
setupPath := filepath.Join(templatesPath, "setup.html")
var tmpl *template.Template
var err error
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(setupPath)
} else {
tmpl, err = template.New("").Funcs(funcMap).ParseFS(qfassets.TemplatesFS, "web/templates/setup.html")
}
tmpl, err = template.New("").Funcs(funcMap).ParseFS(qfassets.TemplatesFS, "web/templates/setup.html")
if err != nil {
return nil, fmt.Errorf("parsing setup template: %w", err)
}

View File

@@ -6,8 +6,6 @@ import (
"html/template"
"log/slog"
"net/http"
"os"
"path/filepath"
stdsync "sync"
"time"
@@ -32,16 +30,9 @@ type SyncHandler struct {
}
// NewSyncHandler creates a new sync handler
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, templatesPath string, autoSyncInterval time.Duration) (*SyncHandler, error) {
func NewSyncHandler(localDB *localdb.LocalDB, syncService *sync.Service, connMgr *db.ConnectionManager, _ string, autoSyncInterval time.Duration) (*SyncHandler, error) {
// Load sync_status partial template
partialPath := filepath.Join(templatesPath, "partials", "sync_status.html")
var tmpl *template.Template
var err error
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
tmpl, err = template.ParseFiles(partialPath)
} else {
tmpl, err = template.ParseFS(qfassets.TemplatesFS, "web/templates/partials/sync_status.html")
}
tmpl, err := template.ParseFS(qfassets.TemplatesFS, "web/templates/partials/sync_status.html")
if err != nil {
return nil, err
}

View File

@@ -2,8 +2,6 @@ package handlers
import (
"html/template"
"os"
"path/filepath"
"strconv"
qfassets "git.mchus.pro/mchus/quoteforge"
@@ -17,7 +15,7 @@ type WebHandler struct {
componentService *services.ComponentService
}
func NewWebHandler(templatesPath string, componentService *services.ComponentService) (*WebHandler, error) {
func NewWebHandler(_ string, componentService *services.ComponentService) (*WebHandler, error) {
funcMap := template.FuncMap{
"sub": func(a, b int) int { return a - b },
"add": func(a, b int) int { return a + b },
@@ -60,27 +58,16 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
}
templates := make(map[string]*template.Template)
basePath := filepath.Join(templatesPath, "base.html")
useDisk := false
if stat, statErr := os.Stat(templatesPath); statErr == nil && stat.IsDir() {
useDisk = true
}
// Load each page template with base
simplePages := []string{"login.html", "configs.html", "projects.html", "project_detail.html", "pricelists.html", "pricelist_detail.html", "config_revisions.html", "partnumber_books.html"}
for _, page := range simplePages {
pagePath := filepath.Join(templatesPath, page)
var tmpl *template.Template
var err error
if useDisk {
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, pagePath)
} else {
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
qfassets.TemplatesFS,
"web/templates/base.html",
"web/templates/"+page,
)
}
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
qfassets.TemplatesFS,
"web/templates/base.html",
"web/templates/"+page,
)
if err != nil {
return nil, err
}
@@ -88,20 +75,14 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
}
// Index page needs components_list.html as well
indexPath := filepath.Join(templatesPath, "index.html")
componentsListPath := filepath.Join(templatesPath, "components_list.html")
var indexTmpl *template.Template
var err error
if useDisk {
indexTmpl, err = template.New("").Funcs(funcMap).ParseFiles(basePath, indexPath, componentsListPath)
} else {
indexTmpl, err = template.New("").Funcs(funcMap).ParseFS(
qfassets.TemplatesFS,
"web/templates/base.html",
"web/templates/index.html",
"web/templates/components_list.html",
)
}
indexTmpl, err = template.New("").Funcs(funcMap).ParseFS(
qfassets.TemplatesFS,
"web/templates/base.html",
"web/templates/index.html",
"web/templates/components_list.html",
)
if err != nil {
return nil, err
}
@@ -110,17 +91,12 @@ func NewWebHandler(templatesPath string, componentService *services.ComponentSer
// Load partial templates (no base needed)
partials := []string{"components_list.html"}
for _, partial := range partials {
partialPath := filepath.Join(templatesPath, partial)
var tmpl *template.Template
var err error
if useDisk {
tmpl, err = template.New("").Funcs(funcMap).ParseFiles(partialPath)
} else {
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
qfassets.TemplatesFS,
"web/templates/"+partial,
)
}
tmpl, err = template.New("").Funcs(funcMap).ParseFS(
qfassets.TemplatesFS,
"web/templates/"+partial,
)
if err != nil {
return nil, err
}

View File

@@ -3,6 +3,7 @@ package repository
import (
"errors"
"fmt"
"log/slog"
"sort"
"strconv"
"strings"
@@ -245,8 +246,11 @@ func (r *PricelistRepository) GetItems(pricelistID uint, offset, limit int, sear
items[i].Category = strings.TrimSpace(items[i].LotCategory)
}
// Stock/partnumber enrichment is optional for pricelist item listing.
// Return base pricelist rows (lot_name/price/category) even when DB user lacks
// access to stock mapping tables (e.g. lot_partnumbers, stock_log).
if err := r.enrichItemsWithStock(items); err != nil {
return nil, 0, fmt.Errorf("enriching pricelist items with stock: %w", err)
slog.Warn("pricelist items stock enrichment skipped", "pricelist_id", pricelistID, "error", err)
}
return items, total, nil