Merge remote-tracking branch 'origin/master'

This commit is contained in:
M_Kececi
2026-06-22 22:25:43 +03:00
parent a839cae840
commit 4e36e2b057
14 changed files with 2946 additions and 372 deletions

View File

@@ -925,6 +925,36 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
"pricing", "update",
wrapV3(routes.PostProductPricingSaveHandler(pgDB, ml)),
)
bindV3(r, pgDB,
"/api/pricing/product-series/definitions", "GET",
"pricing", "view",
wrapV3(http.HandlerFunc(routes.GetProductSeriesDefinitionsHandler(pgDB))),
)
bindV3(r, pgDB,
"/api/pricing/product-series/definitions", "POST",
"pricing", "update",
wrapV3(http.HandlerFunc(routes.PostProductSeriesDefinitionHandler(pgDB))),
)
bindV3(r, pgDB,
"/api/pricing/product-series/definitions/{id}", "PUT",
"pricing", "update",
wrapV3(http.HandlerFunc(routes.PutProductSeriesDefinitionHandler(pgDB))),
)
bindV3(r, pgDB,
"/api/pricing/product-series/definitions/{id}", "DELETE",
"pricing", "update",
wrapV3(http.HandlerFunc(routes.DeleteProductSeriesDefinitionHandler(pgDB))),
)
bindV3(r, pgDB,
"/api/pricing/product-series/mappings", "GET",
"pricing", "view",
wrapV3(http.HandlerFunc(routes.GetProductSeriesMappingsHandler(pgDB))),
)
bindV3(r, pgDB,
"/api/pricing/product-series/mappings/save", "POST",
"pricing", "update",
wrapV3(http.HandlerFunc(routes.PostProductSeriesMappingsSaveHandler(pgDB))),
)
bindV3(r, pgDB,
"/api/pricing/wholesale-campaigns", "GET",
"pricing", "view",
@@ -1330,6 +1360,9 @@ func main() {
if err := queries.EnsurePricingCalcInfraTables(pgDB); err != nil {
log.Println("pricing calc infra bootstrap failed:", err)
}
if err := queries.EnsureProductSeriesAutoInfraTables(pgDB); err != nil {
log.Println("product series auto infra bootstrap failed:", err)
}
// -------------------------------------------------------
// ✉️ MAILER INIT
@@ -1355,6 +1388,7 @@ func main() {
startProductPricingCalcScheduler(pgDB)
startProductPricingFxDeltaScheduler(pgDB)
startProductPricingFxFullScheduler(pgDB)
startProductSeriesAutoSchedulers(pgDB)
handler := enableCORS(
middlewares.GlobalAuthMiddleware(

View File

@@ -0,0 +1,866 @@
package main
import (
"bssapp-backend/db"
"bssapp-backend/queries"
"context"
"crypto/sha1"
"database/sql"
"encoding/hex"
"fmt"
"log"
"math"
"os"
"sort"
"strconv"
"strings"
"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 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++
}
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
}
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, err := productSeriesLoadSingleVariant(ctx, it.ProductCode, it.ColorCode, it.Dim3Code)
if err != nil {
_ = productSeriesMarkQueueFailed(ctx, pg, it, err.Error())
return totalProcessed, totalWritten, totalSkipped, err
}
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 !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 productSeriesLoadSingleVariant(ctx context.Context, productCode, colorCode, dim3Code string) (productSeriesAutoVariant, bool, error) {
variants, _, err := productSeriesLoadMSSQLStockVariants(ctx)
if err != nil {
return productSeriesAutoVariant{}, false, err
}
key := productSeriesAutoKey(productCode, colorCode, dim3Code)
v, ok := variants[key]
return v, ok, 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
}
groupSizes := map[string]struct{}{}
for _, size := range productSeriesSizeGroups[group] {
groupSizes[normalizeProductSeriesSize(size)] = struct{}{}
}
if len(groupSizes) == 0 {
return true
}
for size := range rule.Ratio {
if _, ok := groupSizes[size]; !ok {
return false
}
}
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
}
var dim1ID int64
if err := pg.QueryRowContext(ctx, `SELECT dim_id FROM mk_dim_token_map WHERE dim_column='dimval1' AND token=$1`, strings.TrimSpace(colorCode)).Scan(&dim1ID); err != nil {
if err == sql.ErrNoRows {
return 0, 0, sql.NullInt64{}, false, nil
}
return 0, 0, sql.NullInt64{}, false, err
}
var dim3ID sql.NullInt64
if strings.TrimSpace(dim3Code) != "" {
var id int64
if err := pg.QueryRowContext(ctx, `SELECT dim_id FROM mk_dim_token_map WHERE dim_column='dimval3' AND token=$1`, strings.TrimSpace(dim3Code)).Scan(&id); err != nil {
if err == sql.ErrNoRows {
return 0, 0, sql.NullInt64{}, false, nil
}
return 0, 0, sql.NullInt64{}, false, err
}
dim3ID = sql.NullInt64{Int64: id, Valid: true}
}
return mmitemID, dim1ID, dim3ID, true, nil
}
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())
`, productSeriesAutoKey(productCode, colorCode, dim3Code), productCode, colorCode, dim3Code, strings.TrimSpace(reason))
if err != nil {
if pe, ok := err.(*pq.Error); ok && pe != nil && string(pe.Code) == "23505" {
return 0, nil
}
return 0, err
}
return 1, nil
}
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
}

View File

@@ -0,0 +1,198 @@
package queries
const GetProductSeriesStockRowsQuery = `
DECLARE @Kategori NVARCHAR(100) = NULLIF(LTRIM(RTRIM(@p1)), '');
DECLARE @UrunAnaGrubu NVARCHAR(100) = NULLIF(LTRIM(RTRIM(@p2)), '');
DECLARE @UrunAltGrubuList NVARCHAR(MAX) = NULLIF(LTRIM(RTRIM(@p3)), '');
DECLARE @RenkList NVARCHAR(MAX) = NULLIF(LTRIM(RTRIM(@p4)), '');
DECLARE @Renk2List NVARCHAR(MAX) = NULLIF(LTRIM(RTRIM(@p5)), '');
DECLARE @UrunIcerigiList NVARCHAR(MAX) = NULLIF(LTRIM(RTRIM(@p6)), '');
DECLARE @FitList NVARCHAR(MAX) = NULLIF(LTRIM(RTRIM(@p7)), '');
DECLARE @DropList NVARCHAR(MAX) = NULLIF(LTRIM(RTRIM(@p8)), '');
DECLARE @BedenList NVARCHAR(MAX) = NULLIF(LTRIM(RTRIM(@p9)), '');
DECLARE @Search NVARCHAR(80) = NULLIF(LTRIM(RTRIM(@p11)), '');
DECLARE @RowLimit INT;
IF ISNUMERIC(@p10) = 1
BEGIN
SET @RowLimit = CONVERT(INT, @p10);
END;
IF @RowLimit IS NULL OR @RowLimit <= 0
BEGIN
SET @RowLimit = 2147483647;
END;
CREATE TABLE #AttrFiltered
(
ProductCode NVARCHAR(50) NOT NULL,
ProductDescription NVARCHAR(255) NULL,
ProductAtt01Desc NVARCHAR(255) NULL,
ProductAtt02Desc NVARCHAR(255) NULL,
ProductAtt10Desc NVARCHAR(255) NULL,
ProductAtt11Desc NVARCHAR(255) NULL,
ProductAtt38Desc NVARCHAR(255) NULL,
ProductAtt41Desc NVARCHAR(255) NULL,
ProductAtt44Desc NVARCHAR(255) NULL
);
IF OBJECT_ID('dbo.ProductFilterTRCache','U') IS NOT NULL
BEGIN
INSERT INTO #AttrFiltered
SELECT
C.ProductCode,
C.ProductDescription,
C.ProductAtt01Desc,
C.ProductAtt02Desc,
ISNULL(PF.ProductAtt10Desc, '') AS ProductAtt10Desc,
C.ProductAtt11Desc,
C.ProductAtt38Desc,
C.ProductAtt41Desc,
C.ProductAtt44Desc
FROM dbo.ProductFilterTRCache C
LEFT JOIN ProductFilterWithDescription('TR') PF ON PF.ProductCode = C.ProductCode
WHERE LEN(C.ProductCode) = 13
AND (@Search IS NULL OR C.ProductCode LIKE '%' + @Search + '%' OR C.ProductDescription LIKE '%' + @Search + '%')
AND (@Kategori IS NULL OR C.ProductAtt44Desc = @Kategori)
AND (@UrunAnaGrubu IS NULL OR C.ProductAtt01Desc = @UrunAnaGrubu)
AND (@UrunAltGrubuList IS NULL OR CHARINDEX(NCHAR(31) + ISNULL(C.ProductAtt02Desc,'') + NCHAR(31), NCHAR(31) + @UrunAltGrubuList + NCHAR(31)) > 0)
AND (@UrunIcerigiList IS NULL OR CHARINDEX(NCHAR(31) + ISNULL(C.ProductAtt41Desc,'') + NCHAR(31), NCHAR(31) + @UrunIcerigiList + NCHAR(31)) > 0)
AND (@FitList IS NULL OR CHARINDEX(NCHAR(31) + ISNULL(C.ProductAtt38Desc,'') + NCHAR(31), NCHAR(31) + @FitList + NCHAR(31)) > 0)
AND (@DropList IS NULL OR CHARINDEX(NCHAR(31) + ISNULL(C.ProductAtt11Desc,'') + NCHAR(31), NCHAR(31) + @DropList + NCHAR(31)) > 0);
END
ELSE
BEGIN
INSERT INTO #AttrFiltered
SELECT
ProductCode,
ProductDescription,
ProductAtt01Desc,
ProductAtt02Desc,
ProductAtt10Desc,
ProductAtt11Desc,
ProductAtt38Desc,
ProductAtt41Desc,
ProductAtt44Desc
FROM ProductFilterWithDescription('TR')
WHERE LEN(ProductCode) = 13
AND (@Search IS NULL OR ProductCode LIKE '%' + @Search + '%' OR ProductDescription LIKE '%' + @Search + '%')
AND (@Kategori IS NULL OR ProductAtt44Desc = @Kategori)
AND (@UrunAnaGrubu IS NULL OR ProductAtt01Desc = @UrunAnaGrubu)
AND (@UrunAltGrubuList IS NULL OR CHARINDEX(NCHAR(31) + ISNULL(ProductAtt02Desc,'') + NCHAR(31), NCHAR(31) + @UrunAltGrubuList + NCHAR(31)) > 0)
AND (@UrunIcerigiList IS NULL OR CHARINDEX(NCHAR(31) + ISNULL(ProductAtt41Desc,'') + NCHAR(31), NCHAR(31) + @UrunIcerigiList + NCHAR(31)) > 0)
AND (@FitList IS NULL OR CHARINDEX(NCHAR(31) + ISNULL(ProductAtt38Desc,'') + NCHAR(31), NCHAR(31) + @FitList + NCHAR(31)) > 0)
AND (@DropList IS NULL OR CHARINDEX(NCHAR(31) + ISNULL(ProductAtt11Desc,'') + NCHAR(31), NCHAR(31) + @DropList + NCHAR(31)) > 0);
END;
CREATE CLUSTERED INDEX IX_AttrFiltered_ProductCode ON #AttrFiltered(ProductCode);
;WITH Inv AS
(
SELECT
X.ItemCode,
X.ColorCode,
X.ItemDim1Code,
X.ItemDim2Code,
SUM(X.InventoryQty1) AS InventoryQty1,
SUM(X.PickingQty1) AS PickingQty1,
SUM(X.ReserveQty1) AS ReserveQty1,
SUM(X.DispOrderQty1) AS DispOrderQty1
FROM
(
SELECT T.ItemCode, T.ColorCode, T.ItemDim1Code, T.ItemDim2Code,
SUM(T.In_Qty1 - T.Out_Qty1) AS InventoryQty1, 0 AS PickingQty1, 0 AS ReserveQty1, 0 AS DispOrderQty1
FROM trStock T WITH (NOLOCK)
INNER JOIN #AttrFiltered AF ON AF.ProductCode = T.ItemCode
WHERE T.ItemTypeCode = 1
AND LEN(T.ItemCode) = 13
AND T.WarehouseCode IN (
'1-0-14','1-0-10','1-0-8','1-2-5','1-2-4','1-0-12','100','1-0-28',
'1-0-24','1-2-6','1-1-14','1-0-2','1-0-52','1-1-2','1-0-21','1-1-3',
'1-0-33','101','1-014','1-0-49','1-0-36'
)
GROUP BY T.ItemCode, T.ColorCode, T.ItemDim1Code, T.ItemDim2Code
UNION ALL
SELECT P.ItemCode, P.ColorCode, P.ItemDim1Code, P.ItemDim2Code, 0, SUM(P.Qty1), 0, 0
FROM PickingStates P
INNER JOIN #AttrFiltered AF ON AF.ProductCode = P.ItemCode
WHERE P.ItemTypeCode = 1 AND LEN(P.ItemCode) = 13
GROUP BY P.ItemCode, P.ColorCode, P.ItemDim1Code, P.ItemDim2Code
UNION ALL
SELECT R.ItemCode, R.ColorCode, R.ItemDim1Code, R.ItemDim2Code, 0, 0, SUM(R.Qty1), 0
FROM ReserveStates R
INNER JOIN #AttrFiltered AF ON AF.ProductCode = R.ItemCode
WHERE R.ItemTypeCode = 1 AND LEN(R.ItemCode) = 13
GROUP BY R.ItemCode, R.ColorCode, R.ItemDim1Code, R.ItemDim2Code
UNION ALL
SELECT D.ItemCode, D.ColorCode, D.ItemDim1Code, D.ItemDim2Code, 0, 0, 0, SUM(D.Qty1)
FROM DispOrderStates D
INNER JOIN #AttrFiltered AF ON AF.ProductCode = D.ItemCode
WHERE D.ItemTypeCode = 1 AND LEN(D.ItemCode) = 13
GROUP BY D.ItemCode, D.ColorCode, D.ItemDim1Code, D.ItemDim2Code
) X
GROUP BY X.ItemCode, X.ColorCode, X.ItemDim1Code, X.ItemDim2Code
),
Avail AS
(
SELECT
I.ItemCode,
I.ColorCode,
I.ItemDim1Code,
I.ItemDim2Code,
CAST(ROUND(I.InventoryQty1 - I.PickingQty1 - I.ReserveQty1 - I.DispOrderQty1, 2) AS DECIMAL(18,2)) AS Qty
FROM Inv I
JOIN cdItem CI WITH (NOLOCK)
ON CI.ItemTypeCode = 1
AND CI.ItemCode = I.ItemCode
WHERE CI.IsBlocked = 0
AND (I.InventoryQty1 - I.PickingQty1 - I.ReserveQty1 - I.DispOrderQty1) > 0
AND NULLIF(LTRIM(RTRIM(ISNULL(I.ColorCode, ''))), '') IS NOT NULL
AND (@RenkList IS NULL OR CHARINDEX(NCHAR(31) + ISNULL(I.ColorCode,'') + NCHAR(31), NCHAR(31) + @RenkList + NCHAR(31)) > 0)
AND (@Renk2List IS NULL OR CHARINDEX(NCHAR(31) + ISNULL(I.ItemDim2Code,'') + NCHAR(31), NCHAR(31) + @Renk2List + NCHAR(31)) > 0)
AND (@BedenList IS NULL OR CHARINDEX(NCHAR(31) + ISNULL(I.ItemDim1Code,'') + NCHAR(31), NCHAR(31) + @BedenList + NCHAR(31)) > 0)
),
Groups AS
(
SELECT ItemCode, ColorCode, ItemDim2Code
FROM
(
SELECT
A.ItemCode,
A.ColorCode,
A.ItemDim2Code,
ROW_NUMBER() OVER (ORDER BY A.ItemCode, A.ColorCode, A.ItemDim2Code) AS rn
FROM Avail A
GROUP BY A.ItemCode, A.ColorCode, A.ItemDim2Code
) X
WHERE X.rn <= @RowLimit
)
SELECT
G.ItemCode AS product_code,
AF.ProductDescription AS product_description,
G.ColorCode AS color_code,
ISNULL(C.ColorDescription, '') AS color_title,
ISNULL(G.ItemDim2Code, '') AS dim3_code,
A.ItemDim1Code AS size_code,
A.Qty AS qty,
AF.ProductAtt01Desc AS urun_ana_grubu,
AF.ProductAtt02Desc AS urun_alt_grubu,
AF.ProductAtt10Desc AS marka,
AF.ProductAtt11Desc AS drop_val,
AF.ProductAtt38Desc AS fit,
AF.ProductAtt41Desc AS urun_icerigi,
AF.ProductAtt44Desc AS kategori
FROM Groups G
JOIN Avail A
ON A.ItemCode = G.ItemCode
AND A.ColorCode = G.ColorCode
AND ISNULL(A.ItemDim2Code, '') = ISNULL(G.ItemDim2Code, '')
JOIN #AttrFiltered AF ON AF.ProductCode = G.ItemCode
LEFT JOIN cdColorDesc C WITH (NOLOCK)
ON C.ColorCode = G.ColorCode
AND C.LangCode = 'TR'
ORDER BY G.ItemCode, G.ColorCode, G.ItemDim2Code, A.ItemDim1Code
OPTION (RECOMPILE);
`

View File

@@ -0,0 +1,89 @@
package queries
import "database/sql"
func EnsureProductSeriesAutoInfraTables(pg *sql.DB) error {
stmts := []string{
`
CREATE TABLE IF NOT EXISTS mk_product_series_rule (
id BIGSERIAL PRIMARY KEY,
series_id BIGINT NOT NULL REFERENCES dfgrp(id) ON DELETE CASCADE,
size_group TEXT NOT NULL DEFAULT '',
size_code TEXT NOT NULL,
ratio_qty INTEGER NOT NULL DEFAULT 1,
priority INTEGER NOT NULL DEFAULT 0,
is_active BOOLEAN NOT NULL DEFAULT TRUE,
source TEXT NOT NULL DEFAULT 'manual',
notes TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT ck_mk_product_series_rule_ratio CHECK (ratio_qty > 0),
CONSTRAINT uq_mk_product_series_rule UNIQUE (series_id, size_group, size_code)
)`,
`CREATE INDEX IF NOT EXISTS ix_mk_product_series_rule_series ON mk_product_series_rule (series_id, is_active)`,
`CREATE INDEX IF NOT EXISTS ix_mk_product_series_rule_group ON mk_product_series_rule (size_group, size_code)`,
`
CREATE TABLE IF NOT EXISTS mk_product_series_stock_state (
row_key TEXT PRIMARY KEY,
product_code TEXT NOT NULL,
color_code TEXT NOT NULL,
dim3_code TEXT NOT NULL DEFAULT '',
stock_hash TEXT NOT NULL DEFAULT '',
total_qty NUMERIC(18,2) NOT NULL DEFAULT 0,
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
)`,
`CREATE INDEX IF NOT EXISTS ix_mk_product_series_stock_state_product ON mk_product_series_stock_state (product_code, updated_at DESC)`,
`
CREATE TABLE IF NOT EXISTS mk_product_series_recalc_queue (
id BIGSERIAL PRIMARY KEY,
row_key TEXT NOT NULL,
product_code TEXT NOT NULL,
color_code TEXT NOT NULL,
dim3_code TEXT NOT NULL DEFAULT '',
reason TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'pending',
attempts SMALLINT NOT NULL DEFAULT 0,
available_at TIMESTAMPTZ NOT NULL DEFAULT now(),
queued_at TIMESTAMPTZ NOT NULL DEFAULT now(),
processed_at TIMESTAMPTZ,
last_error TEXT NOT NULL DEFAULT '',
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
CONSTRAINT ck_mk_product_series_recalc_queue_status CHECK (status IN ('pending','processing','done','failed'))
)`,
`CREATE INDEX IF NOT EXISTS ix_mk_product_series_recalc_queue_status ON mk_product_series_recalc_queue (status, available_at, queued_at)`,
`CREATE UNIQUE INDEX IF NOT EXISTS uq_mk_product_series_recalc_queue_pending ON mk_product_series_recalc_queue (row_key) WHERE status IN ('pending','processing')`,
`
CREATE TABLE IF NOT EXISTS mk_product_series_job_log (
id BIGSERIAL PRIMARY KEY,
job_name TEXT NOT NULL,
reason TEXT NOT NULL DEFAULT '',
started_at TIMESTAMPTZ NOT NULL DEFAULT now(),
finished_at TIMESTAMPTZ,
status TEXT NOT NULL DEFAULT 'running',
scanned_rows INTEGER NOT NULL DEFAULT 0,
changed_rows INTEGER NOT NULL DEFAULT 0,
processed_rows INTEGER NOT NULL DEFAULT 0,
written_rows INTEGER NOT NULL DEFAULT 0,
skipped_rows INTEGER NOT NULL DEFAULT 0,
error_text TEXT NOT NULL DEFAULT ''
)`,
`CREATE INDEX IF NOT EXISTS ix_mk_product_series_job_log_started ON mk_product_series_job_log (started_at DESC)`,
}
for _, stmt := range stmts {
if _, err := pg.Exec(stmt); err != nil {
return err
}
}
_, err := pg.Exec(`
INSERT INTO mk_product_series_rule (series_id, size_group, size_code, ratio_qty, priority, source, notes)
SELECT d.id, '', BTRIM(x.size_code), 1, 0, 'dfgrp_title', 'auto-seeded from dfgrp.title'
FROM dfgrp d
CROSS JOIN LATERAL regexp_split_to_table(COALESCE(d.title, ''), '-') AS x(size_code)
WHERE d.master='zbggseri'
AND BTRIM(x.size_code) <> ''
ON CONFLICT (series_id, size_group, size_code) DO NOTHING
`)
return err
}

View File

@@ -0,0 +1,603 @@
package routes
import (
"bssapp-backend/db"
"bssapp-backend/queries"
"context"
"database/sql"
"encoding/json"
"fmt"
"net/http"
"sort"
"strconv"
"strings"
"time"
"github.com/gorilla/mux"
"github.com/lib/pq"
)
type productSeriesDefinition struct {
ID int64 `json:"id"`
Code string `json:"code"`
Title string `json:"title"`
IsActive bool `json:"is_active"`
ParentFilter string `json:"parent_filter"`
SortOrder int `json:"sort_order"`
Notes string `json:"notes"`
}
type productSeriesStockRawRow 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 productSeriesMappingRow struct {
RowKey string `json:"row_key"`
ProductCode string `json:"product_code"`
ProductDescription string `json:"product_description"`
ColorCode string `json:"color_code"`
ColorTitle string `json:"color_title"`
Dim3Code string `json:"dim3_code"`
UrunAnaGrubu string `json:"urun_ana_grubu"`
UrunAltGrubu string `json:"urun_alt_grubu"`
Marka string `json:"marka"`
DropVal string `json:"drop_val"`
Fit string `json:"fit"`
UrunIcerigi string `json:"urun_icerigi"`
Kategori string `json:"kategori"`
SizeQty map[string]float64 `json:"size_qty"`
TotalQty float64 `json:"total_qty"`
SeriesIDs []int64 `json:"series_ids"`
Series []productSeriesDefinition `json:"series"`
MmitemID int64 `json:"mmitem_id"`
Dim1ID int64 `json:"dim1_id"`
Dim3ID int64 `json:"dim3_id"`
MappingReady bool `json:"mapping_ready"`
MappingWarning string `json:"mapping_warning"`
}
type productSeriesMappingsResponse struct {
Rows []productSeriesMappingRow `json:"rows"`
SizeColumns []string `json:"size_columns"`
Definitions []productSeriesDefinition `json:"definitions"`
}
func GetProductSeriesDefinitionsHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
defs, err := listProductSeriesDefinitions(ctx, pg, false)
if err != nil {
http.Error(w, "Seri tanimlari alinamadi: "+err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, defs)
}
}
func PostProductSeriesDefinitionHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req productSeriesDefinition
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Gecersiz istek", http.StatusBadRequest)
return
}
req.Code = strings.TrimSpace(req.Code)
req.Title = strings.TrimSpace(req.Title)
if req.Code == "" {
http.Error(w, "Seri kodu zorunludur", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
err := pg.QueryRowContext(ctx, `
INSERT INTO dfgrp (code, title, is_active, typ, master, parent_filter, sort_order, is_required, notes)
VALUES ($1, $2, COALESCE($3, TRUE), 'opt', 'zbggseri', $4, $5, FALSE, $6)
RETURNING id
`, req.Code, req.Title, req.IsActive, req.ParentFilter, req.SortOrder, req.Notes).Scan(&req.ID)
if err != nil {
http.Error(w, "Seri tanimi eklenemedi: "+err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, req)
}
}
func PutProductSeriesDefinitionHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(mux.Vars(r)["id"], 10, 64)
if id <= 0 {
http.Error(w, "Gecersiz id", http.StatusBadRequest)
return
}
var req productSeriesDefinition
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Gecersiz istek", http.StatusBadRequest)
return
}
req.Code = strings.TrimSpace(req.Code)
req.Title = strings.TrimSpace(req.Title)
if req.Code == "" {
http.Error(w, "Seri kodu zorunludur", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
res, err := pg.ExecContext(ctx, `
UPDATE dfgrp
SET code=$2,
title=$3,
is_active=$4,
parent_filter=$5,
sort_order=$6,
notes=$7
WHERE id=$1 AND master='zbggseri'
`, id, req.Code, req.Title, req.IsActive, req.ParentFilter, req.SortOrder, req.Notes)
if err != nil {
http.Error(w, "Seri tanimi guncellenemedi: "+err.Error(), http.StatusInternalServerError)
return
}
if n, _ := res.RowsAffected(); n == 0 {
http.Error(w, "Seri tanimi bulunamadi", http.StatusNotFound)
return
}
req.ID = id
writeJSON(w, req)
}
}
func DeleteProductSeriesDefinitionHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
id, _ := strconv.ParseInt(mux.Vars(r)["id"], 10, 64)
if id <= 0 {
http.Error(w, "Gecersiz id", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 30*time.Second)
defer cancel()
if _, err := pg.ExecContext(ctx, `UPDATE dfgrp SET is_active=FALSE WHERE id=$1 AND master='zbggseri'`, id); err != nil {
http.Error(w, "Seri tanimi silinemedi: "+err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{"ok": true})
}
}
func GetProductSeriesMappingsHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
f := readStockAttrFilters(r)
limit := 0
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
if n, err := strconv.Atoi(raw); err == nil && n >= 0 {
limit = n
}
}
search := firstNonEmpty(r.URL.Query().Get("q"), r.URL.Query().Get("code"), r.URL.Query().Get("product_code"))
ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
defer cancel()
msRows, err := db.MssqlDB.QueryContext(ctx, queries.GetProductSeriesStockRowsQuery,
f.kategori,
f.urunAnaGrubu,
joinFilterValues(f.urunAltGrubu),
joinFilterValues(f.renk),
joinFilterValues(f.renk2),
joinFilterValues(f.urunIcerigi),
joinFilterValues(f.fit),
joinFilterValues(f.drop),
joinFilterValues(f.beden),
strconv.Itoa(limit),
search,
)
if err != nil {
http.Error(w, "Stok seri listesi alinamadi: "+err.Error(), http.StatusInternalServerError)
return
}
defer msRows.Close()
grouped := map[string]*productSeriesMappingRow{}
codeSet := map[string]struct{}{}
colorSet := map[string]struct{}{}
dim3Set := map[string]struct{}{}
sizeSet := map[string]struct{}{}
for msRows.Next() {
var raw productSeriesStockRawRow
if err := msRows.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 {
http.Error(w, "Stok satiri okunamadi: "+err.Error(), http.StatusInternalServerError)
return
}
raw.ProductCode = strings.TrimSpace(raw.ProductCode)
raw.ColorCode = strings.TrimSpace(raw.ColorCode)
raw.Dim3Code = strings.TrimSpace(raw.Dim3Code)
raw.SizeCode = strings.TrimSpace(raw.SizeCode)
key := productSeriesKey(raw.ProductCode, raw.ColorCode, raw.Dim3Code)
row := grouped[key]
if row == nil {
row = &productSeriesMappingRow{
RowKey: key,
ProductCode: raw.ProductCode,
ProductDescription: strings.TrimSpace(raw.ProductDescription),
ColorCode: raw.ColorCode,
ColorTitle: strings.TrimSpace(raw.ColorTitle),
Dim3Code: raw.Dim3Code,
UrunAnaGrubu: strings.TrimSpace(raw.UrunAnaGrubu),
UrunAltGrubu: strings.TrimSpace(raw.UrunAltGrubu),
Marka: strings.TrimSpace(raw.Marka),
DropVal: strings.TrimSpace(raw.DropVal),
Fit: strings.TrimSpace(raw.Fit),
UrunIcerigi: strings.TrimSpace(raw.UrunIcerigi),
Kategori: strings.TrimSpace(raw.Kategori),
SizeQty: map[string]float64{},
SeriesIDs: []int64{},
Series: []productSeriesDefinition{},
}
grouped[key] = row
codeSet[raw.ProductCode] = struct{}{}
colorSet[raw.ColorCode] = struct{}{}
if raw.Dim3Code != "" {
dim3Set[raw.Dim3Code] = struct{}{}
}
}
if raw.SizeCode != "" {
row.SizeQty[raw.SizeCode] = raw.Qty
sizeSet[raw.SizeCode] = struct{}{}
}
row.TotalQty += raw.Qty
}
if err := msRows.Err(); err != nil {
http.Error(w, "Stok satirlari okunamadi: "+err.Error(), http.StatusInternalServerError)
return
}
defs, err := listProductSeriesDefinitions(ctx, pg, true)
if err != nil {
http.Error(w, "Seri tanimlari alinamadi: "+err.Error(), http.StatusInternalServerError)
return
}
defByID := make(map[int64]productSeriesDefinition, len(defs))
for _, d := range defs {
defByID[d.ID] = d
}
codes := setToSortedSlice(codeSet)
mmitemByCode, _ := loadMmitemIDs(ctx, pg, codes)
dim1ByToken, _ := loadDimTokenIDs(ctx, pg, "dimval1", setToSortedSlice(colorSet))
dim3ByToken, _ := loadDimTokenIDs(ctx, pg, "dimval3", setToSortedSlice(dim3Set))
existing, _ := loadProductSeriesAssignments(ctx, pg, codes)
out := make([]productSeriesMappingRow, 0, len(grouped))
for _, row := range grouped {
row.MmitemID = mmitemByCode[row.ProductCode]
row.Dim1ID = dim1ByToken[row.ColorCode]
if row.Dim3Code != "" {
row.Dim3ID = dim3ByToken[row.Dim3Code]
}
row.MappingReady = row.MmitemID > 0 && row.Dim1ID > 0 && (row.Dim3Code == "" || row.Dim3ID > 0)
if !row.MappingReady {
row.MappingWarning = "PG urun veya varyant token eslesmesi bulunamadi"
}
assignKey := assignmentKey(row.ProductCode, row.Dim1ID, row.Dim3ID)
for _, id := range existing[assignKey] {
if d, ok := defByID[id]; ok {
row.SeriesIDs = append(row.SeriesIDs, id)
row.Series = append(row.Series, d)
}
}
sort.Slice(row.Series, func(i, j int) bool { return row.Series[i].Code < row.Series[j].Code })
sort.Slice(row.SeriesIDs, func(i, j int) bool { return row.SeriesIDs[i] < row.SeriesIDs[j] })
out = append(out, *row)
}
productTotals := productSeriesTotalQtyByCode(out)
sort.Slice(out, func(i, j int) bool {
totalI := productTotals[out[i].ProductCode]
totalJ := productTotals[out[j].ProductCode]
if totalI != totalJ {
return totalI > totalJ
}
if out[i].ProductCode != out[j].ProductCode {
return out[i].ProductCode < out[j].ProductCode
}
if out[i].ColorCode != out[j].ColorCode {
return out[i].ColorCode < out[j].ColorCode
}
return out[i].Dim3Code < out[j].Dim3Code
})
writeJSON(w, productSeriesMappingsResponse{
Rows: out,
SizeColumns: sortSizeColumns(setToSortedSlice(sizeSet)),
Definitions: defs,
})
}
}
func productSeriesTotalQtyByCode(rows []productSeriesMappingRow) map[string]float64 {
out := make(map[string]float64, len(rows))
for _, row := range rows {
out[row.ProductCode] += row.TotalQty
}
return out
}
type saveProductSeriesMappingsRequest struct {
Items []struct {
ProductCode string `json:"product_code"`
ColorCode string `json:"color_code"`
Dim3Code string `json:"dim3_code"`
SeriesIDs []int64 `json:"series_ids"`
} `json:"items"`
}
func PostProductSeriesMappingsSaveHandler(pg *sql.DB) http.HandlerFunc {
return func(w http.ResponseWriter, r *http.Request) {
var req saveProductSeriesMappingsRequest
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
http.Error(w, "Gecersiz istek", http.StatusBadRequest)
return
}
ctx, cancel := context.WithTimeout(r.Context(), 60*time.Second)
defer cancel()
tx, err := pg.BeginTx(ctx, nil)
if err != nil {
http.Error(w, "Transaction baslatilamadi", http.StatusInternalServerError)
return
}
defer tx.Rollback()
saved := 0
for _, item := range req.Items {
code := strings.TrimSpace(item.ProductCode)
color := strings.TrimSpace(item.ColorCode)
dim3Token := strings.TrimSpace(item.Dim3Code)
if code == "" || color == "" {
continue
}
mmitemID, err := resolveMmitemIDTx(ctx, tx, code)
if err != nil || mmitemID <= 0 {
http.Error(w, "PG urun bulunamadi: "+code, http.StatusBadRequest)
return
}
dim1ID, err := resolveDimTokenIDTx(ctx, tx, "dimval1", color)
if err != nil || dim1ID <= 0 {
http.Error(w, "Renk token eslesmesi bulunamadi: "+color, http.StatusBadRequest)
return
}
var dim3ID sql.NullInt64
if dim3Token != "" {
id, err := resolveDimTokenIDTx(ctx, tx, "dimval3", dim3Token)
if err != nil || id <= 0 {
http.Error(w, "Dim3 token eslesmesi bulunamadi: "+dim3Token, http.StatusBadRequest)
return
}
dim3ID = sql.NullInt64{Int64: id, Valid: true}
}
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, nullableInt64Arg(dim3ID)); err != nil {
http.Error(w, "Seri eslesmesi temizlenemedi: "+err.Error(), http.StatusInternalServerError)
return
}
seen := map[int64]struct{}{}
for _, seriesID := range item.SeriesIDs {
if seriesID <= 0 {
continue
}
if _, ok := seen[seriesID]; ok {
continue
}
seen[seriesID] = struct{}{}
if _, err := tx.ExecContext(ctx, `
INSERT INTO zbggseri (mmitem_id, dim1, seri_id, dim3)
VALUES ($1, $2, $3, $4)
`, mmitemID, dim1ID, seriesID, nullableInt64Arg(dim3ID)); err != nil {
http.Error(w, "Seri eslesmesi kaydedilemedi: "+err.Error(), http.StatusInternalServerError)
return
}
}
saved++
}
if err := tx.Commit(); err != nil {
http.Error(w, "Seri eslesmeleri kaydedilemedi: "+err.Error(), http.StatusInternalServerError)
return
}
writeJSON(w, map[string]any{"saved": saved})
}
}
func listProductSeriesDefinitions(ctx context.Context, pg *sql.DB, activeOnly bool) ([]productSeriesDefinition, error) {
whereActive := ""
if activeOnly {
whereActive = " AND is_active=TRUE"
}
rows, err := pg.QueryContext(ctx, `
SELECT id, COALESCE(code,''), COALESCE(title,''), COALESCE(is_active, TRUE),
COALESCE(parent_filter,''), COALESCE(sort_order, 0), COALESCE(notes,'')
FROM dfgrp
WHERE master='zbggseri'`+whereActive+`
ORDER BY sort_order,
CASE WHEN code ~ '^[0-9]+$' THEN code::int ELSE NULL END NULLS LAST,
code,
id
`)
if err != nil {
return nil, err
}
defer rows.Close()
out := []productSeriesDefinition{}
for rows.Next() {
var item productSeriesDefinition
if err := rows.Scan(&item.ID, &item.Code, &item.Title, &item.IsActive, &item.ParentFilter, &item.SortOrder, &item.Notes); err != nil {
return nil, err
}
out = append(out, item)
}
return out, rows.Err()
}
func loadMmitemIDs(ctx context.Context, pg *sql.DB, codes []string) (map[string]int64, error) {
out := map[string]int64{}
if len(codes) == 0 {
return out, nil
}
rows, err := pg.QueryContext(ctx, `SELECT code, id FROM mmitem WHERE code = ANY($1)`, pq.Array(codes))
if err != nil {
return out, err
}
defer rows.Close()
for rows.Next() {
var code string
var id int64
if err := rows.Scan(&code, &id); err != nil {
return out, err
}
out[strings.TrimSpace(code)] = id
}
return out, rows.Err()
}
func loadDimTokenIDs(ctx context.Context, pg *sql.DB, column string, tokens []string) (map[string]int64, error) {
out := map[string]int64{}
if len(tokens) == 0 {
return out, nil
}
rows, err := pg.QueryContext(ctx, `
SELECT token, dim_id
FROM mk_dim_token_map
WHERE dim_column=$1 AND token = ANY($2)
`, column, pq.Array(tokens))
if err != nil {
return out, err
}
defer rows.Close()
for rows.Next() {
var token string
var id int64
if err := rows.Scan(&token, &id); err != nil {
return out, err
}
out[strings.TrimSpace(token)] = id
}
return out, rows.Err()
}
func loadProductSeriesAssignments(ctx context.Context, pg *sql.DB, codes []string) (map[string][]int64, error) {
out := map[string][]int64{}
if len(codes) == 0 {
return out, nil
}
rows, err := pg.QueryContext(ctx, `
SELECT m.code, z.dim1, COALESCE(z.dim3, 0), z.seri_id
FROM zbggseri z
JOIN mmitem m ON m.id = z.mmitem_id
WHERE m.code = ANY($1)
ORDER BY m.code, z.dim1, z.dim3, z.seri_id
`, pq.Array(codes))
if err != nil {
return out, err
}
defer rows.Close()
for rows.Next() {
var code string
var dim1, dim3, seriesID int64
if err := rows.Scan(&code, &dim1, &dim3, &seriesID); err != nil {
return out, err
}
key := assignmentKey(strings.TrimSpace(code), dim1, dim3)
out[key] = append(out[key], seriesID)
}
return out, rows.Err()
}
func resolveMmitemIDTx(ctx context.Context, tx *sql.Tx, code string) (int64, error) {
var id int64
err := tx.QueryRowContext(ctx, `SELECT id FROM mmitem WHERE code=$1`, code).Scan(&id)
return id, err
}
func resolveDimTokenIDTx(ctx context.Context, tx *sql.Tx, column string, token string) (int64, error) {
var id int64
err := tx.QueryRowContext(ctx, `SELECT dim_id FROM mk_dim_token_map WHERE dim_column=$1 AND token=$2`, column, token).Scan(&id)
return id, err
}
func nullableInt64Arg(v sql.NullInt64) any {
if !v.Valid {
return nil
}
return v.Int64
}
func productSeriesKey(productCode, colorCode, dim3Code string) string {
return strings.TrimSpace(productCode) + "|" + strings.TrimSpace(colorCode) + "|" + strings.TrimSpace(dim3Code)
}
func assignmentKey(productCode string, dim1ID int64, dim3ID int64) string {
return fmt.Sprintf("%s|%d|%d", strings.TrimSpace(productCode), dim1ID, dim3ID)
}
func setToSortedSlice(set map[string]struct{}) []string {
out := make([]string, 0, len(set))
for v := range set {
v = strings.TrimSpace(v)
if v != "" {
out = append(out, v)
}
}
sort.Strings(out)
return out
}
func sortSizeColumns(values []string) []string {
sort.Slice(values, func(i, j int) bool {
ai, ae := strconv.Atoi(values[i])
bi, be := strconv.Atoi(values[j])
if ae == nil && be == nil {
return ai < bi
}
return values[i] < values[j]
})
return values
}
func writeJSON(w http.ResponseWriter, payload any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(payload)
}