Merge remote-tracking branch 'origin/master'
This commit is contained in:
34
svc/main.go
34
svc/main.go
@@ -925,6 +925,36 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
|
|||||||
"pricing", "update",
|
"pricing", "update",
|
||||||
wrapV3(routes.PostProductPricingSaveHandler(pgDB, ml)),
|
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,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/wholesale-campaigns", "GET",
|
"/api/pricing/wholesale-campaigns", "GET",
|
||||||
"pricing", "view",
|
"pricing", "view",
|
||||||
@@ -1330,6 +1360,9 @@ func main() {
|
|||||||
if err := queries.EnsurePricingCalcInfraTables(pgDB); err != nil {
|
if err := queries.EnsurePricingCalcInfraTables(pgDB); err != nil {
|
||||||
log.Println("pricing calc infra bootstrap failed:", err)
|
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
|
// ✉️ MAILER INIT
|
||||||
@@ -1355,6 +1388,7 @@ func main() {
|
|||||||
startProductPricingCalcScheduler(pgDB)
|
startProductPricingCalcScheduler(pgDB)
|
||||||
startProductPricingFxDeltaScheduler(pgDB)
|
startProductPricingFxDeltaScheduler(pgDB)
|
||||||
startProductPricingFxFullScheduler(pgDB)
|
startProductPricingFxFullScheduler(pgDB)
|
||||||
|
startProductSeriesAutoSchedulers(pgDB)
|
||||||
|
|
||||||
handler := enableCORS(
|
handler := enableCORS(
|
||||||
middlewares.GlobalAuthMiddleware(
|
middlewares.GlobalAuthMiddleware(
|
||||||
|
|||||||
866
svc/product_series_auto_scheduler.go
Normal file
866
svc/product_series_auto_scheduler.go
Normal 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
|
||||||
|
}
|
||||||
198
svc/queries/product_series.go
Normal file
198
svc/queries/product_series.go
Normal 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);
|
||||||
|
`
|
||||||
89
svc/queries/product_series_auto_infra.go
Normal file
89
svc/queries/product_series_auto_infra.go
Normal 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
|
||||||
|
}
|
||||||
603
svc/routes/product_series.go
Normal file
603
svc/routes/product_series.go
Normal 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)
|
||||||
|
}
|
||||||
@@ -1,75 +0,0 @@
|
|||||||
/* eslint-disable */
|
|
||||||
/**
|
|
||||||
* THIS FILE IS GENERATED AUTOMATICALLY.
|
|
||||||
* DO NOT EDIT.
|
|
||||||
*
|
|
||||||
* You are probably looking on adding startup/initialization code.
|
|
||||||
* Use "quasar new boot <name>" and add it there.
|
|
||||||
* One boot file per concern. Then reference the file(s) in quasar.config file > boot:
|
|
||||||
* boot: ['file', ...] // do not add ".js" extension to it.
|
|
||||||
*
|
|
||||||
* Boot files are your "main.js"
|
|
||||||
**/
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import { Quasar } from 'quasar'
|
|
||||||
import { markRaw } from 'vue'
|
|
||||||
import RootComponent from 'app/src/App.vue'
|
|
||||||
|
|
||||||
import createStore from 'app/src/stores/index'
|
|
||||||
import createRouter from 'app/src/router/index'
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default async function (createAppFn, quasarUserOptions) {
|
|
||||||
|
|
||||||
|
|
||||||
// Create the app instance.
|
|
||||||
// Here we inject into it the Quasar UI, the router & possibly the store.
|
|
||||||
const app = createAppFn(RootComponent)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
app.use(Quasar, quasarUserOptions)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const store = typeof createStore === 'function'
|
|
||||||
? await createStore({})
|
|
||||||
: createStore
|
|
||||||
|
|
||||||
|
|
||||||
app.use(store)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const router = markRaw(
|
|
||||||
typeof createRouter === 'function'
|
|
||||||
? await createRouter({store})
|
|
||||||
: createRouter
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
// make router instance available in store
|
|
||||||
|
|
||||||
store.use(({ store }) => { store.router = router })
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// Expose the app, the router and the store.
|
|
||||||
// Note that we are not mounting the app here, since bootstrapping will be
|
|
||||||
// different depending on whether we are in a browser or on the server.
|
|
||||||
return {
|
|
||||||
app,
|
|
||||||
store,
|
|
||||||
router
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,158 +0,0 @@
|
|||||||
/* eslint-disable */
|
|
||||||
/**
|
|
||||||
* THIS FILE IS GENERATED AUTOMATICALLY.
|
|
||||||
* DO NOT EDIT.
|
|
||||||
*
|
|
||||||
* You are probably looking on adding startup/initialization code.
|
|
||||||
* Use "quasar new boot <name>" and add it there.
|
|
||||||
* One boot file per concern. Then reference the file(s) in quasar.config file > boot:
|
|
||||||
* boot: ['file', ...] // do not add ".js" extension to it.
|
|
||||||
*
|
|
||||||
* Boot files are your "main.js"
|
|
||||||
**/
|
|
||||||
|
|
||||||
|
|
||||||
import { createApp } from 'vue'
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import '@quasar/extras/roboto-font/roboto-font.css'
|
|
||||||
|
|
||||||
import '@quasar/extras/material-icons/material-icons.css'
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
// We load Quasar stylesheet file
|
|
||||||
import 'quasar/dist/quasar.sass'
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import 'src/css/app.css'
|
|
||||||
|
|
||||||
|
|
||||||
import createQuasarApp from './app.js'
|
|
||||||
import quasarUserOptions from './quasar-user-options.js'
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
const publicPath = `/`
|
|
||||||
|
|
||||||
|
|
||||||
async function start ({
|
|
||||||
app,
|
|
||||||
router
|
|
||||||
, store
|
|
||||||
}, bootFiles) {
|
|
||||||
|
|
||||||
let hasRedirected = false
|
|
||||||
const getRedirectUrl = url => {
|
|
||||||
try { return router.resolve(url).href }
|
|
||||||
catch (err) {}
|
|
||||||
|
|
||||||
return Object(url) === url
|
|
||||||
? null
|
|
||||||
: url
|
|
||||||
}
|
|
||||||
const redirect = url => {
|
|
||||||
hasRedirected = true
|
|
||||||
|
|
||||||
if (typeof url === 'string' && /^https?:\/\//.test(url)) {
|
|
||||||
window.location.href = url
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
const href = getRedirectUrl(url)
|
|
||||||
|
|
||||||
// continue if we didn't fail to resolve the url
|
|
||||||
if (href !== null) {
|
|
||||||
window.location.href = href
|
|
||||||
window.location.reload()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const urlPath = window.location.href.replace(window.location.origin, '')
|
|
||||||
|
|
||||||
for (let i = 0; hasRedirected === false && i < bootFiles.length; i++) {
|
|
||||||
try {
|
|
||||||
await bootFiles[i]({
|
|
||||||
app,
|
|
||||||
router,
|
|
||||||
store,
|
|
||||||
ssrContext: null,
|
|
||||||
redirect,
|
|
||||||
urlPath,
|
|
||||||
publicPath
|
|
||||||
})
|
|
||||||
}
|
|
||||||
catch (err) {
|
|
||||||
if (err && err.url) {
|
|
||||||
redirect(err.url)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
console.error('[Quasar] boot error:', err)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hasRedirected === true) return
|
|
||||||
|
|
||||||
|
|
||||||
app.use(router)
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
app.mount('#q-app')
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
}
|
|
||||||
|
|
||||||
createQuasarApp(createApp, quasarUserOptions)
|
|
||||||
|
|
||||||
.then(app => {
|
|
||||||
// eventually remove this when Cordova/Capacitor/Electron support becomes old
|
|
||||||
const [ method, mapFn ] = Promise.allSettled !== void 0
|
|
||||||
? [
|
|
||||||
'allSettled',
|
|
||||||
bootFiles => bootFiles.map(result => {
|
|
||||||
if (result.status === 'rejected') {
|
|
||||||
console.error('[Quasar] boot error:', result.reason)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
return result.value.default
|
|
||||||
})
|
|
||||||
]
|
|
||||||
: [
|
|
||||||
'all',
|
|
||||||
bootFiles => bootFiles.map(entry => entry.default)
|
|
||||||
]
|
|
||||||
|
|
||||||
return Promise[ method ]([
|
|
||||||
|
|
||||||
import(/* webpackMode: "eager" */ 'boot/dayjs'),
|
|
||||||
|
|
||||||
import(/* webpackMode: "eager" */ 'boot/locale'),
|
|
||||||
|
|
||||||
import(/* webpackMode: "eager" */ 'boot/resizeObserverGuard')
|
|
||||||
|
|
||||||
]).then(bootFiles => {
|
|
||||||
const boot = mapFn(bootFiles).filter(entry => typeof entry === 'function')
|
|
||||||
start(app, boot)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
@@ -1,116 +0,0 @@
|
|||||||
/* eslint-disable */
|
|
||||||
/**
|
|
||||||
* THIS FILE IS GENERATED AUTOMATICALLY.
|
|
||||||
* DO NOT EDIT.
|
|
||||||
*
|
|
||||||
* You are probably looking on adding startup/initialization code.
|
|
||||||
* Use "quasar new boot <name>" and add it there.
|
|
||||||
* One boot file per concern. Then reference the file(s) in quasar.config file > boot:
|
|
||||||
* boot: ['file', ...] // do not add ".js" extension to it.
|
|
||||||
*
|
|
||||||
* Boot files are your "main.js"
|
|
||||||
**/
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import App from 'app/src/App.vue'
|
|
||||||
let appPrefetch = typeof App.preFetch === 'function'
|
|
||||||
? App.preFetch
|
|
||||||
: (
|
|
||||||
// Class components return the component options (and the preFetch hook) inside __c property
|
|
||||||
App.__c !== void 0 && typeof App.__c.preFetch === 'function'
|
|
||||||
? App.__c.preFetch
|
|
||||||
: false
|
|
||||||
)
|
|
||||||
|
|
||||||
|
|
||||||
function getMatchedComponents (to, router) {
|
|
||||||
const route = to
|
|
||||||
? (to.matched ? to : router.resolve(to).route)
|
|
||||||
: router.currentRoute.value
|
|
||||||
|
|
||||||
if (!route) { return [] }
|
|
||||||
|
|
||||||
const matched = route.matched.filter(m => m.components !== void 0)
|
|
||||||
|
|
||||||
if (matched.length === 0) { return [] }
|
|
||||||
|
|
||||||
return Array.prototype.concat.apply([], matched.map(m => {
|
|
||||||
return Object.keys(m.components).map(key => {
|
|
||||||
const comp = m.components[key]
|
|
||||||
return {
|
|
||||||
path: m.path,
|
|
||||||
c: comp
|
|
||||||
}
|
|
||||||
})
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
export function addPreFetchHooks ({ router, store, publicPath }) {
|
|
||||||
// Add router hook for handling preFetch.
|
|
||||||
// Doing it after initial route is resolved so that we don't double-fetch
|
|
||||||
// the data that we already have. Using router.beforeResolve() so that all
|
|
||||||
// async components are resolved.
|
|
||||||
router.beforeResolve((to, from, next) => {
|
|
||||||
const
|
|
||||||
urlPath = window.location.href.replace(window.location.origin, ''),
|
|
||||||
matched = getMatchedComponents(to, router),
|
|
||||||
prevMatched = getMatchedComponents(from, router)
|
|
||||||
|
|
||||||
let diffed = false
|
|
||||||
const preFetchList = matched
|
|
||||||
.filter((m, i) => {
|
|
||||||
return diffed || (diffed = (
|
|
||||||
!prevMatched[i] ||
|
|
||||||
prevMatched[i].c !== m.c ||
|
|
||||||
m.path.indexOf('/:') > -1 // does it has params?
|
|
||||||
))
|
|
||||||
})
|
|
||||||
.filter(m => m.c !== void 0 && (
|
|
||||||
typeof m.c.preFetch === 'function'
|
|
||||||
// Class components return the component options (and the preFetch hook) inside __c property
|
|
||||||
|| (m.c.__c !== void 0 && typeof m.c.__c.preFetch === 'function')
|
|
||||||
))
|
|
||||||
.map(m => m.c.__c !== void 0 ? m.c.__c.preFetch : m.c.preFetch)
|
|
||||||
|
|
||||||
|
|
||||||
if (appPrefetch !== false) {
|
|
||||||
preFetchList.unshift(appPrefetch)
|
|
||||||
appPrefetch = false
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
if (preFetchList.length === 0) {
|
|
||||||
return next()
|
|
||||||
}
|
|
||||||
|
|
||||||
let hasRedirected = false
|
|
||||||
const redirect = url => {
|
|
||||||
hasRedirected = true
|
|
||||||
next(url)
|
|
||||||
}
|
|
||||||
const proceed = () => {
|
|
||||||
|
|
||||||
if (hasRedirected === false) { next() }
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
preFetchList.reduce(
|
|
||||||
(promise, preFetch) => promise.then(() => hasRedirected === false && preFetch({
|
|
||||||
store,
|
|
||||||
currentRoute: to,
|
|
||||||
previousRoute: from,
|
|
||||||
redirect,
|
|
||||||
urlPath,
|
|
||||||
publicPath
|
|
||||||
})),
|
|
||||||
Promise.resolve()
|
|
||||||
)
|
|
||||||
.then(proceed)
|
|
||||||
.catch(e => {
|
|
||||||
console.error(e)
|
|
||||||
proceed()
|
|
||||||
})
|
|
||||||
})
|
|
||||||
}
|
|
||||||
@@ -1,23 +0,0 @@
|
|||||||
/* eslint-disable */
|
|
||||||
/**
|
|
||||||
* THIS FILE IS GENERATED AUTOMATICALLY.
|
|
||||||
* DO NOT EDIT.
|
|
||||||
*
|
|
||||||
* You are probably looking on adding startup/initialization code.
|
|
||||||
* Use "quasar new boot <name>" and add it there.
|
|
||||||
* One boot file per concern. Then reference the file(s) in quasar.config file > boot:
|
|
||||||
* boot: ['file', ...] // do not add ".js" extension to it.
|
|
||||||
*
|
|
||||||
* Boot files are your "main.js"
|
|
||||||
**/
|
|
||||||
|
|
||||||
import lang from 'quasar/lang/tr.js'
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import {Loading,Dialog,Notify} from 'quasar'
|
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
export default { config: {"notify":{"position":"top","timeout":2500}},lang,plugins: {Loading,Dialog,Notify} }
|
|
||||||
|
|
||||||
@@ -368,6 +368,16 @@ const menuItems = [
|
|||||||
to: '/app/pricing/wholesale-campaigns',
|
to: '/app/pricing/wholesale-campaigns',
|
||||||
permission: 'pricing:view'
|
permission: 'pricing:view'
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: 'Ürün Seri Eşleşmeleri',
|
||||||
|
to: '/app/pricing/product-series-mappings',
|
||||||
|
permission: 'pricing:view'
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: 'Ürün Seri Tanımlamaları',
|
||||||
|
to: '/app/pricing/product-series-definitions',
|
||||||
|
permission: 'pricing:view'
|
||||||
|
},
|
||||||
{
|
{
|
||||||
label: 'Marka Sınıflandırma',
|
label: 'Marka Sınıflandırma',
|
||||||
to: '/app/pricing/brand-classification',
|
to: '/app/pricing/brand-classification',
|
||||||
|
|||||||
195
ui/src/pages/ProductSeriesDefinitions.vue
Normal file
195
ui/src/pages/ProductSeriesDefinitions.vue
Normal file
@@ -0,0 +1,195 @@
|
|||||||
|
<template>
|
||||||
|
<q-page class="q-pa-md">
|
||||||
|
<div class="row items-center justify-between q-mb-md">
|
||||||
|
<div>
|
||||||
|
<div class="text-h6">Urun Seri Tanimlamalari</div>
|
||||||
|
<div class="text-caption text-grey-7">Seri kodu ve beden seri basliklari burada yonetilir.</div>
|
||||||
|
</div>
|
||||||
|
<div class="row q-gutter-sm">
|
||||||
|
<q-btn color="secondary" outline icon="refresh" label="Yenile" :loading="loading" @click="reload" />
|
||||||
|
<q-btn color="primary" icon="add" label="Yeni Seri" :disable="!canUpdate" @click="newRow" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-table
|
||||||
|
flat
|
||||||
|
bordered
|
||||||
|
row-key="id"
|
||||||
|
:rows="rows"
|
||||||
|
:columns="columns"
|
||||||
|
:loading="loading"
|
||||||
|
:pagination="{ rowsPerPage: 0 }"
|
||||||
|
hide-bottom
|
||||||
|
class="bg-white"
|
||||||
|
>
|
||||||
|
<template #body-cell-is_active="props">
|
||||||
|
<q-td :props="props">
|
||||||
|
<q-toggle v-model="props.row.is_active" dense :disable="!canUpdate || saving" @update:model-value="() => markDirty(props.row)" />
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #body-cell-actions="props">
|
||||||
|
<q-td :props="props" class="text-right">
|
||||||
|
<q-btn dense flat round icon="edit" color="primary" :disable="!canUpdate" @click="editRow(props.row)" />
|
||||||
|
<q-btn dense flat round icon="delete" color="negative" :disable="!canUpdate" @click="removeRow(props.row)" />
|
||||||
|
</q-td>
|
||||||
|
</template>
|
||||||
|
</q-table>
|
||||||
|
|
||||||
|
<q-dialog v-model="dialogOpen" persistent>
|
||||||
|
<q-card style="min-width: 520px">
|
||||||
|
<q-card-section class="row items-center justify-between">
|
||||||
|
<div class="text-subtitle1">{{ editing.id ? 'Seri Duzenle' : 'Yeni Seri' }}</div>
|
||||||
|
<q-btn flat round dense icon="close" v-close-popup />
|
||||||
|
</q-card-section>
|
||||||
|
<q-separator />
|
||||||
|
<q-card-section class="q-gutter-md">
|
||||||
|
<q-input v-model="editing.code" dense outlined label="Seri Kodu" autofocus />
|
||||||
|
<q-input v-model="editing.title" dense outlined label="Seri Basligi" />
|
||||||
|
<q-input v-model="editing.parent_filter" dense outlined label="Parent Filter" />
|
||||||
|
<q-input v-model.number="editing.sort_order" dense outlined type="number" label="Sira" />
|
||||||
|
<q-input v-model="editing.notes" dense outlined type="textarea" label="Not" />
|
||||||
|
<q-toggle v-model="editing.is_active" label="Aktif" />
|
||||||
|
</q-card-section>
|
||||||
|
<q-card-actions align="right">
|
||||||
|
<q-btn flat label="Vazgec" v-close-popup />
|
||||||
|
<q-btn color="primary" icon="save" label="Kaydet" :loading="saving" @click="saveDialog" />
|
||||||
|
</q-card-actions>
|
||||||
|
</q-card>
|
||||||
|
</q-dialog>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { Dialog, Notify } from 'quasar'
|
||||||
|
import api from 'src/services/api'
|
||||||
|
import { usePermissionStore } from 'stores/permissionStore'
|
||||||
|
|
||||||
|
const perm = usePermissionStore()
|
||||||
|
const canUpdate = computed(() => perm.hasApiPermission('pricing:update'))
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const rows = ref([])
|
||||||
|
const dialogOpen = ref(false)
|
||||||
|
const editing = ref(emptyRow())
|
||||||
|
|
||||||
|
const columns = [
|
||||||
|
{ name: 'code', label: 'Seri Kodu', field: 'code', align: 'left', sortable: true },
|
||||||
|
{ name: 'title', label: 'Seri Basligi', field: 'title', align: 'left', sortable: true },
|
||||||
|
{ name: 'parent_filter', label: 'Parent Filter', field: 'parent_filter', align: 'left', sortable: true },
|
||||||
|
{ name: 'sort_order', label: 'Sira', field: 'sort_order', align: 'right', sortable: true },
|
||||||
|
{ name: 'is_active', label: 'Aktif', field: 'is_active', align: 'center', sortable: true },
|
||||||
|
{ name: 'actions', label: '', field: 'actions', align: 'right' }
|
||||||
|
]
|
||||||
|
|
||||||
|
function emptyRow () {
|
||||||
|
return {
|
||||||
|
id: 0,
|
||||||
|
code: '',
|
||||||
|
title: '',
|
||||||
|
is_active: true,
|
||||||
|
parent_filter: '',
|
||||||
|
sort_order: 0,
|
||||||
|
notes: ''
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRow (row) {
|
||||||
|
return {
|
||||||
|
id: Number(row.id || 0),
|
||||||
|
code: String(row.code || '').trim(),
|
||||||
|
title: String(row.title || '').trim(),
|
||||||
|
is_active: row.is_active !== false,
|
||||||
|
parent_filter: String(row.parent_filter || '').trim(),
|
||||||
|
sort_order: Number(row.sort_order || 0),
|
||||||
|
notes: String(row.notes || '').trim()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reload () {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const res = await api.get('/pricing/product-series/definitions')
|
||||||
|
rows.value = (Array.isArray(res.data) ? res.data : []).map(normalizeRow)
|
||||||
|
} catch (err) {
|
||||||
|
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Seri tanimlari alinamadi' })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function newRow () {
|
||||||
|
editing.value = emptyRow()
|
||||||
|
dialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function editRow (row) {
|
||||||
|
editing.value = { ...normalizeRow(row) }
|
||||||
|
dialogOpen.value = true
|
||||||
|
}
|
||||||
|
|
||||||
|
function markDirty (row) {
|
||||||
|
saveInline(row)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveInline (row) {
|
||||||
|
if (!canUpdate.value || !row.id) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await api.put(`/pricing/product-series/definitions/${row.id}`, normalizeRow(row))
|
||||||
|
Notify.create({ type: 'positive', message: 'Kaydedildi' })
|
||||||
|
} catch (err) {
|
||||||
|
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Seri tanimi kaydedilemedi' })
|
||||||
|
await reload()
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDialog () {
|
||||||
|
const payload = normalizeRow(editing.value)
|
||||||
|
if (!payload.code) {
|
||||||
|
Notify.create({ type: 'warning', message: 'Seri kodu zorunludur' })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
if (payload.id) {
|
||||||
|
await api.put(`/pricing/product-series/definitions/${payload.id}`, payload)
|
||||||
|
} else {
|
||||||
|
await api.post('/pricing/product-series/definitions', payload)
|
||||||
|
}
|
||||||
|
dialogOpen.value = false
|
||||||
|
await reload()
|
||||||
|
Notify.create({ type: 'positive', message: 'Kaydedildi' })
|
||||||
|
} catch (err) {
|
||||||
|
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Seri tanimi kaydedilemedi' })
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeRow (row) {
|
||||||
|
Dialog.create({
|
||||||
|
title: 'Seri pasife alinsin mi?',
|
||||||
|
message: `${row.code} - ${row.title}`,
|
||||||
|
cancel: true,
|
||||||
|
persistent: true
|
||||||
|
}).onOk(async () => {
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await api.delete(`/pricing/product-series/definitions/${row.id}`)
|
||||||
|
await reload()
|
||||||
|
Notify.create({ type: 'positive', message: 'Pasife alindi' })
|
||||||
|
} catch (err) {
|
||||||
|
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Seri tanimi silinemedi' })
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(reload)
|
||||||
|
</script>
|
||||||
939
ui/src/pages/ProductSeriesMappings.vue
Normal file
939
ui/src/pages/ProductSeriesMappings.vue
Normal file
@@ -0,0 +1,939 @@
|
|||||||
|
<template>
|
||||||
|
<q-page
|
||||||
|
class="order-page product-series-page q-pa-md"
|
||||||
|
:style="{
|
||||||
|
'--grid-header-h': showGridHeader ? `${schemaRows.length * 28}px` : '0px',
|
||||||
|
'--beden-count': 16
|
||||||
|
}"
|
||||||
|
>
|
||||||
|
<div class="sticky-stack">
|
||||||
|
<div class="save-toolbar row items-center q-gutter-sm">
|
||||||
|
<div>
|
||||||
|
<div class="text-subtitle2 text-weight-bold">Urun Seri Eslesmeleri</div>
|
||||||
|
<div class="text-caption text-grey-8">
|
||||||
|
Stogu olan tum varyantlar beden bazinda listelenir; seri secenekleri satir bazinda kaydedilir.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<q-space />
|
||||||
|
<q-btn color="primary" outline icon="grid_on" label="Excel" :disable="!displayRows.length" @click="exportVisibleExcel" />
|
||||||
|
<q-btn color="secondary" outline icon="refresh" label="Yenile" :loading="loading" @click="reload" />
|
||||||
|
<q-btn color="primary" icon="save" label="Kaydet" :disable="!canUpdate || dirtyRows.length === 0" :loading="saving" @click="saveDirty" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="showGridHeader" class="order-grid-header series-grid-header">
|
||||||
|
<div class="col-fixed model">
|
||||||
|
<div class="filter-head">
|
||||||
|
<span>MODEL</span>
|
||||||
|
<q-btn dense flat round size="sm" icon="filter_list" :color="activeFilterCount('model') ? 'primary' : 'grey-8'">
|
||||||
|
<q-badge v-if="activeFilterCount('model')" floating color="primary">{{ activeFilterCount('model') }}</q-badge>
|
||||||
|
<q-menu class="column-filter-menu" anchor="bottom left" self="top left">
|
||||||
|
<div class="q-pa-sm">
|
||||||
|
<q-input v-model="filterSearch.model" dense outlined clearable placeholder="Ara" autofocus />
|
||||||
|
<div class="row q-gutter-xs q-mt-sm">
|
||||||
|
<q-btn dense flat size="sm" label="Tumu" @click="selectAllFilter('model')" />
|
||||||
|
<q-btn dense flat size="sm" label="Temizle" @click="clearFilter('model')" />
|
||||||
|
</div>
|
||||||
|
<q-separator class="q-my-sm" />
|
||||||
|
<q-option-group
|
||||||
|
v-model="columnFilters.model"
|
||||||
|
:options="filteredFilterOptions('model')"
|
||||||
|
type="checkbox"
|
||||||
|
dense
|
||||||
|
class="column-filter-options"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-menu>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-fixed desc-col">
|
||||||
|
<div class="filter-head">
|
||||||
|
<span>DESC</span>
|
||||||
|
<q-btn dense flat round size="sm" icon="filter_list" :color="activeFilterCount('desc') ? 'primary' : 'grey-8'">
|
||||||
|
<q-badge v-if="activeFilterCount('desc')" floating color="primary">{{ activeFilterCount('desc') }}</q-badge>
|
||||||
|
<q-menu class="column-filter-menu" anchor="bottom left" self="top left">
|
||||||
|
<div class="q-pa-sm">
|
||||||
|
<q-input v-model="filterSearch.desc" dense outlined clearable placeholder="Ara" autofocus />
|
||||||
|
<div class="row q-gutter-xs q-mt-sm">
|
||||||
|
<q-btn dense flat size="sm" label="Tumu" @click="selectAllFilter('desc')" />
|
||||||
|
<q-btn dense flat size="sm" label="Temizle" @click="clearFilter('desc')" />
|
||||||
|
</div>
|
||||||
|
<q-separator class="q-my-sm" />
|
||||||
|
<q-option-group
|
||||||
|
v-model="columnFilters.desc"
|
||||||
|
:options="filteredFilterOptions('desc')"
|
||||||
|
type="checkbox"
|
||||||
|
dense
|
||||||
|
class="column-filter-options"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-menu>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-fixed renk">
|
||||||
|
<div class="filter-head">
|
||||||
|
<span>RENK</span>
|
||||||
|
<q-btn dense flat round size="sm" icon="filter_list" :color="activeFilterCount('renk') ? 'primary' : 'grey-8'">
|
||||||
|
<q-badge v-if="activeFilterCount('renk')" floating color="primary">{{ activeFilterCount('renk') }}</q-badge>
|
||||||
|
<q-menu class="column-filter-menu" anchor="bottom left" self="top left">
|
||||||
|
<div class="q-pa-sm">
|
||||||
|
<q-input v-model="filterSearch.renk" dense outlined clearable placeholder="Ara" autofocus />
|
||||||
|
<div class="row q-gutter-xs q-mt-sm">
|
||||||
|
<q-btn dense flat size="sm" label="Tumu" @click="selectAllFilter('renk')" />
|
||||||
|
<q-btn dense flat size="sm" label="Temizle" @click="clearFilter('renk')" />
|
||||||
|
</div>
|
||||||
|
<q-separator class="q-my-sm" />
|
||||||
|
<q-option-group
|
||||||
|
v-model="columnFilters.renk"
|
||||||
|
:options="filteredFilterOptions('renk')"
|
||||||
|
type="checkbox"
|
||||||
|
dense
|
||||||
|
class="column-filter-options"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-menu>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-fixed ana">
|
||||||
|
<div class="filter-head">
|
||||||
|
<span>URUN ANA GRUBU</span>
|
||||||
|
<q-btn dense flat round size="sm" icon="filter_list" :color="activeFilterCount('ana') ? 'primary' : 'grey-8'">
|
||||||
|
<q-badge v-if="activeFilterCount('ana')" floating color="primary">{{ activeFilterCount('ana') }}</q-badge>
|
||||||
|
<q-menu class="column-filter-menu" anchor="bottom left" self="top left">
|
||||||
|
<div class="q-pa-sm">
|
||||||
|
<q-input v-model="filterSearch.ana" dense outlined clearable placeholder="Ara" autofocus />
|
||||||
|
<div class="row q-gutter-xs q-mt-sm">
|
||||||
|
<q-btn dense flat size="sm" label="Tumu" @click="selectAllFilter('ana')" />
|
||||||
|
<q-btn dense flat size="sm" label="Temizle" @click="clearFilter('ana')" />
|
||||||
|
</div>
|
||||||
|
<q-separator class="q-my-sm" />
|
||||||
|
<q-option-group
|
||||||
|
v-model="columnFilters.ana"
|
||||||
|
:options="filteredFilterOptions('ana')"
|
||||||
|
type="checkbox"
|
||||||
|
dense
|
||||||
|
class="column-filter-options"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-menu>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-fixed alt">
|
||||||
|
<div class="filter-head">
|
||||||
|
<span>URUN ALT GRUBU</span>
|
||||||
|
<q-btn dense flat round size="sm" icon="filter_list" :color="activeFilterCount('alt') ? 'primary' : 'grey-8'">
|
||||||
|
<q-badge v-if="activeFilterCount('alt')" floating color="primary">{{ activeFilterCount('alt') }}</q-badge>
|
||||||
|
<q-menu class="column-filter-menu" anchor="bottom left" self="top left">
|
||||||
|
<div class="q-pa-sm">
|
||||||
|
<q-input v-model="filterSearch.alt" dense outlined clearable placeholder="Ara" autofocus />
|
||||||
|
<div class="row q-gutter-xs q-mt-sm">
|
||||||
|
<q-btn dense flat size="sm" label="Tumu" @click="selectAllFilter('alt')" />
|
||||||
|
<q-btn dense flat size="sm" label="Temizle" @click="clearFilter('alt')" />
|
||||||
|
</div>
|
||||||
|
<q-separator class="q-my-sm" />
|
||||||
|
<q-option-group
|
||||||
|
v-model="columnFilters.alt"
|
||||||
|
:options="filteredFilterOptions('alt')"
|
||||||
|
type="checkbox"
|
||||||
|
dense
|
||||||
|
class="column-filter-options"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-menu>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-fixed marka">
|
||||||
|
<div class="filter-head">
|
||||||
|
<span>MARKA</span>
|
||||||
|
<q-btn dense flat round size="sm" icon="filter_list" :color="activeFilterCount('marka') ? 'primary' : 'grey-8'">
|
||||||
|
<q-badge v-if="activeFilterCount('marka')" floating color="primary">{{ activeFilterCount('marka') }}</q-badge>
|
||||||
|
<q-menu class="column-filter-menu" anchor="bottom left" self="top left">
|
||||||
|
<div class="q-pa-sm">
|
||||||
|
<q-input v-model="filterSearch.marka" dense outlined clearable placeholder="Ara" autofocus />
|
||||||
|
<div class="row q-gutter-xs q-mt-sm">
|
||||||
|
<q-btn dense flat size="sm" label="Tumu" @click="selectAllFilter('marka')" />
|
||||||
|
<q-btn dense flat size="sm" label="Temizle" @click="clearFilter('marka')" />
|
||||||
|
</div>
|
||||||
|
<q-separator class="q-my-sm" />
|
||||||
|
<q-option-group
|
||||||
|
v-model="columnFilters.marka"
|
||||||
|
:options="filteredFilterOptions('marka')"
|
||||||
|
type="checkbox"
|
||||||
|
dense
|
||||||
|
class="column-filter-options"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</q-menu>
|
||||||
|
</q-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="beden-block">
|
||||||
|
<div
|
||||||
|
v-for="grp in schemaRows"
|
||||||
|
:key="'series-hdr-' + grp.key"
|
||||||
|
class="grp-row"
|
||||||
|
>
|
||||||
|
<div class="grp-title">{{ grp.title }}</div>
|
||||||
|
<div class="grp-body">
|
||||||
|
<div v-for="v in paddedSchemaValues(grp)" :key="'hdr-' + grp.key + '-' + v.key" class="grp-cell hdr" :class="{ ghost: v.ghost }">
|
||||||
|
{{ v.ghost ? '' : v.value }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="total-header-cell">TOPLAM</div>
|
||||||
|
<div class="series-header-cell">SERI</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<q-banner v-if="errorMessage" class="bg-red-1 text-negative q-my-sm rounded-borders" dense>
|
||||||
|
{{ errorMessage }}
|
||||||
|
</q-banner>
|
||||||
|
|
||||||
|
<q-banner v-else-if="!loading && !displayRows.length" class="bg-blue-1 text-primary q-my-sm rounded-borders" dense>
|
||||||
|
Filtrelere uygun stoklu urun bulunamadi.
|
||||||
|
</q-banner>
|
||||||
|
|
||||||
|
<q-inner-loading :showing="loading">
|
||||||
|
<q-spinner color="primary" size="42px" />
|
||||||
|
</q-inner-loading>
|
||||||
|
|
||||||
|
<div class="order-scroll-y series-scroll">
|
||||||
|
<div v-if="displayRows.length" class="order-grid-body series-grid-body">
|
||||||
|
<div
|
||||||
|
v-for="row in displayRows"
|
||||||
|
:key="row.row_key"
|
||||||
|
class="series-flat-row"
|
||||||
|
:class="{ dirty: row._dirty, warning: !row.mapping_ready }"
|
||||||
|
>
|
||||||
|
<div class="sub-col model">{{ row.product_code || '-' }}</div>
|
||||||
|
<div class="sub-col desc">{{ row.product_description || '-' }}</div>
|
||||||
|
<div class="sub-col renk">
|
||||||
|
<div class="renk-kodu">{{ variantCode(row) }}</div>
|
||||||
|
<div class="renk-aciklama">{{ row.color_title || '-' }}</div>
|
||||||
|
</div>
|
||||||
|
<div class="sub-col ana">{{ row.urun_ana_grubu || '-' }}</div>
|
||||||
|
<div class="sub-col alt">{{ row.urun_alt_grubu || '-' }}</div>
|
||||||
|
<div class="sub-col marka">{{ row.marka || '-' }}</div>
|
||||||
|
|
||||||
|
<div class="flat-size-cells">
|
||||||
|
<div v-for="sz in rowSizeCells(row)" :key="`${row.row_key}-${sz.key}`" class="beden-cell" :class="{ ghost: sz.ghost }">
|
||||||
|
{{ formatQty(row._mapped_size_qty?.[sz.value]) }}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="total-cell">{{ formatQty(row.total_qty) }}</div>
|
||||||
|
|
||||||
|
<div class="series-select-cell" @click.stop>
|
||||||
|
<q-select
|
||||||
|
v-model="row._series_ids"
|
||||||
|
dense
|
||||||
|
outlined
|
||||||
|
multiple
|
||||||
|
emit-value
|
||||||
|
map-options
|
||||||
|
use-chips
|
||||||
|
:options="seriesOptions"
|
||||||
|
option-value="id"
|
||||||
|
option-label="label"
|
||||||
|
:disable="!canUpdate || saving || !canEditSeriesRow(row)"
|
||||||
|
@update:model-value="() => markDirty(row)"
|
||||||
|
>
|
||||||
|
<template #selected-item="scope">
|
||||||
|
<q-chip dense removable @remove="scope.removeAtIndex(scope.index)" class="series-chip">
|
||||||
|
{{ formatSeries(scope.opt) }}
|
||||||
|
</q-chip>
|
||||||
|
</template>
|
||||||
|
</q-select>
|
||||||
|
<div class="series-meta">
|
||||||
|
<q-badge v-if="row._dirty" color="orange-8">Degisti</q-badge>
|
||||||
|
<q-badge v-else color="grey-6">Kayitli</q-badge>
|
||||||
|
<span v-if="row.mapping_warning" class="mapping-warning">{{ row.mapping_warning }}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</q-page>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup>
|
||||||
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
import { Notify } from 'quasar'
|
||||||
|
import api from 'src/services/api'
|
||||||
|
import { usePermissionStore } from 'stores/permissionStore'
|
||||||
|
import {
|
||||||
|
detectBedenGroup,
|
||||||
|
normalizeBedenLabel,
|
||||||
|
schemaByKey as fallbackSchemaByKey,
|
||||||
|
useOrderEntryStore
|
||||||
|
} from 'src/stores/orderentryStore'
|
||||||
|
|
||||||
|
const perm = usePermissionStore()
|
||||||
|
const orderStore = useOrderEntryStore()
|
||||||
|
const canUpdate = computed(() => perm.hasApiPermission('pricing:update'))
|
||||||
|
|
||||||
|
const loading = ref(false)
|
||||||
|
const saving = ref(false)
|
||||||
|
const rows = ref([])
|
||||||
|
const definitions = ref([])
|
||||||
|
const errorMessage = ref('')
|
||||||
|
const columnFilters = ref({
|
||||||
|
model: [],
|
||||||
|
desc: [],
|
||||||
|
renk: [],
|
||||||
|
ana: [],
|
||||||
|
alt: [],
|
||||||
|
marka: []
|
||||||
|
})
|
||||||
|
const filterSearch = ref({
|
||||||
|
model: '',
|
||||||
|
desc: '',
|
||||||
|
renk: '',
|
||||||
|
ana: '',
|
||||||
|
alt: '',
|
||||||
|
marka: ''
|
||||||
|
})
|
||||||
|
|
||||||
|
const showGridHeader = computed(() => !loading.value && productGroups.value.length > 0)
|
||||||
|
|
||||||
|
const seriesOptions = computed(() => definitions.value.map(item => ({
|
||||||
|
...item,
|
||||||
|
label: formatSeries(item)
|
||||||
|
})))
|
||||||
|
|
||||||
|
const dirtyRows = computed(() => rows.value.filter(row => row._dirty))
|
||||||
|
|
||||||
|
const displayRows = computed(() => {
|
||||||
|
const list = rows.value.filter(rowPassesFilters)
|
||||||
|
const productTotals = productTotalQtyMap(list)
|
||||||
|
return list.sort((a, b) => {
|
||||||
|
const groupDiff = Number(productTotals.get(b.product_code) || 0) - Number(productTotals.get(a.product_code) || 0)
|
||||||
|
if (groupDiff !== 0) return groupDiff
|
||||||
|
const code = String(a.product_code || '').localeCompare(String(b.product_code || ''), 'tr')
|
||||||
|
if (code !== 0) return code
|
||||||
|
const color = String(a.color_code || '').localeCompare(String(b.color_code || ''), 'tr')
|
||||||
|
if (color !== 0) return color
|
||||||
|
const dim3 = String(a.dim3_code || '').localeCompare(String(b.dim3_code || ''), 'tr')
|
||||||
|
if (dim3 !== 0) return dim3
|
||||||
|
return Number(b.total_qty || 0) - Number(a.total_qty || 0)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
function productTotalQtyMap (list) {
|
||||||
|
const totals = new Map()
|
||||||
|
for (const row of list || []) {
|
||||||
|
const code = String(row?.product_code || '').trim()
|
||||||
|
if (!code) continue
|
||||||
|
totals.set(code, Number(totals.get(code) || 0) + Number(row?.total_qty || 0))
|
||||||
|
}
|
||||||
|
return totals
|
||||||
|
}
|
||||||
|
|
||||||
|
const productGroups = computed(() => displayRows.value)
|
||||||
|
|
||||||
|
const filterOptions = computed(() => ({
|
||||||
|
model: uniqueOptions(rows.value.map(row => row.product_code)),
|
||||||
|
desc: uniqueOptions(rows.value.map(row => row.product_description)),
|
||||||
|
renk: uniqueOptions(rows.value.map(row => variantCode(row))),
|
||||||
|
ana: uniqueOptions(rows.value.map(row => row.urun_ana_grubu)),
|
||||||
|
alt: uniqueOptions(rows.value.map(row => row.urun_alt_grubu)),
|
||||||
|
marka: uniqueOptions(rows.value.map(row => row.marka))
|
||||||
|
}))
|
||||||
|
|
||||||
|
const schemaRows = computed(() => {
|
||||||
|
const map = getSchemaMap()
|
||||||
|
const preferred = ['tak', 'ayk', 'ayk_garson', 'yas', 'pan', 'gom', 'aksbir']
|
||||||
|
const ordered = preferred
|
||||||
|
.map(key => map?.[key])
|
||||||
|
.filter(Boolean)
|
||||||
|
const extras = Object.values(map || {})
|
||||||
|
.filter(grp => grp?.key && !preferred.includes(grp.key))
|
||||||
|
return [...ordered, ...extras]
|
||||||
|
})
|
||||||
|
|
||||||
|
function uniqueOptions (values) {
|
||||||
|
return [...new Set(values.map(v => String(v || '').trim()).filter(Boolean))]
|
||||||
|
.sort((a, b) => a.localeCompare(b, 'tr'))
|
||||||
|
.map(value => ({ label: value, value }))
|
||||||
|
}
|
||||||
|
|
||||||
|
function filteredFilterOptions (key) {
|
||||||
|
const needle = normalizeFilterText(filterSearch.value[key])
|
||||||
|
const opts = filterOptions.value[key] || []
|
||||||
|
if (!needle) return opts
|
||||||
|
return opts.filter(opt => normalizeFilterText(opt.label).includes(needle))
|
||||||
|
}
|
||||||
|
|
||||||
|
function activeFilterCount (key) {
|
||||||
|
return Array.isArray(columnFilters.value[key]) ? columnFilters.value[key].length : 0
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectAllFilter (key) {
|
||||||
|
columnFilters.value[key] = filteredFilterOptions(key).map(opt => opt.value)
|
||||||
|
}
|
||||||
|
|
||||||
|
function clearFilter (key) {
|
||||||
|
columnFilters.value[key] = []
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowPassesFilters (row) {
|
||||||
|
return filterMatch('model', row.product_code) &&
|
||||||
|
filterMatch('desc', row.product_description) &&
|
||||||
|
filterMatch('renk', variantCode(row)) &&
|
||||||
|
filterMatch('ana', row.urun_ana_grubu) &&
|
||||||
|
filterMatch('alt', row.urun_alt_grubu) &&
|
||||||
|
filterMatch('marka', row.marka)
|
||||||
|
}
|
||||||
|
|
||||||
|
function filterMatch (key, value) {
|
||||||
|
const selected = columnFilters.value[key] || []
|
||||||
|
if (!selected.length) return true
|
||||||
|
return selected.includes(String(value || '').trim())
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeFilterText (value) {
|
||||||
|
return String(value || '')
|
||||||
|
.trim()
|
||||||
|
.toLocaleLowerCase('tr-TR')
|
||||||
|
}
|
||||||
|
|
||||||
|
function sameIDs (a, b) {
|
||||||
|
const aa = [...(a || [])].map(Number).sort((x, y) => x - y).join(',')
|
||||||
|
const bb = [...(b || [])].map(Number).sort((x, y) => x - y).join(',')
|
||||||
|
return aa === bb
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeRow (row) {
|
||||||
|
const ids = Array.isArray(row.series_ids) ? row.series_ids.map(Number).filter(Boolean) : []
|
||||||
|
const mappedInfo = mapRowSizesToSchema(row)
|
||||||
|
return {
|
||||||
|
...row,
|
||||||
|
series_ids: ids,
|
||||||
|
_series_ids: [...ids],
|
||||||
|
_grp_key: mappedInfo.grpKey,
|
||||||
|
_schema: mappedInfo.schema,
|
||||||
|
_mapped_size_qty: mappedInfo.mapped,
|
||||||
|
_dirty: false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function markDirty (row) {
|
||||||
|
row._dirty = !sameIDs(row.series_ids, row._series_ids)
|
||||||
|
}
|
||||||
|
|
||||||
|
function canEditSeriesRow (row) {
|
||||||
|
return !!String(row?.product_code || '').trim() && !!String(row?.color_code || '').trim()
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reload () {
|
||||||
|
loading.value = true
|
||||||
|
errorMessage.value = ''
|
||||||
|
try {
|
||||||
|
if (!orderStore.schemaMap || !Object.keys(orderStore.schemaMap).length) {
|
||||||
|
orderStore.initSchemaMap()
|
||||||
|
}
|
||||||
|
await orderStore.ensureProductSizeMatchRules()
|
||||||
|
const res = await api.get('/pricing/product-series/mappings', { timeout: 180000 })
|
||||||
|
rows.value = (res.data?.rows || []).map(normalizeRow)
|
||||||
|
definitions.value = res.data?.definitions || []
|
||||||
|
} catch (err) {
|
||||||
|
errorMessage.value = err?.response?.data || err?.message || 'Urun seri eslesmeleri alinamadi'
|
||||||
|
Notify.create({ type: 'negative', message: errorMessage.value })
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function paddedSchemaValues (grp) {
|
||||||
|
const values = Array.isArray(grp?.values) ? grp.values.map(v => normalizeBedenLabel(v)) : []
|
||||||
|
const out = values.slice(0, 16).map((value, index) => ({ key: `${value}-${index}`, value, ghost: false, toString: () => value }))
|
||||||
|
while (out.length < 16) {
|
||||||
|
const index = out.length
|
||||||
|
out.push({ key: `ghost-${index}`, value: `__ghost_${index}`, ghost: true, toString: () => '' })
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function rowSizeCells (row) {
|
||||||
|
const schema = row?._schema || getSchemaMap()?.[row?._grp_key] || fallbackSchemaByKey.tak
|
||||||
|
return paddedSchemaValues(schema)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveDirty () {
|
||||||
|
const dirty = dirtyRows.value
|
||||||
|
if (dirty.length === 0) return
|
||||||
|
saving.value = true
|
||||||
|
try {
|
||||||
|
await api.post('/pricing/product-series/mappings/save', {
|
||||||
|
items: dirty.map(row => ({
|
||||||
|
product_code: row.product_code,
|
||||||
|
color_code: row.color_code,
|
||||||
|
dim3_code: row.dim3_code,
|
||||||
|
series_ids: row._series_ids || []
|
||||||
|
}))
|
||||||
|
}, { timeout: 180000 })
|
||||||
|
for (const row of dirty) {
|
||||||
|
row.series_ids = [...(row._series_ids || [])]
|
||||||
|
row._dirty = false
|
||||||
|
}
|
||||||
|
Notify.create({ type: 'positive', message: `Kaydedildi: ${dirty.length} satir` })
|
||||||
|
} catch (err) {
|
||||||
|
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Seri eslesmeleri kaydedilemedi' })
|
||||||
|
} finally {
|
||||||
|
saving.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function getSchemaMap () {
|
||||||
|
return Object.keys(orderStore.schemaMap || {}).length
|
||||||
|
? orderStore.schemaMap
|
||||||
|
: fallbackSchemaByKey
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizedSchemaLabelMap (schema) {
|
||||||
|
const out = new Map()
|
||||||
|
for (const label of schema?.values || []) {
|
||||||
|
const normalized = normalizeBedenLabel(label)
|
||||||
|
if (!out.has(normalized)) out.set(normalized, label)
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectRowGroupKey (row, rawSizeLabels) {
|
||||||
|
const fallback = detectRowGroupKeyFallback(row, rawSizeLabels)
|
||||||
|
if (fallback) return fallback
|
||||||
|
return detectBedenGroup(
|
||||||
|
rawSizeLabels,
|
||||||
|
row.urun_ana_grubu || '',
|
||||||
|
row.kategori || '',
|
||||||
|
row.kategori || '',
|
||||||
|
row.urun_alt_grubu || ''
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeMatchText (value) {
|
||||||
|
return String(value || '')
|
||||||
|
.trim()
|
||||||
|
.toLocaleUpperCase('tr-TR')
|
||||||
|
.normalize('NFD')
|
||||||
|
.replace(/[\u0300-\u036f]/g, '')
|
||||||
|
}
|
||||||
|
|
||||||
|
function detectRowGroupKeyFallback (row, rawSizeLabels) {
|
||||||
|
const ana = normalizeMatchText(row?.urun_ana_grubu)
|
||||||
|
const alt = normalizeMatchText(row?.urun_alt_grubu)
|
||||||
|
const marka = normalizeMatchText(row?.marka)
|
||||||
|
const text = `${ana} ${alt} ${marka}`
|
||||||
|
const sizes = (rawSizeLabels || []).map(v => normalizeBedenLabel(v))
|
||||||
|
const hasLetterSize = sizes.some(v => ['XS', 'S', 'M', 'L', 'XL', '2XL', '3XL', '4XL', '5XL', '6XL', '7XL'].includes(v))
|
||||||
|
|
||||||
|
if (['KRAVAT', 'PAPYON', 'KEMER', 'CORAP', 'FULAR', 'MENDIL', 'KASKOL', 'ASKI', 'AKSESUAR'].some(key => text.includes(key))) return 'aksbir'
|
||||||
|
if (text.includes('AYAKKABI')) return text.includes('GARSON') ? 'ayk_garson' : 'ayk'
|
||||||
|
if (text.includes('PANTOLON')) return 'pan'
|
||||||
|
if (text.includes('GOMLEK') || hasLetterSize) return 'gom'
|
||||||
|
if (text.includes('TAKIM') || text.includes('DAMATLIK') || text.includes('CEKET') || text.includes('KABAN') || text.includes('MONT') || text.includes('YELEK')) return 'tak'
|
||||||
|
|
||||||
|
return ''
|
||||||
|
}
|
||||||
|
|
||||||
|
function mapRowSizesToSchema (row) {
|
||||||
|
const sizeEntries = Object.entries(row?.size_qty || {})
|
||||||
|
const rawSizeLabels = sizeEntries.map(([rawSize]) => normalizeBedenLabel(rawSize))
|
||||||
|
const schemaMap = getSchemaMap()
|
||||||
|
const grpKey = detectRowGroupKey(row, rawSizeLabels)
|
||||||
|
const schema = grpKey && schemaMap?.[grpKey] ? schemaMap[grpKey] : null
|
||||||
|
const schemaLabelMap = normalizedSchemaLabelMap(schema)
|
||||||
|
const mapped = {}
|
||||||
|
|
||||||
|
for (const [rawSize, rawQty] of sizeEntries) {
|
||||||
|
const normalized = normalizeBedenLabel(rawSize)
|
||||||
|
const target = normalizeBedenLabel(schemaLabelMap.get(normalized) || normalized)
|
||||||
|
mapped[target] = Number(mapped[target] || 0) + Number(rawQty || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grpKey === 'aksbir' && !Object.keys(mapped).length && Number(row?.total_qty || 0) > 0) {
|
||||||
|
mapped[' '] = Number(row.total_qty || 0)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (grpKey !== 'aksbir' && Object.keys(mapped).some(k => String(k).trim() !== '')) {
|
||||||
|
delete mapped[' ']
|
||||||
|
}
|
||||||
|
|
||||||
|
return { grpKey, schema, mapped }
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatQty (value) {
|
||||||
|
const n = Number(value || 0)
|
||||||
|
if (!Number.isFinite(n) || n === 0) return ''
|
||||||
|
return n.toLocaleString('tr-TR', { maximumFractionDigits: 2 })
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatSeries (item) {
|
||||||
|
const code = String(item?.code || '').trim()
|
||||||
|
const title = String(item?.title || '').trim()
|
||||||
|
return title ? `${code}/${title}` : code
|
||||||
|
}
|
||||||
|
|
||||||
|
function variantCode (row) {
|
||||||
|
const c = String(row?.color_code || '').trim()
|
||||||
|
const d = String(row?.dim3_code || '').trim()
|
||||||
|
return d ? `${c}-${d}` : (c || '-')
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportFileStamp () {
|
||||||
|
const d = new Date()
|
||||||
|
const pad = (n) => String(n).padStart(2, '0')
|
||||||
|
return `${d.getFullYear()}${pad(d.getMonth() + 1)}${pad(d.getDate())}-${pad(d.getHours())}${pad(d.getMinutes())}`
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtml (value) {
|
||||||
|
return String(value ?? '')
|
||||||
|
.replace(/&/g, '&')
|
||||||
|
.replace(/</g, '<')
|
||||||
|
.replace(/>/g, '>')
|
||||||
|
.replace(/"/g, '"')
|
||||||
|
.replace(/'/g, ''')
|
||||||
|
}
|
||||||
|
|
||||||
|
function exportVisibleExcel () {
|
||||||
|
const groups = schemaRows.value
|
||||||
|
const rowSpan = Math.max(groups.length, 1)
|
||||||
|
const leftHeaders = ['MODEL', 'DESC', 'RENK', 'URUN ANA GRUBU', 'URUN ALT GRUBU', 'MARKA']
|
||||||
|
const headerRows = (groups.length ? groups : [{ key: 'tak', title: 'TAKIM ELBISE', values: [] }]).map((grp, index) => {
|
||||||
|
const left = index === 0
|
||||||
|
? leftHeaders.map(h => `<th rowspan="${rowSpan}">${escapeHtml(h)}</th>`).join('')
|
||||||
|
: ''
|
||||||
|
const right = index === 0
|
||||||
|
? `<th rowspan="${rowSpan}">${escapeHtml('TOPLAM')}</th><th rowspan="${rowSpan}">${escapeHtml('SERI')}</th>`
|
||||||
|
: ''
|
||||||
|
const sizeHeaders = paddedSchemaValues(grp)
|
||||||
|
.map(cell => `<th>${escapeHtml(cell.ghost ? '' : cell.value)}</th>`)
|
||||||
|
.join('')
|
||||||
|
return `<tr>${left}<th>${escapeHtml(grp.title || '')}</th>${sizeHeaders}${right}</tr>`
|
||||||
|
}).join('')
|
||||||
|
const rowsHtml = displayRows.value.map(row => {
|
||||||
|
const sizeCells = rowSizeCells(row).map(cell => {
|
||||||
|
const value = cell.ghost ? '' : formatQty(row._mapped_size_qty?.[cell.value])
|
||||||
|
return `<td style="text-align:center;">${escapeHtml(value)}</td>`
|
||||||
|
}).join('')
|
||||||
|
const seriesText = (row._series_ids || [])
|
||||||
|
.map(id => seriesOptions.value.find(opt => Number(opt.id) === Number(id)))
|
||||||
|
.filter(Boolean)
|
||||||
|
.map(formatSeries)
|
||||||
|
.join(', ')
|
||||||
|
return `<tr>
|
||||||
|
<td>${escapeHtml(row.product_code || '')}</td>
|
||||||
|
<td>${escapeHtml(row.product_description || '')}</td>
|
||||||
|
<td>${escapeHtml(variantCode(row))}</td>
|
||||||
|
<td>${escapeHtml(row.urun_ana_grubu || '')}</td>
|
||||||
|
<td>${escapeHtml(row.urun_alt_grubu || '')}</td>
|
||||||
|
<td>${escapeHtml(row.marka || '')}</td>
|
||||||
|
<td>${escapeHtml(row._schema?.title || '')}</td>
|
||||||
|
${sizeCells}
|
||||||
|
<td style="text-align:right;">${escapeHtml(formatQty(row.total_qty))}</td>
|
||||||
|
<td>${escapeHtml(seriesText)}</td>
|
||||||
|
</tr>`
|
||||||
|
}).join('')
|
||||||
|
const html = `<!doctype html><html xmlns:x="urn:schemas-microsoft-com:office:excel"><head><meta charset="utf-8"><style>th{background:#fff8d1;font-weight:700;text-align:center;}td,th{border:1px solid #999;padding:4px;mso-number-format:'\\@';}.group-title{font-weight:700;}</style></head><body><table border="1"><thead>${headerRows}</thead><tbody>${rowsHtml}</tbody></table></body></html>`
|
||||||
|
const blob = new Blob([html], { type: 'application/vnd.ms-excel;charset=utf-8' })
|
||||||
|
const url = URL.createObjectURL(blob)
|
||||||
|
const a = document.createElement('a')
|
||||||
|
a.href = url
|
||||||
|
a.download = `urun_seri_eslesmeleri_${exportFileStamp()}.xls`
|
||||||
|
document.body.appendChild(a)
|
||||||
|
a.click()
|
||||||
|
a.remove()
|
||||||
|
URL.revokeObjectURL(url)
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(async () => {
|
||||||
|
await orderStore.ensureProductSizeMatchRules()
|
||||||
|
await reload()
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.product-series-page {
|
||||||
|
--filter-h: 0px;
|
||||||
|
--psq-sticky-offset: 12px;
|
||||||
|
--grp-title-w: 90px;
|
||||||
|
--col-desc: var(--col-aciklama);
|
||||||
|
--col-marka-series: var(--col-marka, 90px);
|
||||||
|
--psq-header-h: var(--grid-header-h);
|
||||||
|
--series-total-w: 76px;
|
||||||
|
--series-col-w: minmax(220px, 1fr);
|
||||||
|
background: #f5f1da;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-scroll {
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-grid-header,
|
||||||
|
.series-flat-row {
|
||||||
|
grid-template-columns:
|
||||||
|
var(--col-model)
|
||||||
|
var(--col-desc)
|
||||||
|
var(--col-renk)
|
||||||
|
var(--col-ana)
|
||||||
|
var(--col-alt)
|
||||||
|
var(--col-marka-series)
|
||||||
|
calc(var(--grp-title-w) + var(--grp-title-gap) + (var(--beden-w) * var(--beden-count)))
|
||||||
|
var(--series-total-w)
|
||||||
|
var(--series-col-w) !important;
|
||||||
|
width: 100%;
|
||||||
|
min-width: min-content;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-grid-header {
|
||||||
|
top: calc(var(--header-h) + var(--filter-h) + var(--save-h) + var(--psq-sticky-offset)) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-grid-header .col-fixed,
|
||||||
|
.total-header-cell,
|
||||||
|
.series-header-cell {
|
||||||
|
writing-mode: horizontal-tb !important;
|
||||||
|
transform: none !important;
|
||||||
|
height: var(--psq-header-h) !important;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 10px !important;
|
||||||
|
line-height: 1 !important;
|
||||||
|
white-space: nowrap;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
padding: 0 4px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-head {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 2px;
|
||||||
|
width: 100%;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.filter-head span {
|
||||||
|
min-width: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-filter-menu {
|
||||||
|
width: 280px;
|
||||||
|
max-width: 86vw;
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-filter-options {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-grid-header .beden-block {
|
||||||
|
height: var(--psq-header-h) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-grid-header .grp-row {
|
||||||
|
height: var(--beden-h) !important;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-grid-header .grp-title {
|
||||||
|
width: var(--grp-title-w) !important;
|
||||||
|
text-align: center !important;
|
||||||
|
padding-right: 0 !important;
|
||||||
|
font-size: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-grid-header .grp-cell.hdr {
|
||||||
|
height: var(--beden-h) !important;
|
||||||
|
font-size: 10px !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-grid-header .grp-cell.hdr.ghost {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-header-cell,
|
||||||
|
.series-header-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
background: #fff8d1;
|
||||||
|
border-left: 1px solid #d4c79f;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-header-cell {
|
||||||
|
grid-column: 8 / 9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-header-cell {
|
||||||
|
grid-column: 9 / 10;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-flat-row {
|
||||||
|
display: grid;
|
||||||
|
min-height: 72px;
|
||||||
|
align-items: center;
|
||||||
|
background: #fff9c4 !important;
|
||||||
|
border-top: 1px solid #d4c79f !important;
|
||||||
|
border-bottom: 1px solid #d4c79f !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-flat-row.dirty {
|
||||||
|
background: #fff0cf !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-flat-row.warning {
|
||||||
|
box-shadow: inset 3px 0 0 #f59e0b;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-flat-row .sub-col {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0 8px;
|
||||||
|
font-size: 12px;
|
||||||
|
font-weight: 500;
|
||||||
|
color: #111;
|
||||||
|
min-width: 0;
|
||||||
|
border-right: 1px solid #d4c79f;
|
||||||
|
white-space: normal;
|
||||||
|
overflow: visible;
|
||||||
|
line-height: 1.2;
|
||||||
|
word-break: break-word;
|
||||||
|
overflow-wrap: anywhere;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-flat-row .sub-col.model { grid-column: 1; }
|
||||||
|
.series-flat-row .sub-col.desc { grid-column: 2; }
|
||||||
|
.series-flat-row .sub-col.renk { grid-column: 3; }
|
||||||
|
.series-flat-row .sub-col.ana { grid-column: 4; }
|
||||||
|
.series-flat-row .sub-col.alt { grid-column: 5; }
|
||||||
|
.series-flat-row .sub-col.marka { grid-column: 6; }
|
||||||
|
|
||||||
|
.series-flat-row .sub-col.model,
|
||||||
|
.series-flat-row .sub-col.renk,
|
||||||
|
.series-flat-row .sub-col.ana,
|
||||||
|
.series-flat-row .sub-col.alt,
|
||||||
|
.series-flat-row .sub-col.marka {
|
||||||
|
justify-content: center;
|
||||||
|
text-align: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-flat-row .sub-col.desc {
|
||||||
|
justify-content: flex-start;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-flat-row .sub-col.renk {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 2px;
|
||||||
|
line-height: 1.1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-flat-row .sub-col.renk .renk-kodu {
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-flat-row .sub-col.renk .renk-aciklama {
|
||||||
|
font-size: 11px;
|
||||||
|
opacity: 0.9;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flat-size-cells {
|
||||||
|
grid-column: 7;
|
||||||
|
display: grid;
|
||||||
|
grid-auto-flow: column;
|
||||||
|
grid-auto-columns: var(--beden-w);
|
||||||
|
justify-content: start;
|
||||||
|
align-items: stretch;
|
||||||
|
width: calc(var(--grp-title-w) + var(--grp-title-gap) + (var(--beden-w) * var(--beden-count)));
|
||||||
|
padding-left: calc(var(--grp-title-w) + var(--grp-title-gap));
|
||||||
|
margin-left: 0;
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flat-size-cells::before,
|
||||||
|
.flat-size-cells::after {
|
||||||
|
content: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flat-size-cells .beden-cell {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
width: var(--beden-w);
|
||||||
|
height: 100%;
|
||||||
|
box-sizing: border-box;
|
||||||
|
border: 1px solid #d4c79f;
|
||||||
|
border-right: none;
|
||||||
|
background: #fffef6;
|
||||||
|
color: #1f1f1f;
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flat-size-cells .beden-cell:last-child {
|
||||||
|
border-right: 1px solid #d4c79f;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flat-size-cells .beden-cell.ghost {
|
||||||
|
color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.total-cell {
|
||||||
|
grid-column: 8;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: flex-end;
|
||||||
|
padding: 0 10px;
|
||||||
|
border-left: 1px solid #d4c79f;
|
||||||
|
border-right: 1px solid #d4c79f;
|
||||||
|
color: var(--q-primary, #1976d2);
|
||||||
|
font-size: 14px;
|
||||||
|
font-weight: 800;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-select-cell {
|
||||||
|
grid-column: 9 / 10;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
gap: 6px;
|
||||||
|
padding: 8px 10px;
|
||||||
|
border-left: 1px solid #d4c79f;
|
||||||
|
background: #fffdf0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-select-cell :deep(.q-field__control) {
|
||||||
|
min-height: 36px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-chip {
|
||||||
|
max-width: 170px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.series-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
min-height: 18px;
|
||||||
|
font-size: 11px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mapping-warning {
|
||||||
|
color: #b45309;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@@ -419,6 +419,18 @@ const routes = [
|
|||||||
component: () => import('pages/WholesaleCampaigns.vue'),
|
component: () => import('pages/WholesaleCampaigns.vue'),
|
||||||
meta: { permission: 'pricing:view' }
|
meta: { permission: 'pricing:view' }
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'pricing/product-series-mappings',
|
||||||
|
name: 'product-series-mappings',
|
||||||
|
component: () => import('pages/ProductSeriesMappings.vue'),
|
||||||
|
meta: { permission: 'pricing:view' }
|
||||||
|
},
|
||||||
|
{
|
||||||
|
path: 'pricing/product-series-definitions',
|
||||||
|
name: 'product-series-definitions',
|
||||||
|
component: () => import('pages/ProductSeriesDefinitions.vue'),
|
||||||
|
meta: { permission: 'pricing:view' }
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'costing/production-product-costing',
|
path: 'costing/production-product-costing',
|
||||||
name: 'production-product-costing',
|
name: 'production-product-costing',
|
||||||
|
|||||||
Reference in New Issue
Block a user