ui: update ProductPerformanceProfitability page to enhance table views and tabs management
This commit is contained in:
@@ -1,83 +0,0 @@
|
|||||||
package main
|
|
||||||
|
|
||||||
import (
|
|
||||||
"bssapp-backend/db"
|
|
||||||
"bssapp-backend/queries"
|
|
||||||
"context"
|
|
||||||
"encoding/json"
|
|
||||||
"flag"
|
|
||||||
"fmt"
|
|
||||||
"log"
|
|
||||||
"os"
|
|
||||||
"runtime"
|
|
||||||
"strconv"
|
|
||||||
"time"
|
|
||||||
|
|
||||||
"github.com/joho/godotenv"
|
|
||||||
)
|
|
||||||
|
|
||||||
func main() {
|
|
||||||
mode := flag.String("mode", "full", "refresh mode: full or delta")
|
|
||||||
stage := flag.String("stage", "all", "refresh stage: all, sales, stock, price, or kpi")
|
|
||||||
resumeAfter := flag.Int("resume-after", 0, "skip this many source rows before inserting; implies skip-delete")
|
|
||||||
skipDelete := flag.Bool("skip-delete", false, "skip deleting cache rows for the selected stage")
|
|
||||||
productPrefix := flag.String("product-prefix", "", "optional product code prefix filter: S, O, N, X, I, or A")
|
|
||||||
start := flag.String("start", "2022-01-01", "start date in YYYY-MM-DD")
|
|
||||||
end := flag.String("end", "", "end date in YYYY-MM-DD; default today")
|
|
||||||
mssqlTimeoutSec := flag.Int("mssql-timeout-sec", 43200, "MSSQL connection/read timeout in seconds for long refresh jobs")
|
|
||||||
flag.Parse()
|
|
||||||
|
|
||||||
_ = godotenv.Load(".env", "mail.env")
|
|
||||||
if runtime.GOOS == "windows" {
|
|
||||||
_ = godotenv.Overload(".env.local")
|
|
||||||
}
|
|
||||||
if *mssqlTimeoutSec > 0 {
|
|
||||||
timeout := strconv.Itoa(*mssqlTimeoutSec)
|
|
||||||
_ = os.Setenv("MSSQL_CONNECTION_TIMEOUT_SEC", timeout)
|
|
||||||
_ = os.Setenv("MSSQL_DIAL_TIMEOUT_SEC", timeout)
|
|
||||||
}
|
|
||||||
|
|
||||||
if err := db.ConnectMSSQL(); err != nil {
|
|
||||||
log.Fatalf("mssql connect failed: %v", err)
|
|
||||||
}
|
|
||||||
defer db.MssqlDB.Close()
|
|
||||||
|
|
||||||
pg, err := db.ConnectPostgres()
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("postgres connect failed: %v", err)
|
|
||||||
}
|
|
||||||
defer pg.Close()
|
|
||||||
|
|
||||||
startDate, err := time.Parse("2006-01-02", *start)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("invalid start date: %v", err)
|
|
||||||
}
|
|
||||||
var endDate time.Time
|
|
||||||
if *end == "" {
|
|
||||||
endDate = time.Now()
|
|
||||||
} else {
|
|
||||||
endDate, err = time.Parse("2006-01-02", *end)
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("invalid end date: %v", err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
refreshTimeout := time.Duration(*mssqlTimeoutSec) * time.Second
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), refreshTimeout)
|
|
||||||
defer cancel()
|
|
||||||
|
|
||||||
result, err := queries.RefreshProductPerformance(ctx, pg, queries.ProductPerformanceRefreshRequest{
|
|
||||||
Mode: *mode,
|
|
||||||
Stage: *stage,
|
|
||||||
ResumeAfter: *resumeAfter,
|
|
||||||
SkipDelete: *skipDelete,
|
|
||||||
ProductPrefix: *productPrefix,
|
|
||||||
StartDate: startDate,
|
|
||||||
EndDate: endDate,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
log.Fatalf("refresh failed: %v", err)
|
|
||||||
}
|
|
||||||
out, _ := json.MarshalIndent(result, "", " ")
|
|
||||||
fmt.Println(string(out))
|
|
||||||
}
|
|
||||||
@@ -989,11 +989,6 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
|
|||||||
"pricing", "view",
|
"pricing", "view",
|
||||||
wrapV3(routes.GetProductPerformanceStockSizesHandler()),
|
wrapV3(routes.GetProductPerformanceStockSizesHandler()),
|
||||||
)
|
)
|
||||||
bindV3(r, pgDB,
|
|
||||||
"/api/pricing/product-performance/refresh", "POST",
|
|
||||||
"pricing", "update",
|
|
||||||
wrapV3(routes.PostProductPerformanceRefreshHandler(pgDB)),
|
|
||||||
)
|
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/product-series/definitions", "GET",
|
"/api/pricing/product-series/definitions", "GET",
|
||||||
"pricing", "view",
|
"pricing", "view",
|
||||||
|
|||||||
@@ -25,28 +25,26 @@ func startProductPerformanceScheduler(pgDB *sql.DB) {
|
|||||||
deltaDays := productPerformanceEnvInt("PRODUCT_PERFORMANCE_DELTA_DAYS", 45, 1, 365)
|
deltaDays := productPerformanceEnvInt("PRODUCT_PERFORMANCE_DELTA_DAYS", 45, 1, 365)
|
||||||
deltaHHMM := productPerformanceEnvString("PRODUCT_PERFORMANCE_DELTA_HHMM", "02:00")
|
deltaHHMM := productPerformanceEnvString("PRODUCT_PERFORMANCE_DELTA_HHMM", "02:00")
|
||||||
deltaTimeoutHours := productPerformanceEnvInt("PRODUCT_PERFORMANCE_DELTA_TIMEOUT_HOURS", 6, 1, 24)
|
deltaTimeoutHours := productPerformanceEnvInt("PRODUCT_PERFORMANCE_DELTA_TIMEOUT_HOURS", 6, 1, 24)
|
||||||
|
runOnStartup := productPerformanceEnvBool("PRODUCT_PERFORMANCE_DELTA_RUN_ON_STARTUP", false)
|
||||||
fullEnabled := productPerformanceEnvBool("PRODUCT_PERFORMANCE_FULL_ENABLED", true)
|
|
||||||
fullWeekday := productPerformanceEnvWeekday("PRODUCT_PERFORMANCE_FULL_WEEKDAY", time.Saturday)
|
|
||||||
fullHHMM := productPerformanceEnvString("PRODUCT_PERFORMANCE_FULL_HHMM", "16:00")
|
|
||||||
fullTimeoutHours := productPerformanceEnvInt("PRODUCT_PERFORMANCE_FULL_TIMEOUT_HOURS", 18, 1, 48)
|
|
||||||
fullStart := productPerformanceEnvDate("PRODUCT_PERFORMANCE_FULL_START_DATE", time.Date(2022, 1, 1, 0, 0, 0, 0, time.Local))
|
|
||||||
|
|
||||||
resumeOnStartup := productPerformanceEnvBool("PRODUCT_PERFORMANCE_RESUME_ON_STARTUP", true)
|
|
||||||
runOnStartup := productPerformanceEnvBool("PRODUCT_PERFORMANCE_RUN_ON_STARTUP", false)
|
|
||||||
|
|
||||||
var running int32
|
var running int32
|
||||||
runRefresh := func(reason string, req queries.ProductPerformanceRefreshRequest, timeoutHours int) {
|
runDelta := func(reason string) {
|
||||||
if !atomic.CompareAndSwapInt32(&running, 0, 1) {
|
if !atomic.CompareAndSwapInt32(&running, 0, 1) {
|
||||||
log.Printf("[ProductPerformanceJob] skip (%s): already running", reason)
|
log.Printf("[ProductPerformanceJob] skip (%s): already running", reason)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
defer atomic.StoreInt32(&running, 0)
|
defer atomic.StoreInt32(&running, 0)
|
||||||
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(timeoutHours)*time.Hour)
|
endDate := time.Now()
|
||||||
|
ctx, cancel := context.WithTimeout(context.Background(), time.Duration(deltaTimeoutHours)*time.Hour)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
result, err := queries.RefreshProductPerformance(ctx, pgDB, req)
|
result, err := queries.RefreshProductPerformance(ctx, pgDB, queries.ProductPerformanceRefreshRequest{
|
||||||
|
Mode: "delta",
|
||||||
|
Stage: "all",
|
||||||
|
StartDate: endDate.AddDate(0, 0, -deltaDays),
|
||||||
|
EndDate: endDate,
|
||||||
|
})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
log.Printf("[ProductPerformanceJob] error (%s): %v", reason, err)
|
log.Printf("[ProductPerformanceJob] error (%s): %v", reason, err)
|
||||||
return
|
return
|
||||||
@@ -55,105 +53,22 @@ func startProductPerformanceScheduler(pgDB *sql.DB) {
|
|||||||
reason, result.Mode, result.Stage, result.StartDate, result.EndDate, result.SalesRows, result.StockRows, result.KpiRows, result.DurationMS)
|
reason, result.Mode, result.Stage, result.StartDate, result.EndDate, result.SalesRows, result.StockRows, result.KpiRows, result.DurationMS)
|
||||||
}
|
}
|
||||||
|
|
||||||
runDelta := func(reason string) {
|
log.Printf("[ProductPerformanceJob] scheduled daily_delta=%s lookback_days=%d run_delta_on_startup=%t",
|
||||||
endDate := time.Now()
|
deltaHHMM, deltaDays, runOnStartup)
|
||||||
runRefresh(reason, queries.ProductPerformanceRefreshRequest{
|
|
||||||
Mode: "delta",
|
|
||||||
Stage: "all",
|
|
||||||
StartDate: endDate.AddDate(0, 0, -deltaDays),
|
|
||||||
EndDate: endDate,
|
|
||||||
}, deltaTimeoutHours)
|
|
||||||
}
|
|
||||||
|
|
||||||
runFull := func(reason string) {
|
|
||||||
runRefresh(reason, queries.ProductPerformanceRefreshRequest{
|
|
||||||
Mode: "full",
|
|
||||||
Stage: "all",
|
|
||||||
StartDate: fullStart,
|
|
||||||
EndDate: time.Now(),
|
|
||||||
}, fullTimeoutHours)
|
|
||||||
}
|
|
||||||
|
|
||||||
runFullResume := func(reason string) {
|
|
||||||
resumeAfter, ok := productPerformanceStockResumeAfter(pgDB, fullStart, time.Now())
|
|
||||||
if !ok {
|
|
||||||
log.Printf("[ProductPerformanceJob] resume skip (%s): no committed full stock progress found", reason)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
runRefresh(reason, queries.ProductPerformanceRefreshRequest{
|
|
||||||
Mode: "full",
|
|
||||||
Stage: "stock",
|
|
||||||
ResumeAfter: resumeAfter,
|
|
||||||
SkipDelete: true,
|
|
||||||
StartDate: fullStart,
|
|
||||||
EndDate: time.Now(),
|
|
||||||
}, fullTimeoutHours)
|
|
||||||
}
|
|
||||||
|
|
||||||
log.Printf("[ProductPerformanceJob] scheduled daily_delta=%s lookback_days=%d weekly_full=%t %s %s full_start=%s resume_on_startup=%t",
|
|
||||||
deltaHHMM, deltaDays, fullEnabled, fullWeekday.String(), fullHHMM, fullStart.Format("2006-01-02"), resumeOnStartup)
|
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
if resumeOnStartup {
|
if runOnStartup {
|
||||||
time.Sleep(20 * time.Second)
|
time.Sleep(20 * time.Second)
|
||||||
runFullResume("startup-resume")
|
runDelta("startup-delta")
|
||||||
} else if runOnStartup {
|
|
||||||
time.Sleep(20 * time.Second)
|
|
||||||
runDelta("startup")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
for {
|
for {
|
||||||
next := productPerformanceNextDaily(time.Now(), deltaHHMM)
|
next := productPerformanceNextDaily(time.Now(), deltaHHMM)
|
||||||
log.Printf("[ProductPerformanceJob] daily delta next_at=%s in=%s", next.Format(time.RFC3339), time.Until(next).Round(time.Second))
|
log.Printf("[ProductPerformanceJob] daily delta next_at=%s in=%s", next.Format(time.RFC3339), time.Until(next).Round(time.Second))
|
||||||
time.Sleep(time.Until(next))
|
time.Sleep(time.Until(next))
|
||||||
runDelta("daily-02")
|
runDelta("daily-delta")
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
if fullEnabled {
|
|
||||||
go func() {
|
|
||||||
for {
|
|
||||||
next := productPerformanceNextWeekly(time.Now(), fullWeekday, fullHHMM)
|
|
||||||
log.Printf("[ProductPerformanceJob] weekly full next_at=%s in=%s", next.Format(time.RFC3339), time.Until(next).Round(time.Second))
|
|
||||||
time.Sleep(time.Until(next))
|
|
||||||
runFull("weekly-full")
|
|
||||||
}
|
|
||||||
}()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func productPerformanceStockResumeAfter(pgDB *sql.DB, startDate, endDate time.Time) (int, bool) {
|
|
||||||
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
|
|
||||||
defer cancel()
|
|
||||||
if err := queries.EnsureProductPerformanceTables(pgDB); err != nil {
|
|
||||||
log.Printf("[ProductPerformanceJob] resume inspect failed: %v", err)
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
var stockRows int
|
|
||||||
var maxStockDate sql.NullTime
|
|
||||||
var maxKPIDate sql.NullTime
|
|
||||||
if err := pgDB.QueryRowContext(ctx, `
|
|
||||||
SELECT COUNT(*), MAX(stock_date)
|
|
||||||
FROM mk_product_performance_stock_daily
|
|
||||||
WHERE stock_date BETWEEN $1 AND $2
|
|
||||||
`, startDate, endDate).Scan(&stockRows, &maxStockDate); err != nil {
|
|
||||||
log.Printf("[ProductPerformanceJob] resume inspect stock failed: %v", err)
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
if stockRows <= 0 {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
if err := pgDB.QueryRowContext(ctx, `
|
|
||||||
SELECT MAX(kpi_date)
|
|
||||||
FROM mk_product_performance_kpi_daily
|
|
||||||
`).Scan(&maxKPIDate); err != nil {
|
|
||||||
log.Printf("[ProductPerformanceJob] resume inspect kpi failed: %v", err)
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
if maxStockDate.Valid && maxKPIDate.Valid && !maxStockDate.Time.After(maxKPIDate.Time) {
|
|
||||||
return 0, false
|
|
||||||
}
|
|
||||||
return stockRows, true
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func productPerformanceNextDaily(now time.Time, hhmm string) time.Time {
|
func productPerformanceNextDaily(now time.Time, hhmm string) time.Time {
|
||||||
@@ -165,17 +80,6 @@ func productPerformanceNextDaily(now time.Time, hhmm string) time.Time {
|
|||||||
return next
|
return next
|
||||||
}
|
}
|
||||||
|
|
||||||
func productPerformanceNextWeekly(now time.Time, weekday time.Weekday, hhmm string) time.Time {
|
|
||||||
hour, minute := productPerformanceParseHHMM(hhmm, 16, 0)
|
|
||||||
next := time.Date(now.Year(), now.Month(), now.Day(), hour, minute, 0, 0, now.Location())
|
|
||||||
days := (int(weekday) - int(now.Weekday()) + 7) % 7
|
|
||||||
next = next.AddDate(0, 0, days)
|
|
||||||
if !next.After(now) {
|
|
||||||
next = next.AddDate(0, 0, 7)
|
|
||||||
}
|
|
||||||
return next
|
|
||||||
}
|
|
||||||
|
|
||||||
func productPerformanceParseHHMM(raw string, fallbackHour, fallbackMinute int) (int, int) {
|
func productPerformanceParseHHMM(raw string, fallbackHour, fallbackMinute int) (int, int) {
|
||||||
parts := strings.Split(strings.TrimSpace(raw), ":")
|
parts := strings.Split(strings.TrimSpace(raw), ":")
|
||||||
if len(parts) != 2 {
|
if len(parts) != 2 {
|
||||||
@@ -216,36 +120,3 @@ func productPerformanceEnvBool(name string, fallback bool) bool {
|
|||||||
}
|
}
|
||||||
return raw == "1" || raw == "true" || raw == "on" || raw == "yes"
|
return raw == "1" || raw == "true" || raw == "on" || raw == "yes"
|
||||||
}
|
}
|
||||||
|
|
||||||
func productPerformanceEnvDate(name string, fallback time.Time) time.Time {
|
|
||||||
raw := strings.TrimSpace(os.Getenv(name))
|
|
||||||
if raw == "" {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
t, err := time.ParseInLocation("2006-01-02", raw, time.Local)
|
|
||||||
if err != nil {
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|
||||||
func productPerformanceEnvWeekday(name string, fallback time.Weekday) time.Weekday {
|
|
||||||
switch strings.ToLower(strings.TrimSpace(os.Getenv(name))) {
|
|
||||||
case "0", "sunday", "pazar":
|
|
||||||
return time.Sunday
|
|
||||||
case "1", "monday", "pazartesi":
|
|
||||||
return time.Monday
|
|
||||||
case "2", "tuesday", "sali", "salı":
|
|
||||||
return time.Tuesday
|
|
||||||
case "3", "wednesday", "carsamba", "çarşamba":
|
|
||||||
return time.Wednesday
|
|
||||||
case "4", "thursday", "persembe", "perşembe":
|
|
||||||
return time.Thursday
|
|
||||||
case "5", "friday", "cuma":
|
|
||||||
return time.Friday
|
|
||||||
case "6", "saturday", "cumartesi":
|
|
||||||
return time.Saturday
|
|
||||||
default:
|
|
||||||
return fallback
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -240,49 +240,6 @@ func GetProductPerformanceCustomersHandler(pg *sql.DB) http.HandlerFunc {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
type productPerformanceRefreshPayload struct {
|
|
||||||
Mode string `json:"mode"`
|
|
||||||
Stage string `json:"stage"`
|
|
||||||
ResumeAfter int `json:"resume_after"`
|
|
||||||
SkipDelete bool `json:"skip_delete"`
|
|
||||||
ProductPrefix string `json:"product_prefix"`
|
|
||||||
StartDate string `json:"start_date"`
|
|
||||||
EndDate string `json:"end_date"`
|
|
||||||
}
|
|
||||||
|
|
||||||
func PostProductPerformanceRefreshHandler(pg *sql.DB) http.HandlerFunc {
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
|
||||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
||||||
var payload productPerformanceRefreshPayload
|
|
||||||
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
||||||
http.Error(w, "invalid payload", http.StatusBadRequest)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
now := time.Now()
|
|
||||||
startDate := parseDateOrZero(payload.StartDate)
|
|
||||||
endDate := parseDateOrZero(payload.EndDate)
|
|
||||||
if endDate.IsZero() {
|
|
||||||
endDate = now
|
|
||||||
}
|
|
||||||
ctx, cancel := context.WithTimeout(r.Context(), 4*time.Hour)
|
|
||||||
defer cancel()
|
|
||||||
result, err := queries.RefreshProductPerformance(ctx, pg, queries.ProductPerformanceRefreshRequest{
|
|
||||||
Mode: payload.Mode,
|
|
||||||
Stage: payload.Stage,
|
|
||||||
ResumeAfter: payload.ResumeAfter,
|
|
||||||
SkipDelete: payload.SkipDelete,
|
|
||||||
ProductPrefix: payload.ProductPrefix,
|
|
||||||
StartDate: startDate,
|
|
||||||
EndDate: endDate,
|
|
||||||
})
|
|
||||||
if err != nil {
|
|
||||||
http.Error(w, "urun performans refresh hatasi: "+err.Error(), http.StatusInternalServerError)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
_ = json.NewEncoder(w).Encode(result)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
func intQuery(r *http.Request, key string, fallback int) int {
|
func intQuery(r *http.Request, key string, fallback int) int {
|
||||||
raw := strings.TrimSpace(r.URL.Query().Get(key))
|
raw := strings.TrimSpace(r.URL.Query().Get(key))
|
||||||
if raw == "" {
|
if raw == "" {
|
||||||
@@ -302,15 +259,3 @@ func boolQuery(r *http.Request, key string, fallback bool) bool {
|
|||||||
}
|
}
|
||||||
return raw == "1" || raw == "true" || raw == "yes" || raw == "on"
|
return raw == "1" || raw == "true" || raw == "yes" || raw == "on"
|
||||||
}
|
}
|
||||||
|
|
||||||
func parseDateOrZero(raw string) time.Time {
|
|
||||||
raw = strings.TrimSpace(raw)
|
|
||||||
if raw == "" {
|
|
||||||
return time.Time{}
|
|
||||||
}
|
|
||||||
t, err := time.Parse("2006-01-02", raw)
|
|
||||||
if err != nil {
|
|
||||||
return time.Time{}
|
|
||||||
}
|
|
||||||
return t
|
|
||||||
}
|
|
||||||
|
|||||||
Reference in New Issue
Block a user