1145 lines
33 KiB
Go
1145 lines
33 KiB
Go
package main
|
|
|
|
import (
|
|
"bssapp-backend/db"
|
|
"bssapp-backend/queries"
|
|
"context"
|
|
"crypto/sha1"
|
|
"database/sql"
|
|
"encoding/hex"
|
|
"fmt"
|
|
"log"
|
|
"math"
|
|
"os"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"sync/atomic"
|
|
"time"
|
|
|
|
"github.com/lib/pq"
|
|
)
|
|
|
|
type productSeriesAutoStockRaw struct {
|
|
ProductCode string
|
|
ProductDescription string
|
|
ColorCode string
|
|
ColorTitle string
|
|
Dim3Code string
|
|
SizeCode string
|
|
Qty float64
|
|
UrunAnaGrubu string
|
|
UrunAltGrubu string
|
|
Marka string
|
|
DropVal string
|
|
Fit string
|
|
UrunIcerigi string
|
|
Kategori string
|
|
}
|
|
|
|
type productSeriesAutoVariant struct {
|
|
RowKey string
|
|
ProductCode string
|
|
ColorCode string
|
|
Dim3Code string
|
|
SizeQty map[string]float64
|
|
TotalQty float64
|
|
UrunAnaGrubu string
|
|
UrunAltGrubu string
|
|
Kategori string
|
|
GroupKey string
|
|
StockHash string
|
|
}
|
|
|
|
type productSeriesAutoRule struct {
|
|
SeriesID int64
|
|
Code string
|
|
Title string
|
|
GroupKey string
|
|
Ratio map[string]int
|
|
Priority int
|
|
}
|
|
|
|
type productSeriesQueueItem struct {
|
|
ID int64
|
|
RowKey string
|
|
ProductCode string
|
|
ColorCode string
|
|
Dim3Code string
|
|
Attempts int
|
|
}
|
|
|
|
type productSeriesAutoStats struct {
|
|
Scanned int
|
|
Changed int
|
|
Processed int
|
|
Written int
|
|
Skipped int
|
|
}
|
|
|
|
var productSeriesFallbackMu sync.Mutex
|
|
var productSeriesFallbackCachedCode string
|
|
var productSeriesFallbackCachedID int64
|
|
var productSeriesFallbackCachedAt time.Time
|
|
|
|
func productSeriesFallbackEnabled() bool {
|
|
// Default on: user explicitly requested "stok var ama seri yoksa 1- ata".
|
|
raw := strings.TrimSpace(strings.ToLower(os.Getenv("PRODUCT_SERIES_FALLBACK_ON_EMPTY")))
|
|
if raw == "" {
|
|
return true
|
|
}
|
|
return raw == "1" || raw == "true" || raw == "on" || raw == "yes"
|
|
}
|
|
|
|
func productSeriesFallbackCode() string {
|
|
code := strings.TrimSpace(os.Getenv("PRODUCT_SERIES_FALLBACK_SERIES_CODE"))
|
|
if code == "" {
|
|
code = "1-"
|
|
}
|
|
return code
|
|
}
|
|
|
|
func productSeriesResolveFallbackSeries(ctx context.Context, pg *sql.DB) (int64, string, error) {
|
|
if !productSeriesFallbackEnabled() {
|
|
return 0, "", nil
|
|
}
|
|
code := productSeriesFallbackCode()
|
|
if code == "" {
|
|
return 0, "", nil
|
|
}
|
|
|
|
productSeriesFallbackMu.Lock()
|
|
// Cache for a bit to avoid repeated lookups under high throughput.
|
|
if productSeriesFallbackCachedCode == code && productSeriesFallbackCachedAt.After(time.Now().Add(-10*time.Minute)) {
|
|
id := productSeriesFallbackCachedID
|
|
productSeriesFallbackMu.Unlock()
|
|
return id, code, nil
|
|
}
|
|
productSeriesFallbackMu.Unlock()
|
|
|
|
var id int64
|
|
var gotCode string
|
|
err := pg.QueryRowContext(ctx, `
|
|
SELECT id, COALESCE(code,'')
|
|
FROM dfgrp
|
|
WHERE master='zbggseri'
|
|
AND COALESCE(is_active, TRUE)=TRUE
|
|
AND (
|
|
code = $1
|
|
OR title = $1
|
|
OR title LIKE $1 || '%'
|
|
)
|
|
ORDER BY
|
|
CASE
|
|
WHEN code = $1 THEN 0
|
|
WHEN title = $1 THEN 1
|
|
ELSE 2
|
|
END,
|
|
id
|
|
LIMIT 1
|
|
`, code).Scan(&id, &gotCode)
|
|
if err != nil {
|
|
if err == sql.ErrNoRows {
|
|
// cache negative for a short period too
|
|
productSeriesFallbackMu.Lock()
|
|
productSeriesFallbackCachedCode = code
|
|
productSeriesFallbackCachedID = 0
|
|
productSeriesFallbackCachedAt = time.Now()
|
|
productSeriesFallbackMu.Unlock()
|
|
return 0, code, nil
|
|
}
|
|
return 0, code, err
|
|
}
|
|
if gotCode != "" {
|
|
code = gotCode
|
|
}
|
|
productSeriesFallbackMu.Lock()
|
|
productSeriesFallbackCachedCode = code
|
|
productSeriesFallbackCachedID = id
|
|
productSeriesFallbackCachedAt = time.Now()
|
|
productSeriesFallbackMu.Unlock()
|
|
return id, code, nil
|
|
}
|
|
|
|
var productSeriesSizeGroups = map[string][]string{
|
|
"tak": {"44", "46", "48", "50", "52", "54", "56", "58", "60", "62", "64", "66", "68", "70", "72", "74"},
|
|
"ayk": {"39", "40", "41", "42", "43", "44", "45", "46"},
|
|
"ayk_garson": {"22", "23", "24", "25", "26", "27", "28", "29", "30", "31", "32", "33", "34", "35", "36", "37"},
|
|
"yas": {"2", "4", "6", "8", "10", "12", "14"},
|
|
"pan": {"38", "40", "42", "44", "46", "48", "50", "52", "54", "56", "58", "60", "62", "64", "66", "68"},
|
|
"gom": {"XS", "S", "M", "L", "XL", "2XL", "3XL", "4XL", "5XL", "6XL", "7XL"},
|
|
"aks": {"44", "STD", "110", "115", "120", "125", "130", "135"},
|
|
}
|
|
|
|
func startProductSeriesAutoSchedulers(pgDB *sql.DB) {
|
|
enabled := strings.TrimSpace(strings.ToLower(os.Getenv("PRODUCT_SERIES_AUTO_ENABLED")))
|
|
if enabled == "0" || enabled == "false" || enabled == "off" {
|
|
log.Println("Product series auto scheduler disabled")
|
|
return
|
|
}
|
|
if pgDB == nil || db.MssqlDB == nil {
|
|
return
|
|
}
|
|
|
|
apply := envBool("PRODUCT_SERIES_AUTO_APPLY", false)
|
|
deltaIntervalMin := envIntRange("PRODUCT_SERIES_DELTA_INTERVAL_MIN", 60, 5, 1440)
|
|
queueBatch := envIntRange("PRODUCT_SERIES_DELTA_BATCH_SIZE", 300, 10, 2000)
|
|
fullHH, fullMM := envHHMM("PRODUCT_SERIES_FULL_HHMM", 5, 30)
|
|
runFullOnStartup := envBool("PRODUCT_SERIES_FULL_RUN_ON_STARTUP", false)
|
|
runDeltaOnStartup := envBool("PRODUCT_SERIES_DELTA_RUN_ON_STARTUP", false)
|
|
|
|
var deltaRunning int32
|
|
var fullRunning int32
|
|
|
|
runDelta := func(reason string) {
|
|
if !atomic.CompareAndSwapInt32(&deltaRunning, 0, 1) {
|
|
log.Printf("[ProductSeriesDelta] skip (%s): already running", reason)
|
|
return
|
|
}
|
|
defer atomic.StoreInt32(&deltaRunning, 0)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Hour)
|
|
defer cancel()
|
|
|
|
stats, err := productSeriesDetectStockChanges(ctx, pgDB, reason)
|
|
if err != nil {
|
|
log.Printf("[ProductSeriesDelta] detect_error (%s): %v", reason, err)
|
|
return
|
|
}
|
|
processed, written, skipped, err := productSeriesProcessQueue(ctx, pgDB, queueBatch, apply)
|
|
stats.Processed += processed
|
|
stats.Written += written
|
|
stats.Skipped += skipped
|
|
if err != nil {
|
|
log.Printf("[ProductSeriesDelta] process_error (%s): %v", reason, err)
|
|
return
|
|
}
|
|
log.Printf("[ProductSeriesDelta] ok (%s): scanned=%d changed=%d processed=%d written=%d skipped=%d apply=%t interval_min=%d",
|
|
reason, stats.Scanned, stats.Changed, stats.Processed, stats.Written, stats.Skipped, apply, deltaIntervalMin)
|
|
}
|
|
|
|
runFull := func(reason string) {
|
|
if !atomic.CompareAndSwapInt32(&fullRunning, 0, 1) {
|
|
log.Printf("[ProductSeriesFull] skip (%s): already running", reason)
|
|
return
|
|
}
|
|
defer atomic.StoreInt32(&fullRunning, 0)
|
|
|
|
ctx, cancel := context.WithTimeout(context.Background(), 4*time.Hour)
|
|
defer cancel()
|
|
|
|
stats, err := productSeriesFullRebuild(ctx, pgDB, apply, reason)
|
|
if err != nil {
|
|
log.Printf("[ProductSeriesFull] error (%s): %v", reason, err)
|
|
return
|
|
}
|
|
log.Printf("[ProductSeriesFull] ok (%s): scanned=%d processed=%d written=%d skipped=%d apply=%t hhmm=%02d:%02d",
|
|
reason, stats.Scanned, stats.Processed, stats.Written, stats.Skipped, apply, fullHH, fullMM)
|
|
}
|
|
|
|
go func() {
|
|
time.Sleep(3 * time.Second)
|
|
if runDeltaOnStartup {
|
|
runDelta("startup")
|
|
}
|
|
ticker := time.NewTicker(time.Duration(deltaIntervalMin) * time.Minute)
|
|
defer ticker.Stop()
|
|
for range ticker.C {
|
|
runDelta("scheduled")
|
|
}
|
|
}()
|
|
|
|
go func() {
|
|
time.Sleep(5 * time.Second)
|
|
if runFullOnStartup {
|
|
runFull("startup_manual")
|
|
}
|
|
for {
|
|
next := nextProductSeriesWeeklyRun(time.Now(), fullHH, fullMM)
|
|
wait := time.Until(next)
|
|
if wait < 0 {
|
|
wait = time.Minute
|
|
}
|
|
log.Printf("[ProductSeriesFull] scheduled next_at=%s in=%s", next.Format(time.RFC3339), wait.Round(time.Second))
|
|
time.Sleep(wait)
|
|
runFull("weekly")
|
|
}
|
|
}()
|
|
}
|
|
|
|
func productSeriesDetectStockChanges(ctx context.Context, pg *sql.DB, reason string) (productSeriesAutoStats, error) {
|
|
var stats productSeriesAutoStats
|
|
variants, scanned, err := productSeriesLoadMSSQLStockVariants(ctx)
|
|
stats.Scanned = scanned
|
|
if err != nil {
|
|
return stats, err
|
|
}
|
|
|
|
tx, err := pg.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return stats, err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
currentKeys := make([]string, 0, len(variants))
|
|
for key, v := range variants {
|
|
currentKeys = append(currentKeys, key)
|
|
var oldHash string
|
|
err := tx.QueryRowContext(ctx, `SELECT stock_hash FROM mk_product_series_stock_state WHERE row_key=$1`, key).Scan(&oldHash)
|
|
if err != nil && err != sql.ErrNoRows {
|
|
return stats, err
|
|
}
|
|
if err == sql.ErrNoRows || oldHash != v.StockHash {
|
|
if _, err := productSeriesEnqueueTx(ctx, tx, v.ProductCode, v.ColorCode, v.Dim3Code, reason); err != nil {
|
|
return stats, err
|
|
}
|
|
stats.Changed++
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO mk_product_series_stock_state (row_key, product_code, color_code, dim3_code, stock_hash, total_qty, last_seen_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, $6, now(), now())
|
|
ON CONFLICT (row_key) DO UPDATE
|
|
SET stock_hash=EXCLUDED.stock_hash,
|
|
total_qty=EXCLUDED.total_qty,
|
|
last_seen_at=now(),
|
|
updated_at=now()
|
|
`, key, v.ProductCode, v.ColorCode, v.Dim3Code, v.StockHash, v.TotalQty); err != nil {
|
|
return stats, err
|
|
}
|
|
}
|
|
|
|
rows, err := tx.QueryContext(ctx, `
|
|
SELECT row_key, product_code, color_code, dim3_code
|
|
FROM mk_product_series_stock_state
|
|
WHERE NOT (row_key = ANY($1))
|
|
`, pq.Array(currentKeys))
|
|
if err != nil {
|
|
return stats, err
|
|
}
|
|
missing := []productSeriesQueueItem{}
|
|
for rows.Next() {
|
|
var it productSeriesQueueItem
|
|
if err := rows.Scan(&it.RowKey, &it.ProductCode, &it.ColorCode, &it.Dim3Code); err != nil {
|
|
_ = rows.Close()
|
|
return stats, err
|
|
}
|
|
missing = append(missing, it)
|
|
}
|
|
_ = rows.Close()
|
|
for _, it := range missing {
|
|
if _, err := productSeriesEnqueueTx(ctx, tx, it.ProductCode, it.ColorCode, it.Dim3Code, "stock_zero_"+reason); err != nil {
|
|
return stats, err
|
|
}
|
|
stats.Changed++
|
|
}
|
|
retryCount, err := productSeriesEnqueueUnmatchedStateTx(ctx, tx, reason)
|
|
if err != nil {
|
|
return stats, err
|
|
}
|
|
stats.Changed += retryCount
|
|
|
|
return stats, tx.Commit()
|
|
}
|
|
|
|
func productSeriesFullRebuild(ctx context.Context, pg *sql.DB, apply bool, reason string) (productSeriesAutoStats, error) {
|
|
var stats productSeriesAutoStats
|
|
variants, scanned, err := productSeriesLoadMSSQLStockVariants(ctx)
|
|
stats.Scanned = scanned
|
|
if err != nil {
|
|
return stats, err
|
|
}
|
|
rules, err := productSeriesLoadRules(ctx, pg)
|
|
if err != nil {
|
|
return stats, err
|
|
}
|
|
for _, v := range variants {
|
|
written, skipped, err := productSeriesApplyVariant(ctx, pg, v, rules, apply)
|
|
if err != nil {
|
|
return stats, err
|
|
}
|
|
stats.Processed++
|
|
stats.Written += written
|
|
stats.Skipped += skipped
|
|
}
|
|
_, _ = pg.ExecContext(ctx, `
|
|
INSERT INTO mk_product_series_job_log (job_name, reason, finished_at, status, scanned_rows, processed_rows, written_rows, skipped_rows)
|
|
VALUES ('product_series_full', $1, now(), 'ok', $2, $3, $4, $5)
|
|
`, reason, stats.Scanned, stats.Processed, stats.Written, stats.Skipped)
|
|
return stats, nil
|
|
}
|
|
|
|
func productSeriesProcessQueue(ctx context.Context, pg *sql.DB, batchSize int, apply bool) (int, int, int, error) {
|
|
rules, err := productSeriesLoadRules(ctx, pg)
|
|
if err != nil {
|
|
return 0, 0, 0, err
|
|
}
|
|
variants, _, err := productSeriesLoadMSSQLStockVariants(ctx)
|
|
if err != nil {
|
|
return 0, 0, 0, err
|
|
}
|
|
totalProcessed, totalWritten, totalSkipped := 0, 0, 0
|
|
for {
|
|
tx, err := pg.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return totalProcessed, totalWritten, totalSkipped, err
|
|
}
|
|
items, err := productSeriesClaimQueue(ctx, tx, batchSize)
|
|
if err != nil {
|
|
_ = tx.Rollback()
|
|
return totalProcessed, totalWritten, totalSkipped, err
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return totalProcessed, totalWritten, totalSkipped, err
|
|
}
|
|
if len(items) == 0 {
|
|
break
|
|
}
|
|
|
|
for _, it := range items {
|
|
variant, ok := variants[productSeriesAutoKey(it.ProductCode, it.ColorCode, it.Dim3Code)]
|
|
if !ok {
|
|
variant = productSeriesAutoVariant{
|
|
RowKey: productSeriesAutoKey(it.ProductCode, it.ColorCode, it.Dim3Code),
|
|
ProductCode: it.ProductCode,
|
|
ColorCode: it.ColorCode,
|
|
Dim3Code: it.Dim3Code,
|
|
SizeQty: map[string]float64{},
|
|
}
|
|
}
|
|
written, skipped, err := productSeriesApplyVariant(ctx, pg, variant, rules, apply)
|
|
if err != nil {
|
|
_ = productSeriesMarkQueueFailed(ctx, pg, it, err.Error())
|
|
return totalProcessed, totalWritten, totalSkipped, err
|
|
}
|
|
totalProcessed++
|
|
totalWritten += written
|
|
totalSkipped += skipped
|
|
_ = productSeriesMarkQueueDone(ctx, pg, it.ID)
|
|
}
|
|
}
|
|
return totalProcessed, totalWritten, totalSkipped, nil
|
|
}
|
|
|
|
func productSeriesApplyVariant(ctx context.Context, pg *sql.DB, v productSeriesAutoVariant, rules []productSeriesAutoRule, apply bool) (int, int, error) {
|
|
mmitemID, dim1ID, dim3ID, ready, err := productSeriesResolvePGVariant(ctx, pg, v.ProductCode, v.ColorCode, v.Dim3Code)
|
|
if err != nil {
|
|
return 0, 1, err
|
|
}
|
|
if !ready {
|
|
return 0, 1, nil
|
|
}
|
|
selected := productSeriesSelectRules(v, rules)
|
|
if len(selected) == 0 {
|
|
fallbackID, _, err := productSeriesResolveFallbackSeries(ctx, pg)
|
|
if err != nil {
|
|
return 0, 1, err
|
|
}
|
|
if fallbackID > 0 {
|
|
// Only apply fallback when the variant has no assignments yet.
|
|
var exists int
|
|
checkErr := pg.QueryRowContext(ctx, `
|
|
SELECT 1
|
|
FROM zbggseri
|
|
WHERE mmitem_id=$1
|
|
AND dim1=$2
|
|
AND (($3::bigint IS NULL AND dim3 IS NULL) OR dim3=$3::bigint)
|
|
LIMIT 1
|
|
`, mmitemID, dim1ID, nullableInt64ForAuto(dim3ID)).Scan(&exists)
|
|
if checkErr == nil {
|
|
// keep existing manual/previous assignment; nothing to do
|
|
return 0, 0, nil
|
|
}
|
|
if checkErr != nil && checkErr != sql.ErrNoRows {
|
|
return 0, 1, checkErr
|
|
}
|
|
// Use the fallback series as the single selected rule.
|
|
selected = []productSeriesAutoRule{{SeriesID: fallbackID}}
|
|
} else {
|
|
return 0, 1, nil
|
|
}
|
|
}
|
|
if !apply {
|
|
return len(selected), 0, nil
|
|
}
|
|
|
|
tx, err := pg.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return 0, 0, err
|
|
}
|
|
defer tx.Rollback()
|
|
if _, err := tx.ExecContext(ctx, `
|
|
DELETE FROM zbggseri
|
|
WHERE mmitem_id=$1
|
|
AND dim1=$2
|
|
AND (($3::bigint IS NULL AND dim3 IS NULL) OR dim3=$3::bigint)
|
|
`, mmitemID, dim1ID, nullableInt64ForAuto(dim3ID)); err != nil {
|
|
return 0, 0, err
|
|
}
|
|
for _, rule := range selected {
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO zbggseri (mmitem_id, dim1, seri_id, dim3)
|
|
VALUES ($1, $2, $3, $4)
|
|
`, mmitemID, dim1ID, rule.SeriesID, nullableInt64ForAuto(dim3ID)); err != nil {
|
|
return 0, 0, err
|
|
}
|
|
}
|
|
if err := tx.Commit(); err != nil {
|
|
return 0, 0, err
|
|
}
|
|
return len(selected), 0, nil
|
|
}
|
|
|
|
func productSeriesLoadMSSQLStockVariants(ctx context.Context) (map[string]productSeriesAutoVariant, int, error) {
|
|
rows, err := db.MssqlDB.QueryContext(ctx, queries.GetProductSeriesStockRowsQuery,
|
|
"", "", "", "", "", "", "", "", "", "0", "",
|
|
)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := map[string]productSeriesAutoVariant{}
|
|
scanned := 0
|
|
for rows.Next() {
|
|
scanned++
|
|
var raw productSeriesAutoStockRaw
|
|
if err := rows.Scan(
|
|
&raw.ProductCode,
|
|
&raw.ProductDescription,
|
|
&raw.ColorCode,
|
|
&raw.ColorTitle,
|
|
&raw.Dim3Code,
|
|
&raw.SizeCode,
|
|
&raw.Qty,
|
|
&raw.UrunAnaGrubu,
|
|
&raw.UrunAltGrubu,
|
|
&raw.Marka,
|
|
&raw.DropVal,
|
|
&raw.Fit,
|
|
&raw.UrunIcerigi,
|
|
&raw.Kategori,
|
|
); err != nil {
|
|
return nil, scanned, err
|
|
}
|
|
raw.ProductCode = strings.TrimSpace(raw.ProductCode)
|
|
raw.ColorCode = strings.TrimSpace(raw.ColorCode)
|
|
raw.Dim3Code = strings.TrimSpace(raw.Dim3Code)
|
|
raw.SizeCode = normalizeProductSeriesSize(raw.SizeCode)
|
|
if raw.ProductCode == "" || raw.ColorCode == "" || raw.SizeCode == "" {
|
|
continue
|
|
}
|
|
key := productSeriesAutoKey(raw.ProductCode, raw.ColorCode, raw.Dim3Code)
|
|
v := out[key]
|
|
if v.SizeQty == nil {
|
|
v = productSeriesAutoVariant{
|
|
RowKey: key,
|
|
ProductCode: raw.ProductCode,
|
|
ColorCode: raw.ColorCode,
|
|
Dim3Code: raw.Dim3Code,
|
|
SizeQty: map[string]float64{},
|
|
UrunAnaGrubu: strings.TrimSpace(raw.UrunAnaGrubu),
|
|
UrunAltGrubu: strings.TrimSpace(raw.UrunAltGrubu),
|
|
Kategori: strings.TrimSpace(raw.Kategori),
|
|
}
|
|
}
|
|
v.SizeQty[raw.SizeCode] += raw.Qty
|
|
v.TotalQty += raw.Qty
|
|
out[key] = v
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, scanned, err
|
|
}
|
|
for key, v := range out {
|
|
v.GroupKey = productSeriesDetectGroup(v)
|
|
v.StockHash = productSeriesStockHash(v.SizeQty)
|
|
out[key] = v
|
|
}
|
|
return out, scanned, nil
|
|
}
|
|
|
|
func productSeriesLoadRules(ctx context.Context, pg *sql.DB) ([]productSeriesAutoRule, error) {
|
|
rows, err := pg.QueryContext(ctx, `
|
|
SELECT d.id, COALESCE(d.code,''), COALESCE(d.title,''),
|
|
COALESCE(r.size_group,''), COALESCE(r.size_code,''), COALESCE(r.ratio_qty,1), COALESCE(r.priority,0)
|
|
FROM dfgrp d
|
|
LEFT JOIN mk_product_series_rule r ON r.series_id=d.id AND r.is_active=TRUE
|
|
WHERE d.master='zbggseri'
|
|
AND COALESCE(d.is_active, TRUE)=TRUE
|
|
ORDER BY d.id, r.priority DESC, r.id
|
|
`)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
byID := map[int64]*productSeriesAutoRule{}
|
|
for rows.Next() {
|
|
var id int64
|
|
var code, title, group, size string
|
|
var ratio, priority int
|
|
if err := rows.Scan(&id, &code, &title, &group, &size, &ratio, &priority); err != nil {
|
|
return nil, err
|
|
}
|
|
r := byID[id]
|
|
if r == nil {
|
|
r = &productSeriesAutoRule{SeriesID: id, Code: strings.TrimSpace(code), Title: strings.TrimSpace(title), GroupKey: strings.TrimSpace(group), Ratio: map[string]int{}}
|
|
byID[id] = r
|
|
}
|
|
size = normalizeProductSeriesSize(size)
|
|
if size != "" {
|
|
if ratio <= 0 {
|
|
ratio = 1
|
|
}
|
|
r.Ratio[size] = ratio
|
|
if priority > r.Priority {
|
|
r.Priority = priority
|
|
}
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
out := make([]productSeriesAutoRule, 0, len(byID))
|
|
for _, r := range byID {
|
|
if len(r.Ratio) == 0 {
|
|
for _, part := range strings.Split(r.Title, "-") {
|
|
size := normalizeProductSeriesSize(part)
|
|
if size != "" {
|
|
r.Ratio[size] = 1
|
|
}
|
|
}
|
|
}
|
|
if len(r.Ratio) > 0 {
|
|
out = append(out, *r)
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].Priority != out[j].Priority {
|
|
return out[i].Priority > out[j].Priority
|
|
}
|
|
if len(out[i].Ratio) != len(out[j].Ratio) {
|
|
return len(out[i].Ratio) > len(out[j].Ratio)
|
|
}
|
|
return out[i].Code < out[j].Code
|
|
})
|
|
return out, nil
|
|
}
|
|
|
|
func productSeriesSelectRules(v productSeriesAutoVariant, rules []productSeriesAutoRule) []productSeriesAutoRule {
|
|
candidates := make([]productSeriesAutoRule, 0, len(rules))
|
|
for _, rule := range rules {
|
|
if !productSeriesRuleFitsGroup(v.GroupKey, rule) {
|
|
continue
|
|
}
|
|
if productSeriesCanConsume(v.SizeQty, rule) {
|
|
candidates = append(candidates, rule)
|
|
}
|
|
}
|
|
if len(candidates) == 0 {
|
|
return nil
|
|
}
|
|
if len(candidates) > 24 {
|
|
candidates = candidates[:24]
|
|
}
|
|
|
|
type bestState struct {
|
|
score int
|
|
rules []productSeriesAutoRule
|
|
}
|
|
best := bestState{score: math.MinInt}
|
|
stock := copyProductSeriesStock(v.SizeQty)
|
|
var picked []productSeriesAutoRule
|
|
var dfs func(int)
|
|
dfs = func(idx int) {
|
|
if idx >= len(candidates) {
|
|
score := productSeriesSelectionScore(stock, picked)
|
|
if score > best.score {
|
|
best.score = score
|
|
best.rules = append([]productSeriesAutoRule(nil), picked...)
|
|
}
|
|
return
|
|
}
|
|
dfs(idx + 1)
|
|
rule := candidates[idx]
|
|
if productSeriesCanConsume(stock, rule) {
|
|
productSeriesConsume(stock, rule, -1)
|
|
picked = append(picked, rule)
|
|
dfs(idx + 1)
|
|
picked = picked[:len(picked)-1]
|
|
productSeriesConsume(stock, rule, 1)
|
|
}
|
|
}
|
|
dfs(0)
|
|
sort.Slice(best.rules, func(i, j int) bool { return best.rules[i].Code < best.rules[j].Code })
|
|
return best.rules
|
|
}
|
|
|
|
func productSeriesRuleFitsGroup(group string, rule productSeriesAutoRule) bool {
|
|
if rule.GroupKey != "" && rule.GroupKey != group {
|
|
return false
|
|
}
|
|
// NOTE: In this installation, Nebim ItemDim1Code (size_code) can be numeric tokens that do not match the
|
|
// hardcoded group size lists (e.g. "XS/S/M..."). Enforcing those lists causes valid rules to be rejected
|
|
// and series assignment to stay blank across many products.
|
|
//
|
|
// We therefore rely only on explicit rule.GroupKey when provided, and otherwise allow the rule to compete
|
|
// based on actual stock consumption.
|
|
return true
|
|
}
|
|
|
|
func productSeriesCanConsume(stock map[string]float64, rule productSeriesAutoRule) bool {
|
|
for size, ratio := range rule.Ratio {
|
|
if stock[size] < float64(ratio) {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func productSeriesConsume(stock map[string]float64, rule productSeriesAutoRule, sign float64) {
|
|
for size, ratio := range rule.Ratio {
|
|
stock[size] += sign * float64(ratio)
|
|
}
|
|
}
|
|
|
|
func productSeriesSelectionScore(remaining map[string]float64, picked []productSeriesAutoRule) int {
|
|
usedQty := 0
|
|
covered := map[string]struct{}{}
|
|
priority := 0
|
|
for _, rule := range picked {
|
|
priority += rule.Priority
|
|
for size, ratio := range rule.Ratio {
|
|
usedQty += ratio
|
|
covered[size] = struct{}{}
|
|
}
|
|
}
|
|
leftQty := 0
|
|
for _, qty := range remaining {
|
|
leftQty += int(math.Round(qty))
|
|
}
|
|
return usedQty*100 + len(covered)*20 + priority - leftQty*8 - len(picked)*5
|
|
}
|
|
|
|
func productSeriesResolvePGVariant(ctx context.Context, pg *sql.DB, productCode, colorCode, dim3Code string) (int64, int64, sql.NullInt64, bool, error) {
|
|
var mmitemID int64
|
|
if err := pg.QueryRowContext(ctx, `SELECT id FROM mmitem WHERE code=$1`, strings.TrimSpace(productCode)).Scan(&mmitemID); err != nil {
|
|
if err == sql.ErrNoRows {
|
|
return 0, 0, sql.NullInt64{}, false, nil
|
|
}
|
|
return 0, 0, sql.NullInt64{}, false, err
|
|
}
|
|
dim1ID, ok, err := productSeriesResolveDimTokenID(ctx, pg, "dimval1", colorCode, mmitemID)
|
|
if err != nil {
|
|
return 0, 0, sql.NullInt64{}, false, err
|
|
}
|
|
if !ok || dim1ID <= 0 {
|
|
return 0, 0, sql.NullInt64{}, false, nil
|
|
}
|
|
var dim3ID sql.NullInt64
|
|
if strings.TrimSpace(dim3Code) != "" {
|
|
id, ok, err := productSeriesResolveDimTokenID(ctx, pg, "dimval3", dim3Code, mmitemID)
|
|
if err != nil {
|
|
return 0, 0, sql.NullInt64{}, false, err
|
|
}
|
|
if !ok || id <= 0 {
|
|
return 0, 0, sql.NullInt64{}, false, nil
|
|
}
|
|
dim3ID = sql.NullInt64{Int64: id, Valid: true}
|
|
}
|
|
return mmitemID, dim1ID, dim3ID, true, nil
|
|
}
|
|
|
|
func productSeriesResolveDimTokenID(ctx context.Context, pg *sql.DB, column string, token string, mmitemID int64) (int64, bool, error) {
|
|
tok := strings.ToUpper(strings.TrimSpace(token))
|
|
if tok == "" || tok == "0" {
|
|
return 0, false, nil
|
|
}
|
|
|
|
// dimval3 tokens like "001" can map to different dim ids per product in this installation.
|
|
// Prefer per-mmitem inference from dfblob (src_id filter) to avoid global mk_dim_token_map mismatches.
|
|
if column == "dimval3" && mmitemID > 0 {
|
|
if inferred, ok := productSeriesInferDimIDFromImages(pg, mmitemID, column, tok); ok {
|
|
return inferred, true, nil
|
|
}
|
|
}
|
|
|
|
var id int64
|
|
err := pg.QueryRowContext(ctx, `SELECT dim_id FROM mk_dim_token_map WHERE dim_column=$1 AND token=$2`, column, tok).Scan(&id)
|
|
if err == nil {
|
|
return id, id > 0, nil
|
|
}
|
|
if err != sql.ErrNoRows {
|
|
return 0, false, err
|
|
}
|
|
|
|
// Fallback: infer from dfblob filenames. For dimval3 do not persist globally.
|
|
if mmitemID > 0 {
|
|
if inferred, ok := productSeriesInferDimIDFromImages(pg, mmitemID, column, tok); ok {
|
|
return inferred, true, nil
|
|
}
|
|
}
|
|
v := productSeriesResolveDimvalFromFileNameToken(pg, column, tok, 0)
|
|
if v == "" {
|
|
return 0, false, nil
|
|
}
|
|
parsed, perr := strconv.ParseInt(v, 10, 64)
|
|
if perr != nil || parsed <= 0 {
|
|
return 0, false, nil
|
|
}
|
|
if column == "dimval1" {
|
|
// Persist only for dimval1 where tokens are globally stable.
|
|
_, _ = pg.ExecContext(ctx, `
|
|
INSERT INTO mk_dim_token_map (dim_column, token, dim_id, updated_at)
|
|
VALUES ($1,$2,$3,now())
|
|
ON CONFLICT (dim_column, token)
|
|
DO UPDATE SET dim_id = EXCLUDED.dim_id, updated_at = EXCLUDED.updated_at
|
|
`, column, tok, parsed)
|
|
}
|
|
return parsed, true, nil
|
|
}
|
|
|
|
func productSeriesBuildNameLikePatterns(token string) []string {
|
|
t := strings.ToUpper(strings.TrimSpace(token))
|
|
if t == "" {
|
|
return nil
|
|
}
|
|
return []string{
|
|
"% " + t + " %",
|
|
"%-" + t + "-%",
|
|
"%-" + t + "_%",
|
|
"%_" + t + "_%",
|
|
"%(" + t + ")%",
|
|
t + " %",
|
|
}
|
|
}
|
|
|
|
func productSeriesResolveDimvalFromFileNameToken(pg *sql.DB, column, token string, mmitemID int64) string {
|
|
patterns := productSeriesBuildNameLikePatterns(token)
|
|
if len(patterns) == 0 {
|
|
return ""
|
|
}
|
|
srcFilter := ""
|
|
args := []any{patterns[0], patterns[1], patterns[2], patterns[3], patterns[4], patterns[5]}
|
|
if mmitemID > 0 {
|
|
srcFilter = " AND src_id=$7"
|
|
args = append(args, mmitemID)
|
|
}
|
|
query := fmt.Sprintf(`
|
|
SELECT x.dimv
|
|
FROM (
|
|
SELECT COALESCE(%s::text, '') AS dimv, COUNT(*) AS cnt
|
|
FROM dfblob
|
|
WHERE src_table='mmitem'
|
|
AND typ='img'
|
|
AND COALESCE(%s::text, '') <> ''
|
|
%s
|
|
AND (
|
|
UPPER(COALESCE(file_name,'')) LIKE $1 OR
|
|
UPPER(COALESCE(file_name,'')) LIKE $2 OR
|
|
UPPER(COALESCE(file_name,'')) LIKE $3 OR
|
|
UPPER(COALESCE(file_name,'')) LIKE $4 OR
|
|
UPPER(COALESCE(file_name,'')) LIKE $5 OR
|
|
UPPER(COALESCE(file_name,'')) LIKE $6
|
|
)
|
|
GROUP BY COALESCE(%s::text, '')
|
|
) x
|
|
ORDER BY x.cnt DESC, x.dimv
|
|
LIMIT 1
|
|
`, column, column, srcFilter, column)
|
|
var v string
|
|
if err := pg.QueryRow(query, args...).Scan(&v); err != nil {
|
|
return ""
|
|
}
|
|
v = strings.TrimSpace(v)
|
|
if v == "" || v == "0" {
|
|
return ""
|
|
}
|
|
return v
|
|
}
|
|
|
|
func productSeriesInferDimIDFromImages(pg *sql.DB, mmitemID int64, column, token string) (int64, bool) {
|
|
v := productSeriesResolveDimvalFromFileNameToken(pg, column, token, mmitemID)
|
|
if v == "" {
|
|
return 0, false
|
|
}
|
|
id, err := strconv.ParseInt(v, 10, 64)
|
|
if err != nil || id <= 0 {
|
|
return 0, false
|
|
}
|
|
return id, true
|
|
}
|
|
|
|
func productSeriesClaimQueue(ctx context.Context, tx *sql.Tx, limit int) ([]productSeriesQueueItem, error) {
|
|
rows, err := tx.QueryContext(ctx, `
|
|
WITH picked AS (
|
|
SELECT id
|
|
FROM mk_product_series_recalc_queue
|
|
WHERE status='pending'
|
|
AND available_at <= now()
|
|
ORDER BY queued_at
|
|
LIMIT $1
|
|
FOR UPDATE SKIP LOCKED
|
|
)
|
|
UPDATE mk_product_series_recalc_queue q
|
|
SET status='processing', updated_at=now()
|
|
FROM picked
|
|
WHERE q.id=picked.id
|
|
RETURNING q.id, q.row_key, q.product_code, q.color_code, q.dim3_code, q.attempts
|
|
`, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []productSeriesQueueItem{}
|
|
for rows.Next() {
|
|
var it productSeriesQueueItem
|
|
if err := rows.Scan(&it.ID, &it.RowKey, &it.ProductCode, &it.ColorCode, &it.Dim3Code, &it.Attempts); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, it)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func productSeriesEnqueueTx(ctx context.Context, tx *sql.Tx, productCode, colorCode, dim3Code, reason string) (int, error) {
|
|
productCode = strings.TrimSpace(productCode)
|
|
colorCode = strings.TrimSpace(colorCode)
|
|
dim3Code = strings.TrimSpace(dim3Code)
|
|
if productCode == "" || colorCode == "" {
|
|
return 0, nil
|
|
}
|
|
_, err := tx.ExecContext(ctx, `
|
|
INSERT INTO mk_product_series_recalc_queue (row_key, product_code, color_code, dim3_code, reason, status, attempts, available_at, queued_at, last_error, created_at, updated_at)
|
|
VALUES ($1, $2, $3, $4, $5, 'pending', 0, now(), now(), '', now(), now())
|
|
ON CONFLICT (row_key) WHERE status IN ('pending','processing') DO NOTHING
|
|
`, productSeriesAutoKey(productCode, colorCode, dim3Code), productCode, colorCode, dim3Code, strings.TrimSpace(reason))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
return 1, nil
|
|
}
|
|
|
|
func productSeriesEnqueueUnmatchedStateTx(ctx context.Context, tx *sql.Tx, reason string) (int, error) {
|
|
rows, err := tx.QueryContext(ctx, `
|
|
WITH candidates AS (
|
|
SELECT
|
|
s.row_key,
|
|
s.product_code,
|
|
s.color_code,
|
|
s.dim3_code
|
|
FROM mk_product_series_stock_state s
|
|
LEFT JOIN mmitem m
|
|
ON BTRIM(m.code) = BTRIM(s.product_code)
|
|
LEFT JOIN mk_dim_token_map d1
|
|
ON d1.dim_column='dimval1'
|
|
AND BTRIM(d1.token) = BTRIM(s.color_code)
|
|
LEFT JOIN mk_dim_token_map d3
|
|
ON d3.dim_column='dimval3'
|
|
AND BTRIM(d3.token) = BTRIM(s.dim3_code)
|
|
LEFT JOIN zbggseri z
|
|
ON z.mmitem_id = m.id
|
|
AND z.dim1 = d1.dim_id
|
|
AND (
|
|
(NULLIF(BTRIM(s.dim3_code), '') IS NULL AND z.dim3 IS NULL)
|
|
OR z.dim3 = d3.dim_id
|
|
)
|
|
WHERE s.total_qty > 0
|
|
AND s.last_seen_at > now() - interval '30 days'
|
|
AND (
|
|
m.id IS NULL
|
|
OR d1.dim_id IS NULL
|
|
OR (NULLIF(BTRIM(s.dim3_code), '') IS NOT NULL AND d3.dim_id IS NULL)
|
|
OR z.id IS NULL
|
|
)
|
|
ORDER BY s.updated_at DESC
|
|
LIMIT 2000
|
|
),
|
|
inserted AS (
|
|
INSERT INTO mk_product_series_recalc_queue (row_key, product_code, color_code, dim3_code, reason, status, attempts, available_at, queued_at, last_error, created_at, updated_at)
|
|
SELECT row_key, product_code, color_code, dim3_code, $1, 'pending', 0, now(), now(), '', now(), now()
|
|
FROM candidates
|
|
ON CONFLICT (row_key) WHERE status IN ('pending','processing') DO NOTHING
|
|
RETURNING id
|
|
)
|
|
SELECT COUNT(*) FROM inserted
|
|
`, "unmatched_retry_"+strings.TrimSpace(reason))
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer rows.Close()
|
|
var count int
|
|
if rows.Next() {
|
|
if err := rows.Scan(&count); err != nil {
|
|
return 0, err
|
|
}
|
|
}
|
|
return count, rows.Err()
|
|
}
|
|
|
|
func productSeriesMarkQueueDone(ctx context.Context, pg *sql.DB, id int64) error {
|
|
_, err := pg.ExecContext(ctx, `
|
|
UPDATE mk_product_series_recalc_queue
|
|
SET status='done', processed_at=now(), updated_at=now(), last_error=''
|
|
WHERE id=$1
|
|
`, id)
|
|
return err
|
|
}
|
|
|
|
func productSeriesMarkQueueFailed(ctx context.Context, pg *sql.DB, it productSeriesQueueItem, errText string) error {
|
|
if len(errText) > 900 {
|
|
errText = errText[:900]
|
|
}
|
|
delay := 5 * time.Minute
|
|
if it.Attempts >= 1 {
|
|
delay = 15 * time.Minute
|
|
}
|
|
if it.Attempts >= 2 {
|
|
delay = 60 * time.Minute
|
|
}
|
|
_, err := pg.ExecContext(ctx, `
|
|
UPDATE mk_product_series_recalc_queue
|
|
SET status='failed',
|
|
attempts=attempts+1,
|
|
processed_at=now(),
|
|
updated_at=now(),
|
|
last_error=$2,
|
|
available_at=now() + $3::interval
|
|
WHERE id=$1
|
|
`, it.ID, strings.TrimSpace(errText), fmt.Sprintf("%d seconds", int(delay.Seconds())))
|
|
return err
|
|
}
|
|
|
|
func productSeriesDetectGroup(v productSeriesAutoVariant) string {
|
|
text := strings.ToUpper(v.Kategori + " " + v.UrunAnaGrubu + " " + v.UrunAltGrubu)
|
|
switch {
|
|
case strings.Contains(text, "AYAKKABI") && strings.Contains(text, "GARSON"):
|
|
return "ayk_garson"
|
|
case strings.Contains(text, "AYAKKABI"):
|
|
return "ayk"
|
|
case strings.Contains(text, "PANTOLON"):
|
|
return "pan"
|
|
case strings.Contains(text, "TAKIM") || strings.Contains(text, "DAMATLIK") || strings.Contains(text, "CEKET") || strings.Contains(text, "KABAN") || strings.Contains(text, "MONT") || strings.Contains(text, "YELEK"):
|
|
return "tak"
|
|
case strings.Contains(text, "GOMLEK") || strings.Contains(text, "GÖMLEK"):
|
|
return "gom"
|
|
case strings.Contains(text, "YAS") || strings.Contains(text, "YAŞ") || strings.Contains(text, "COCUK") || strings.Contains(text, "ÇOCUK"):
|
|
return "yas"
|
|
case strings.Contains(text, "AKSESUAR") || strings.Contains(text, "KRAVAT") || strings.Contains(text, "KEMER") || strings.Contains(text, "PAPYON"):
|
|
return "aks"
|
|
default:
|
|
return productSeriesDetectGroupBySizes(v.SizeQty)
|
|
}
|
|
}
|
|
|
|
func productSeriesDetectGroupBySizes(sizeQty map[string]float64) string {
|
|
bestKey := "tak"
|
|
bestCount := -1
|
|
for _, key := range []string{"tak", "pan", "gom", "ayk", "ayk_garson", "yas", "aks"} {
|
|
sizes := productSeriesSizeGroups[key]
|
|
set := map[string]struct{}{}
|
|
for _, size := range sizes {
|
|
set[normalizeProductSeriesSize(size)] = struct{}{}
|
|
}
|
|
count := 0
|
|
miss := 0
|
|
for size := range sizeQty {
|
|
if _, ok := set[size]; ok {
|
|
count++
|
|
} else {
|
|
miss++
|
|
}
|
|
}
|
|
if miss == 0 && count > bestCount {
|
|
bestKey = key
|
|
bestCount = count
|
|
}
|
|
}
|
|
return bestKey
|
|
}
|
|
|
|
func productSeriesStockHash(sizeQty map[string]float64) string {
|
|
keys := make([]string, 0, len(sizeQty))
|
|
for k := range sizeQty {
|
|
keys = append(keys, k)
|
|
}
|
|
sort.Strings(keys)
|
|
parts := make([]string, 0, len(keys))
|
|
for _, k := range keys {
|
|
parts = append(parts, fmt.Sprintf("%s=%.2f", k, sizeQty[k]))
|
|
}
|
|
sum := sha1.Sum([]byte(strings.Join(parts, "|")))
|
|
return hex.EncodeToString(sum[:])
|
|
}
|
|
|
|
func productSeriesAutoKey(productCode, colorCode, dim3Code string) string {
|
|
return strings.TrimSpace(productCode) + "|" + strings.TrimSpace(colorCode) + "|" + strings.TrimSpace(dim3Code)
|
|
}
|
|
|
|
func normalizeProductSeriesSize(v string) string {
|
|
return strings.ToUpper(strings.TrimSpace(v))
|
|
}
|
|
|
|
func copyProductSeriesStock(in map[string]float64) map[string]float64 {
|
|
out := make(map[string]float64, len(in))
|
|
for k, v := range in {
|
|
out[k] = v
|
|
}
|
|
return out
|
|
}
|
|
|
|
func nullableInt64ForAuto(v sql.NullInt64) any {
|
|
if !v.Valid {
|
|
return nil
|
|
}
|
|
return v.Int64
|
|
}
|
|
|
|
func nextProductSeriesWeeklyRun(now time.Time, hh int, mm int) time.Time {
|
|
loc := now.Location()
|
|
base := time.Date(now.Year(), now.Month(), now.Day(), hh, mm, 0, 0, loc)
|
|
daysUntilMon := (int(time.Monday) - int(now.Weekday()) + 7) % 7
|
|
candidate := base.AddDate(0, 0, daysUntilMon)
|
|
if !candidate.After(now) {
|
|
candidate = candidate.AddDate(0, 0, 7)
|
|
}
|
|
return candidate
|
|
}
|
|
|
|
func envBool(key string, fallback bool) bool {
|
|
raw := strings.TrimSpace(strings.ToLower(os.Getenv(key)))
|
|
if raw == "" {
|
|
return fallback
|
|
}
|
|
return raw == "1" || raw == "true" || raw == "on" || raw == "yes"
|
|
}
|
|
|
|
func envIntRange(key string, fallback int, min int, max int) int {
|
|
raw := strings.TrimSpace(os.Getenv(key))
|
|
if raw == "" {
|
|
return fallback
|
|
}
|
|
n, err := strconv.Atoi(raw)
|
|
if err != nil || n < min || n > max {
|
|
return fallback
|
|
}
|
|
return n
|
|
}
|
|
|
|
func envHHMM(key string, fallbackHH int, fallbackMM int) (int, int) {
|
|
raw := strings.TrimSpace(os.Getenv(key))
|
|
if raw == "" {
|
|
return fallbackHH, fallbackMM
|
|
}
|
|
parts := strings.Split(raw, ":")
|
|
if len(parts) != 2 {
|
|
return fallbackHH, fallbackMM
|
|
}
|
|
hh, errH := strconv.Atoi(strings.TrimSpace(parts[0]))
|
|
mm, errM := strconv.Atoi(strings.TrimSpace(parts[1]))
|
|
if errH != nil || errM != nil || hh < 0 || hh > 23 || mm < 0 || mm > 59 {
|
|
return fallbackHH, fallbackMM
|
|
}
|
|
return hh, mm
|
|
}
|