diff --git a/logs/ui-dev-20260617-171953.err.log b/logs/ui-dev-20260617-171953.err.log
index 2cf3ab4..8b2d198 100644
--- a/logs/ui-dev-20260617-171953.err.log
+++ b/logs/ui-dev-20260617-171953.err.log
@@ -38,3 +38,21 @@ Did you forget to install it?
Module not found: Can't resolve imported dependency "D:\baggitekstilas\software projects\bssapp\bssapp\ui\.quasar\dev-spa\client-entry.js"
Did you forget to install it?
+
+ App • ERROR • SPA UI
+
+Module not found: Can't resolve imported dependency "D:\baggitekstilas\software projects\bssapp\bssapp\ui\.quasar\dev-spa\client-entry.js"
+Did you forget to install it?
+
+
+ App • ERROR • SPA UI in ./src/router/routes.js
+
+Module not found: Can't resolve imported dependency "pages/WholesaleCampaigns.vue"
+Did you forget to install it?
+
+
+ App • ERROR • SPA UI
+
+Module not found: Can't resolve imported dependency "D:\baggitekstilas\software projects\bssapp\bssapp\ui\.quasar\dev-spa\client-entry.js"
+Did you forget to install it?
+
diff --git a/logs/ui-dev-20260617-171953.out.log b/logs/ui-dev-20260617-171953.out.log
index 40179a2..4838c42 100644
--- a/logs/ui-dev-20260617-171953.out.log
+++ b/logs/ui-dev-20260617-171953.out.log
@@ -142,3 +142,109 @@ Y88b.Y8b88P Y88b 888 888 888 X88 888 888 888
App • DONE • "SPA UI" compiled with success by Webpack • 760ms
App • WAIT • Compiling of "SPA UI" by Webpack in progress...
App • DONE • "SPA UI" compiled with success by Webpack • 750ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 552ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 430ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 394ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 597ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 535ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 666ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled by Webpack with errors • 24ms
+
+ App • COMPILATION FAILED • Please check the log above for details.
+
+
+ App • The quasar.config file (or its dependencies) changed. Reading it again...
+ App • TIP • 🚀 You (or an AE) specified an explicit quasar.config file > devServer > port. It is recommended to use a different devServer > port for each Quasar mode to avoid browser cache issues. Example: ctx.mode.ssr ? 9100 : ...
+ App • Scheduled to apply quasar.config changes in 550ms
+ App • Applying quasar.config file changes...
+
+ App • The quasar.config file (or its dependencies) changed. Reading it again...
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • TIP • 🚀 You (or an AE) specified an explicit quasar.config file > devServer > port. It is recommended to use a different devServer > port for each Quasar mode to avoid browser cache issues. Example: ctx.mode.ssr ? 9100 : ...
+ App • Scheduled to apply quasar.config changes in 550ms
+ App • DONE • "SPA UI" compiled with success by Webpack • 275ms
+ App • Applying quasar.config file changes...
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled by Webpack with errors • 209ms
+
+ App • COMPILATION FAILED • Please check the log above for details.
+
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 599ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 762ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 646ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 1037ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 743ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 692ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 1009ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 743ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 917ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 1090ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 582ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 960ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 886ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 958ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 882ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 930ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 1038ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 758ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 620ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 855ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 919ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 718ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 965ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 612ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 913ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 674ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 594ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 876ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 945ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 604ms
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • DONE • "SPA UI" compiled with success by Webpack • 781ms
+
+ App • The quasar.config file (or its dependencies) changed. Reading it again...
+ App • WAIT • Compiling of "SPA UI" by Webpack in progress...
+ App • TIP • 🚀 You (or an AE) specified an explicit quasar.config file > devServer > port. It is recommended to use a different devServer > port for each Quasar mode to avoid browser cache issues. Example: ctx.mode.ssr ? 9100 : ...
+ App • Scheduled to apply quasar.config changes in 550ms
+ App • DONE • "SPA UI" compiled by Webpack with errors • 28ms
+
+ App • COMPILATION FAILED • Please check the log above for details.
+
+ App • Applying quasar.config file changes...
diff --git a/svc/main.go b/svc/main.go
index 16e3a70..ace6536 100644
--- a/svc/main.go
+++ b/svc/main.go
@@ -840,6 +840,41 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
"pricing", "update",
wrapV3(routes.PostProductPricingSaveHandler(pgDB, ml)),
)
+ bindV3(r, pgDB,
+ "/api/pricing/wholesale-campaigns", "GET",
+ "pricing", "view",
+ wrapV3(routes.GetWholesaleCampaignsHandler(pgDB)),
+ )
+ bindV3(r, pgDB,
+ "/api/pricing/wholesale-campaigns/assignments", "GET",
+ "pricing", "view",
+ wrapV3(routes.GetWholesaleCampaignAssignmentsHandler(pgDB)),
+ )
+ bindV3(r, pgDB,
+ "/api/pricing/wholesale-campaigns/variants", "GET",
+ "pricing", "view",
+ wrapV3(routes.GetWholesaleCampaignVariantStockHandler(mssql)),
+ )
+ bindV3(r, pgDB,
+ "/api/pricing/wholesale-campaigns/variant-rows", "GET",
+ "pricing", "view",
+ wrapV3(routes.GetWholesaleCampaignVariantRowsHandler(pgDB, mssql)),
+ )
+ bindV3(r, pgDB,
+ "/api/pricing/wholesale-campaigns/save", "POST",
+ "pricing", "update",
+ wrapV3(routes.SaveWholesaleCampaignAssignmentsHandler(pgDB, ml)),
+ )
+ bindV3(r, pgDB,
+ "/api/pricing/wholesale-campaigns/{code}/campaign-history", "GET",
+ "pricing", "view",
+ wrapV3(routes.GetWholesaleCampaignHistoryHandler(pgDB)),
+ )
+ bindV3(r, pgDB,
+ "/api/pricing/wholesale-campaigns/{code}/campaign-history/delete-selected", "POST",
+ "pricing", "update",
+ wrapV3(routes.PostDeleteSelectedWholesaleCampaignHistoryHandler(pgDB)),
+ )
bindV3(r, pgDB,
"/api/pricing/brand-classification/lookups", "GET",
"pricing", "view",
diff --git a/svc/queries/product_pricing.go b/svc/queries/product_pricing.go
index 578dd24..54c4123 100644
--- a/svc/queries/product_pricing.go
+++ b/svc/queries/product_pricing.go
@@ -548,6 +548,44 @@ func enrichAllProductPricingRows(ctx context.Context, out []models.ProductPricin
}
_ = pgRows.Close()
}
+
+ // Last pricing date should reflect the most recent price publish, not the Nebim base price date.
+ // E-commerce reads pg.sdprc; use its latest write timestamp as the authoritative "last pricing" signal.
+ dateRows, err := pg.QueryContext(ctx, `
+SELECT
+ mmitem.code AS code,
+ to_char(MAX(sdprc.zlins_dttm), 'YYYY-MM-DD') AS last_pricing_date
+FROM sdprc
+JOIN mmitem ON mmitem.id = sdprc.mmitem_id
+WHERE mmitem.code = ANY($1)
+GROUP BY mmitem.code;
+`, pq.Array(chunk))
+ if err == nil {
+ for dateRows.Next() {
+ var code, ymd string
+ if err := dateRows.Scan(&code, &ymd); err != nil {
+ _ = dateRows.Close()
+ return err
+ }
+ code = strings.TrimSpace(code)
+ ymd = strings.TrimSpace(ymd)
+ if code == "" || len(ymd) != 10 {
+ continue
+ }
+ if idx, ok := indexByCode[code]; ok {
+ cur := strings.TrimSpace(out[idx].LastPricingDate)
+ // both are YYYY-MM-DD, lexicographical compare is safe
+ if cur == "" || cur < ymd {
+ out[idx].LastPricingDate = ymd
+ }
+ }
+ }
+ if err := dateRows.Err(); err != nil {
+ _ = dateRows.Close()
+ return err
+ }
+ _ = dateRows.Close()
+ }
}
}
diff --git a/svc/queries/product_pricing_options.go b/svc/queries/product_pricing_options.go
index c4ad634..8c61ef9 100644
--- a/svc/queries/product_pricing_options.go
+++ b/svc/queries/product_pricing_options.go
@@ -11,9 +11,13 @@ import (
// GetProductPricingFilterOptions returns distinct option values for ProductPricing filters.
// This is used to render filter dropdowns without loading the full dataset.
func GetProductPricingFilterOptions(ctx context.Context, field string, q string, limit int, scopeUrunIlkGrubu []string) ([]string, error) {
+ pg := db.PgDB
mssql := db.MssqlDB
if mssql == nil {
- return nil, fmt.Errorf("mssql db is nil")
+ // Some option fields can still be served from PG cache table.
+ if pg == nil {
+ return nil, fmt.Errorf("mssql db is nil")
+ }
}
field = strings.TrimSpace(field)
q = strings.TrimSpace(q)
@@ -24,6 +28,83 @@ func GetProductPricingFilterOptions(ctx context.Context, field string, q string,
scopeUrunIlkGrubu = scopeUrunIlkGrubu[:3]
}
+ // Fast path: use PG-derived pricing parameter cache for most fields.
+ // This avoids scanning ProductFilterWithDescription('TR') on MSSQL, which can be slow and cause 504s.
+ // productCode is not available in mk_urunpricingprmtr, keep MSSQL for that.
+ if pg != nil && field != "productCode" {
+ pgCol := ""
+ switch field {
+ case "brandGroupSelection":
+ pgCol = "brand_group_sec"
+ case "marka":
+ pgCol = "marka"
+ case "askiliYan":
+ pgCol = "askili_yan"
+ case "kategori":
+ pgCol = "kategori"
+ case "urunIlkGrubu":
+ pgCol = "urun_ilk_grubu"
+ case "urunAnaGrubu":
+ pgCol = "urun_ana_grubu"
+ case "urunAltGrubu":
+ pgCol = "urun_alt_grubu"
+ case "icerik":
+ pgCol = "icerik"
+ case "karisim":
+ // "karisim" is intentionally deprecated in mk_urunpricingprmtr, keep MSSQL fallback.
+ pgCol = ""
+ default:
+ pgCol = ""
+ }
+
+ if pgCol != "" {
+ args := make([]any, 0, 8)
+ where := []string{
+ "is_active = TRUE",
+ fmt.Sprintf("NULLIF(BTRIM(%s), '') IS NOT NULL", pgCol),
+ }
+ if len(scopeUrunIlkGrubu) > 0 && field != "urunIlkGrubu" {
+ args = append(args, scopeUrunIlkGrubu)
+ where = append(where, fmt.Sprintf("urun_ilk_grubu = ANY($%d::text[])", len(args)))
+ }
+ if q != "" {
+ like := q + "%"
+ args = append(args, like)
+ where = append(where, fmt.Sprintf("%s ILIKE $%d", pgCol, len(args)))
+ }
+ whereSQL := strings.Join(where, " AND ")
+
+ // Note: DISTINCT+ORDER+LIMIT is fine here due to small mk_urunpricingprmtr cardinality (~1000 rows).
+ sqlText := fmt.Sprintf(`
+SELECT DISTINCT %s AS val
+FROM mk_urunpricingprmtr
+WHERE %s
+ORDER BY val ASC
+LIMIT %d
+`, pgCol, whereSQL, limit)
+
+ rows, err := pg.QueryContext(ctx, sqlText, args...)
+ if err == nil {
+ defer rows.Close()
+ out := make([]string, 0, limit)
+ for rows.Next() {
+ var v sql.NullString
+ if err := rows.Scan(&v); err != nil {
+ return nil, err
+ }
+ if s := strings.TrimSpace(v.String); s != "" {
+ out = append(out, s)
+ }
+ }
+ if err := rows.Err(); err != nil {
+ return nil, err
+ }
+ return out, nil
+ }
+ // If PG path fails, fall back to MSSQL below.
+ }
+ }
+
// Map UI filter fields -> MSSQL expression in ProductFilterWithDescription('TR')
var expr string
switch field {
diff --git a/svc/queries/wholesale_campaign_variants_mssql.go b/svc/queries/wholesale_campaign_variants_mssql.go
new file mode 100644
index 0000000..a40258d
--- /dev/null
+++ b/svc/queries/wholesale_campaign_variants_mssql.go
@@ -0,0 +1,99 @@
+package queries
+
+// GetWholesaleCampaignVariantStockByProducts:
+// Returns per-product variant keys (ColorCode/ItemDim1Code/ItemDim3Code) and available stock qty.
+// We aggregate across warehouses/stores; semantics align with product-stock-query's "Kullanilabilir_Envanter".
+const GetWholesaleCampaignVariantStockByProducts = `
+DECLARE @Codes NVARCHAR(MAX) = @p1;
+
+;WITH INP AS (
+ -- SQL Server 2008 compatibility: string_split() does not exist.
+ -- Split CSV via XML nodes().
+ SELECT LTRIM(RTRIM(X.C.value('.', 'NVARCHAR(50)'))) AS ItemCode
+ FROM (
+ SELECT CAST('' + REPLACE(REPLACE(@Codes, '&', '&'), ',', '') + '' AS XML) AS XmlData
+ ) D
+ CROSS APPLY D.XmlData.nodes('/i') AS X(C)
+ WHERE LTRIM(RTRIM(X.C.value('.', 'NVARCHAR(50)'))) <> ''
+),
+STOCK AS (
+ SELECT
+ S.ItemCode,
+ LTRIM(RTRIM(ISNULL(S.ColorCode,''))) AS ColorCode,
+ LTRIM(RTRIM(ISNULL(S.ItemDim3Code,''))) AS ItemDim3Code,
+ MAX(LTRIM(RTRIM(ISNULL(S.ItemDim1Code,'')))) AS ItemDim1Code,
+ SUM(S.In_Qty1 - S.Out_Qty1) AS InventoryQty1
+ FROM trStock S WITH(NOLOCK)
+ JOIN INP ON INP.ItemCode = S.ItemCode
+ WHERE S.ItemTypeCode = 1
+ AND LEN(S.ItemCode) = 13
+ GROUP BY
+ S.ItemCode, S.ColorCode, S.ItemDim3Code
+),
+PICK AS (
+ SELECT
+ P.ItemCode,
+ LTRIM(RTRIM(ISNULL(P.ColorCode,''))) AS ColorCode,
+ LTRIM(RTRIM(ISNULL(P.ItemDim3Code,''))) AS ItemDim3Code,
+ MAX(LTRIM(RTRIM(ISNULL(P.ItemDim1Code,'')))) AS ItemDim1Code,
+ SUM(P.Qty1) AS PickingQty1
+ FROM PickingStates P
+ JOIN INP ON INP.ItemCode = P.ItemCode
+ WHERE P.ItemTypeCode = 1
+ AND LEN(P.ItemCode) = 13
+ GROUP BY
+ P.ItemCode, P.ColorCode, P.ItemDim3Code
+),
+RESERVE AS (
+ SELECT
+ R.ItemCode,
+ LTRIM(RTRIM(ISNULL(R.ColorCode,''))) AS ColorCode,
+ LTRIM(RTRIM(ISNULL(R.ItemDim3Code,''))) AS ItemDim3Code,
+ MAX(LTRIM(RTRIM(ISNULL(R.ItemDim1Code,'')))) AS ItemDim1Code,
+ SUM(R.Qty1) AS ReserveQty1
+ FROM ReserveStates R
+ JOIN INP ON INP.ItemCode = R.ItemCode
+ WHERE R.ItemTypeCode = 1
+ AND LEN(R.ItemCode) = 13
+ GROUP BY
+ R.ItemCode, R.ColorCode, R.ItemDim3Code
+),
+DISP AS (
+ SELECT
+ D.ItemCode,
+ LTRIM(RTRIM(ISNULL(D.ColorCode,''))) AS ColorCode,
+ LTRIM(RTRIM(ISNULL(D.ItemDim3Code,''))) AS ItemDim3Code,
+ MAX(LTRIM(RTRIM(ISNULL(D.ItemDim1Code,'')))) AS ItemDim1Code,
+ SUM(D.Qty1) AS DispOrderQty1
+ FROM DispOrderStates D
+ JOIN INP ON INP.ItemCode = D.ItemCode
+ WHERE D.ItemTypeCode = 1
+ AND LEN(D.ItemCode) = 13
+ GROUP BY
+ D.ItemCode, D.ColorCode, D.ItemDim3Code
+)
+SELECT
+ S.ItemCode AS ItemCode,
+ S.ColorCode AS ColorCode,
+ S.ItemDim1Code AS ItemDim1Code,
+ S.ItemDim3Code AS ItemDim3Code,
+ CAST(ROUND(
+ S.InventoryQty1
+ - ISNULL(PK.PickingQty1,0)
+ - ISNULL(RS.ReserveQty1,0)
+ - ISNULL(DP.DispOrderQty1,0),
+ 2
+ ) AS FLOAT) AS StockQty
+FROM STOCK S
+LEFT JOIN PICK PK
+ ON PK.ItemCode=S.ItemCode AND PK.ColorCode=S.ColorCode AND PK.ItemDim3Code=S.ItemDim3Code
+LEFT JOIN RESERVE RS
+ ON RS.ItemCode=S.ItemCode AND RS.ColorCode=S.ColorCode AND RS.ItemDim3Code=S.ItemDim3Code
+LEFT JOIN DISP DP
+ ON DP.ItemCode=S.ItemCode AND DP.ColorCode=S.ColorCode AND DP.ItemDim3Code=S.ItemDim3Code
+WHERE (S.InventoryQty1
+ - ISNULL(PK.PickingQty1,0)
+ - ISNULL(RS.ReserveQty1,0)
+ - ISNULL(DP.DispOrderQty1,0)) <> 0
+ORDER BY S.ItemCode, S.ColorCode, S.ItemDim3Code;
+`
diff --git a/svc/routes/product_pricing.go b/svc/routes/product_pricing.go
index c0483d8..0061b90 100644
--- a/svc/routes/product_pricing.go
+++ b/svc/routes/product_pricing.go
@@ -254,8 +254,14 @@ func ExportAllProductPricingHandler(w http.ResponseWriter, r *http.Request) {
}
rows = filterProductPricingExportRows(rows, parseProductPricingExportFilters(r))
- currencies := parseExportCurrencies(r.URL.Query().Get("currencies"))
- content := buildProductPricingExportCSV(rows, currencies)
+ priceFields := parseExportPriceFields(r.URL.Query().Get("price_fields"))
+ var content string
+ if len(priceFields) > 0 {
+ content = buildProductPricingExportCSVWithPriceFields(rows, priceFields)
+ } else {
+ currencies := parseExportCurrencies(r.URL.Query().Get("currencies"))
+ content = buildProductPricingExportCSV(rows, currencies)
+ }
filename := fmt.Sprintf("product_pricing_all_%s.csv", time.Now().Format("2006-01-02"))
w.Header().Set("Content-Type", "text/csv; charset=utf-8")
@@ -402,6 +408,39 @@ func parseExportCurrencies(raw string) []string {
return out
}
+func parseExportPriceFields(raw string) []string {
+ requested := splitCSVParam(raw)
+ if len(requested) == 0 {
+ return nil
+ }
+ out := make([]string, 0, 18)
+ seen := map[string]bool{}
+ for _, item := range requested {
+ v := strings.ToLower(strings.TrimSpace(item))
+ if v == "" {
+ continue
+ }
+ // Accept both "usd1" and "USD1" etc.
+ switch v {
+ case "usd1", "usd2", "usd3", "usd4", "usd5", "usd6",
+ "eur1", "eur2", "eur3", "eur4", "eur5", "eur6",
+ "try1", "try2", "try3", "try4", "try5", "try6":
+ // ok
+ default:
+ continue
+ }
+ if seen[v] {
+ continue
+ }
+ seen[v] = true
+ out = append(out, v)
+ }
+ if len(out) == 0 {
+ return nil
+ }
+ return out
+}
+
func matchesProductPricingValueFilters(row models.ProductPricing, filters map[string][]string) bool {
if len(filters) == 0 {
return true
@@ -509,7 +548,60 @@ func csvFloat(value float64) string {
return fmt.Sprintf("%.2f", value)
}
-func buildProductPricingExportCSV(rows []models.ProductPricing, currencies []string) string {
+func exportPriceFieldTitle(field string) string {
+ field = strings.ToLower(strings.TrimSpace(field))
+ if len(field) != 4 {
+ return strings.ToUpper(field)
+ }
+ cur := strings.ToUpper(field[:3])
+ lv := field[3:]
+ return fmt.Sprintf("%s %s", cur, lv)
+}
+
+func exportPriceFieldValue(row models.ProductPricing, field string) float64 {
+ switch strings.ToLower(strings.TrimSpace(field)) {
+ case "usd1":
+ return row.USD1
+ case "usd2":
+ return row.USD2
+ case "usd3":
+ return row.USD3
+ case "usd4":
+ return row.USD4
+ case "usd5":
+ return row.USD5
+ case "usd6":
+ return row.USD6
+ case "eur1":
+ return row.EUR1
+ case "eur2":
+ return row.EUR2
+ case "eur3":
+ return row.EUR3
+ case "eur4":
+ return row.EUR4
+ case "eur5":
+ return row.EUR5
+ case "eur6":
+ return row.EUR6
+ case "try1":
+ return row.TRY1
+ case "try2":
+ return row.TRY2
+ case "try3":
+ return row.TRY3
+ case "try4":
+ return row.TRY4
+ case "try5":
+ return row.TRY5
+ case "try6":
+ return row.TRY6
+ default:
+ return 0
+ }
+}
+
+func buildProductPricingExportCSVWithPriceFields(rows []models.ProductPricing, priceFields []string) string {
var b strings.Builder
headers := []string{
@@ -536,14 +628,12 @@ func buildProductPricingExportCSV(rows []models.ProductPricing, currencies []str
b.WriteString(csvEscape(h))
b.WriteString(";")
}
- for idx, cur := range currencies {
- for tier := 1; tier <= 6; tier++ {
- b.WriteString(csvEscape(fmt.Sprintf("%s %d", cur, tier)))
- if idx == len(currencies)-1 && tier == 6 {
- b.WriteString("\n")
- } else {
- b.WriteString(";")
- }
+ for i, pf := range priceFields {
+ b.WriteString(csvEscape(exportPriceFieldTitle(pf)))
+ if i == len(priceFields)-1 {
+ b.WriteString("\n")
+ } else {
+ b.WriteString(";")
}
}
@@ -572,15 +662,12 @@ func buildProductPricingExportCSV(rows []models.ProductPricing, currencies []str
b.WriteString(csvEscape(item))
b.WriteString(";")
}
- for idx, cur := range currencies {
- values := productPricingCurrencyValues(row, cur)
- for tierIdx, value := range values {
- b.WriteString(csvEscape(csvFloat(value)))
- if idx == len(currencies)-1 && tierIdx == len(values)-1 {
- b.WriteString("\n")
- } else {
- b.WriteString(";")
- }
+ for i, pf := range priceFields {
+ b.WriteString(csvEscape(csvFloat(exportPriceFieldValue(row, pf))))
+ if i == len(priceFields)-1 {
+ b.WriteString("\n")
+ } else {
+ b.WriteString(";")
}
}
}
@@ -588,6 +675,26 @@ func buildProductPricingExportCSV(rows []models.ProductPricing, currencies []str
return b.String()
}
+func buildProductPricingExportCSV(rows []models.ProductPricing, currencies []string) string {
+ // Backward compatible export: USD/EUR/TRY and all 1..6 tiers per currency.
+ fields := make([]string, 0, 18)
+ for _, cur := range currencies {
+ cur = strings.ToUpper(strings.TrimSpace(cur))
+ switch cur {
+ case "USD", "EUR", "TRY":
+ default:
+ continue
+ }
+ for lv := 1; lv <= 6; lv++ {
+ fields = append(fields, strings.ToLower(fmt.Sprintf("%s%d", cur, lv)))
+ }
+ }
+ if len(fields) == 0 {
+ fields = []string{"usd1", "usd2", "usd3", "usd4", "usd5", "usd6", "eur1", "eur2", "eur3", "eur4", "eur5", "eur6", "try1", "try2", "try3", "try4", "try5", "try6"}
+ }
+ return buildProductPricingExportCSVWithPriceFields(rows, fields)
+}
+
func productPricingCurrencyValues(row models.ProductPricing, currency string) []float64 {
switch currency {
case "USD":
diff --git a/svc/routes/product_pricing_price_list_export.go b/svc/routes/product_pricing_price_list_export.go
index 5086ca7..b61aff7 100644
--- a/svc/routes/product_pricing_price_list_export.go
+++ b/svc/routes/product_pricing_price_list_export.go
@@ -44,6 +44,10 @@ type priceListExportRequest struct {
USDLevels []int `json:"usd_levels"` // 1..6
EURLevels []int `json:"eur_levels"` // 1..6
TRYLevels []int `json:"try_levels"` // 1..6
+
+ // Optional: explicit per-tier selection like ["usd1","eur3","try6"].
+ // If provided, it overrides USDLevels/EURLevels/TRYLevels.
+ PriceFields []string `json:"price_fields"`
}
type exportCol struct {
@@ -102,6 +106,34 @@ func resolvePriceListColumns(req priceListExportRequest) []exportCol {
)
}
+ // Explicit per-tier selection path (preserve order).
+ if len(req.PriceFields) > 0 {
+ seen := map[string]struct{}{}
+ for _, raw := range req.PriceFields {
+ v := strings.ToLower(strings.TrimSpace(raw))
+ if v == "" {
+ continue
+ }
+ switch v {
+ case "usd1", "usd2", "usd3", "usd4", "usd5", "usd6",
+ "eur1", "eur2", "eur3", "eur4", "eur5", "eur6",
+ "try1", "try2", "try3", "try4", "try5", "try6":
+ // ok
+ default:
+ continue
+ }
+ if _, ok := seen[v]; ok {
+ continue
+ }
+ seen[v] = struct{}{}
+ cur := strings.ToUpper(v[:3])
+ lv := v[3:]
+ key := fmt.Sprintf("%s%s", cur, lv)
+ cols = append(cols, exportCol{Key: key, Title: fmt.Sprintf("%s %s", cur, lv), Width: 12, Align: "R"})
+ }
+ return cols
+ }
+
usd := cleanLevels(req.USDLevels)
eur := cleanLevels(req.EURLevels)
tr := cleanLevels(req.TRYLevels)
diff --git a/svc/routes/wholesale_campaign_mail.go b/svc/routes/wholesale_campaign_mail.go
new file mode 100644
index 0000000..4978ed3
--- /dev/null
+++ b/svc/routes/wholesale_campaign_mail.go
@@ -0,0 +1,286 @@
+package routes
+
+import (
+ "context"
+ "database/sql"
+ "fmt"
+ "log"
+ "sort"
+ "strings"
+ "time"
+
+ "bssapp-backend/db"
+ "bssapp-backend/internal/mailer"
+ "bssapp-backend/models"
+ "bssapp-backend/queries"
+
+ "github.com/lib/pq"
+)
+
+type wholesaleCampaignMailRow struct {
+ ProductCode string
+ UrunIlkGrubu string
+ Marka string
+ BrandGroupSec string
+ Dim1 int64
+ Dim3 int64
+ CampaignCode string
+ CampaignTitle string
+ DiscountRate float64
+}
+
+func buildWholesaleCampaignChangeMailHTML(firstGroupCode string, rows []wholesaleCampaignMailRow, actor string, at time.Time) string {
+ var b strings.Builder
+ b.WriteString(`
`)
+ b.WriteString(`
`)
+ b.WriteString(`
Kampanya Degisikligi
`)
+ b.WriteString(`
Urun Ilk Grubu: ` + htmlEscapeMini(firstGroupCode) + `
`)
+ if strings.TrimSpace(actor) != "" {
+ b.WriteString(`
Islem Yapan: ` + htmlEscapeMini(actor) + `
`)
+ }
+ b.WriteString(`
Tarih: ` + htmlEscapeMini(at.Format("02.01.2006 15:04")) + `
`)
+ b.WriteString(`
Varyant Sayisi: ` + fmt.Sprintf("%d", len(rows)) + `
`)
+ b.WriteString(`
`)
+
+ b.WriteString(`
`)
+ b.WriteString(`
`)
+ b.WriteString(``)
+ heads := []string{
+ "MARKA GRUBU",
+ "MARKA",
+ "URUN KODU",
+ "DIM1",
+ "DIM3",
+ "KAMPANYA",
+ "IND %",
+ }
+ for _, h := range heads {
+ b.WriteString(`| ` + htmlEscapeMini(h) + ` | `)
+ }
+ b.WriteString(`
`)
+
+ for _, r := range rows {
+ b.WriteString(``)
+ campaignLabel := strings.TrimSpace(r.CampaignCode)
+ if t := strings.TrimSpace(r.CampaignTitle); t != "" {
+ if campaignLabel != "" {
+ campaignLabel = campaignLabel + " - " + t
+ } else {
+ campaignLabel = t
+ }
+ }
+ cells := []string{
+ r.BrandGroupSec,
+ r.Marka,
+ r.ProductCode,
+ fmt.Sprintf("%d", r.Dim1),
+ func() string {
+ if r.Dim3 > 0 {
+ return fmt.Sprintf("%d", r.Dim3)
+ }
+ return ""
+ }(),
+ campaignLabel,
+ fmt.Sprintf("%.2f", r.DiscountRate),
+ }
+ for i, c := range cells {
+ align := "left"
+ if i == 3 || i == 4 || i == 6 {
+ align = "right"
+ }
+ b.WriteString(`| ` + htmlEscapeMini(strings.TrimSpace(c)) + ` | `)
+ }
+ b.WriteString(`
`)
+ }
+
+ b.WriteString(`
`)
+ b.WriteString(`
Bu e-posta BSSApp sistemi tarafindan otomatik olusturulmustur.
`)
+ b.WriteString(`
`)
+ return b.String()
+}
+
+// sendWholesaleCampaignChangeMails sends one mail per UrunIlkGrubu using existing pricing mail mapping tables.
+// It lists only variants that currently have a campaign assigned.
+func sendWholesaleCampaignChangeMails(bg context.Context, ml *mailer.GraphMailer, productCodes []string, actor string) {
+ if ml == nil {
+ return
+ }
+ pg := db.PgDB
+ if pg == nil {
+ log.Printf("[campaign-mail] skipped: pg not ready")
+ return
+ }
+ // Ensure mapping tables exist (reuse pricing mapping).
+ if err := ensureFirstGroupMailMappingTables(pg); err != nil {
+ log.Printf("[campaign-mail] mapping bootstrap error: %v", err)
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(bg, 90*time.Second)
+ defer cancel()
+
+ codes := make([]string, 0, len(productCodes))
+ seen := map[string]struct{}{}
+ for _, c := range productCodes {
+ c = strings.TrimSpace(c)
+ if c == "" {
+ continue
+ }
+ if _, ok := seen[c]; ok {
+ continue
+ }
+ seen[c] = struct{}{}
+ codes = append(codes, c)
+ }
+ if len(codes) == 0 {
+ return
+ }
+
+ // Product info for grouping (UrunIlkGrubu, Marka, BrandGroupSec) comes from Nebim query.
+ // This is best-effort: if MSSQL is down, we still send a single mail under group "UNKNOWN".
+ productInfo := map[string]models.ProductPricing{}
+ {
+ rows, err := queries.GetAllProductPricingRows(ctx, 500, queries.ProductPricingFilters{ProductCode: codes}, "productCode", false)
+ if err == nil {
+ for _, r := range rows {
+ code := strings.TrimSpace(r.ProductCode)
+ if code != "" {
+ productInfo[code] = r
+ }
+ }
+ }
+ }
+
+ type dbRow struct {
+ ProductCode string
+ Dim1 int64
+ Dim3 sql.NullInt64
+ CampaignCode string
+ CampaignTitle string
+ DiscountRate float64
+ }
+
+ rows, err := pg.QueryContext(ctx, `
+WITH mm AS (
+ SELECT id AS mmitem_id, code
+ FROM mmitem
+ WHERE code = ANY($1::text[])
+),
+latest AS (
+ SELECT DISTINCT ON (z.mmitem_id, z.dim1, COALESCE(z.dim3, 0))
+ z.mmitem_id,
+ z.dim1,
+ z.dim3,
+ z.sdcampaign_id
+ FROM zbggcampaign z
+ JOIN mm ON mm.mmitem_id = z.mmitem_id
+ ORDER BY z.mmitem_id, z.dim1, COALESCE(z.dim3, 0), z.id DESC
+)
+SELECT
+ mm.code AS product_code,
+ l.dim1,
+ l.dim3,
+ COALESCE(sc.code,'') AS campaign_code,
+ COALESCE(sc.title,'') AS campaign_title,
+ COALESCE(sc.discount_rate, 0)::float8 AS discount_rate
+FROM mm
+JOIN latest l
+ ON l.mmitem_id = mm.mmitem_id
+JOIN sdcampaign sc
+ ON sc.id = l.sdcampaign_id
+WHERE COALESCE(sc.is_active, TRUE) = TRUE
+ORDER BY mm.code, l.dim1, COALESCE(l.dim3, 0), sc.discount_rate DESC;
+`, pq.Array(codes))
+ if err != nil {
+ log.Printf("[campaign-mail] campaign rows query error: %v", err)
+ return
+ }
+ defer rows.Close()
+
+ mailRows := make([]wholesaleCampaignMailRow, 0, 1024)
+ for rows.Next() {
+ var r dbRow
+ if err := rows.Scan(&r.ProductCode, &r.Dim1, &r.Dim3, &r.CampaignCode, &r.CampaignTitle, &r.DiscountRate); err != nil {
+ log.Printf("[campaign-mail] scan error: %v", err)
+ return
+ }
+ code := strings.TrimSpace(r.ProductCode)
+ info := productInfo[code]
+ group := strings.TrimSpace(info.UrunIlkGrubu)
+ if group == "" {
+ group = "UNKNOWN"
+ }
+ d3 := int64(0)
+ if r.Dim3.Valid {
+ d3 = r.Dim3.Int64
+ }
+ mailRows = append(mailRows, wholesaleCampaignMailRow{
+ ProductCode: code,
+ UrunIlkGrubu: group,
+ Marka: strings.TrimSpace(info.Marka),
+ BrandGroupSec: strings.TrimSpace(info.BrandGroupSec),
+ Dim1: r.Dim1,
+ Dim3: d3,
+ CampaignCode: strings.TrimSpace(r.CampaignCode),
+ CampaignTitle: strings.TrimSpace(r.CampaignTitle),
+ DiscountRate: r.DiscountRate,
+ })
+ }
+ if err := rows.Err(); err != nil {
+ log.Printf("[campaign-mail] rows error: %v", err)
+ return
+ }
+
+ if len(mailRows) == 0 {
+ // Nothing assigned => no mail.
+ return
+ }
+
+ byGroup := map[string][]wholesaleCampaignMailRow{}
+ for _, r := range mailRows {
+ g := strings.TrimSpace(r.UrunIlkGrubu)
+ if g == "" {
+ g = "UNKNOWN"
+ }
+ byGroup[g] = append(byGroup[g], r)
+ }
+
+ now := time.Now()
+ for group, list := range byGroup {
+ recipients, err := loadPricingRecipients(pg, group)
+ if err != nil {
+ log.Printf("[campaign-mail] recipient query error group=%s err=%v", group, err)
+ continue
+ }
+ if len(recipients) == 0 {
+ log.Printf("[campaign-mail] no recipients mapped group=%s", group)
+ continue
+ }
+
+ sort.Slice(list, func(i, j int) bool {
+ if list[i].ProductCode != list[j].ProductCode {
+ return list[i].ProductCode < list[j].ProductCode
+ }
+ if list[i].Dim1 != list[j].Dim1 {
+ return list[i].Dim1 < list[j].Dim1
+ }
+ return list[i].Dim3 < list[j].Dim3
+ })
+
+ subject := fmt.Sprintf("Kampanya Degisikligi | %s | %s | %d varyant", group, now.Format("02.01.2006 15:04"), len(list))
+ html := buildWholesaleCampaignChangeMailHTML(group, list, actor, now)
+
+ stepCtx, stepCancel := context.WithTimeout(bg, 25*time.Second)
+ err = ml.Send(stepCtx, mailer.Message{
+ To: recipients,
+ Subject: subject,
+ BodyHTML: html,
+ })
+ stepCancel()
+ if err != nil {
+ log.Printf("[campaign-mail] send failed group=%s err=%v", group, err)
+ } else {
+ log.Printf("[campaign-mail] sent group=%s to=%d variants=%d", group, len(recipients), len(list))
+ }
+ }
+}
diff --git a/svc/routes/wholesale_campaigns.go b/svc/routes/wholesale_campaigns.go
new file mode 100644
index 0000000..4b8d5e2
--- /dev/null
+++ b/svc/routes/wholesale_campaigns.go
@@ -0,0 +1,1028 @@
+package routes
+
+import (
+ "bssapp-backend/auth"
+ "bssapp-backend/queries"
+ "context"
+ "database/sql"
+ "encoding/json"
+ "fmt"
+ "log"
+ "net/http"
+ "strconv"
+ "strings"
+ "time"
+
+ "bssapp-backend/internal/mailer"
+
+ "github.com/gorilla/mux"
+ "github.com/lib/pq"
+)
+
+type wholesaleCampaignRow struct {
+ ID int64 `json:"id"`
+ Code string `json:"code"`
+ Title string `json:"title"`
+ IsActive bool `json:"is_active"`
+ Dtst string `json:"dtst,omitempty"`
+ Dtfn string `json:"dtfn,omitempty"`
+ DiscountRate float64 `json:"discount_rate"`
+ Notes string `json:"notes,omitempty"`
+}
+
+// GET /api/pricing/wholesale-campaigns
+func GetWholesaleCampaignsHandler(pg *sql.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ traceID := buildPricingTraceID(r)
+ w.Header().Set("X-Trace-ID", traceID)
+
+ claims, ok := auth.GetClaimsFromContext(r.Context())
+ if !ok || claims == nil {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
+ defer cancel()
+
+ rows, err := pg.QueryContext(ctx, `
+SELECT
+ id,
+ COALESCE(NULLIF(BTRIM(code),''),'') AS code,
+ COALESCE(NULLIF(BTRIM(title),''),'') AS title,
+ COALESCE(is_active, TRUE) AS is_active,
+ COALESCE(to_char(dtst, 'YYYY-MM-DD HH24:MI:SS'), '') AS dtst,
+ COALESCE(to_char(dtfn, 'YYYY-MM-DD HH24:MI:SS'), '') AS dtfn,
+ COALESCE(discount_rate, 0)::float8 AS discount_rate,
+ COALESCE(NULLIF(BTRIM(notes),''),'') AS notes
+FROM sdcampaign
+WHERE COALESCE(is_active, TRUE) = TRUE
+ORDER BY discount_rate ASC, id ASC;
+`)
+ if err != nil {
+ http.Error(w, "campaign list error: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ defer rows.Close()
+
+ out := make([]wholesaleCampaignRow, 0, 64)
+ for rows.Next() {
+ var it wholesaleCampaignRow
+ var dtst, dtfn string
+ if err := rows.Scan(&it.ID, &it.Code, &it.Title, &it.IsActive, &dtst, &dtfn, &it.DiscountRate, &it.Notes); err != nil {
+ http.Error(w, "campaign scan error", http.StatusInternalServerError)
+ return
+ }
+ it.Dtst = strings.TrimSpace(dtst)
+ it.Dtfn = strings.TrimSpace(dtfn)
+ out = append(out, it)
+ }
+
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ _ = json.NewEncoder(w).Encode(out)
+ log.Printf("[WholesaleCampaigns] trace=%s user=%s id=%d count=%d", traceID, claims.Username, claims.ID, len(out))
+ }
+}
+
+type campaignAssignmentRow struct {
+ ProductCode string `json:"product_code"`
+ CampaignID *int64 `json:"campaign_id"`
+ CampaignCode string `json:"campaign_code"`
+ DiscountRate float64 `json:"discount_rate"`
+ IsMixed bool `json:"is_mixed"`
+ VariantRows int `json:"variant_rows"`
+ AssignedDim1s int `json:"assigned_dim1s"`
+ AssignedDim3s int `json:"assigned_dim3s"`
+ Notes string `json:"notes,omitempty"`
+}
+
+// GET /api/pricing/wholesale-campaigns/assignments?product_code=A,B,C
+func GetWholesaleCampaignAssignmentsHandler(pg *sql.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ traceID := buildPricingTraceID(r)
+ w.Header().Set("X-Trace-ID", traceID)
+
+ claims, ok := auth.GetClaimsFromContext(r.Context())
+ if !ok || claims == nil {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ codes := splitCSVParam(r.URL.Query().Get("product_code"))
+ if len(codes) == 0 {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ _ = json.NewEncoder(w).Encode([]campaignAssignmentRow{})
+ return
+ }
+ if len(codes) > 500 {
+ http.Error(w, "product_code too many", http.StatusBadRequest)
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second)
+ defer cancel()
+
+ rows, err := pg.QueryContext(ctx, `
+WITH inp AS (
+ SELECT UNNEST($1::text[]) AS code
+),
+mm AS (
+ SELECT m.id AS mmitem_id, m.code
+ FROM mmitem m
+ JOIN inp ON inp.code = m.code
+),
+latest AS (
+ -- "Current" assignment = latest row per variant key (mmitem_id, dim1, dim3_key).
+ SELECT DISTINCT ON (z.mmitem_id, z.dim1, COALESCE(z.dim3, 0))
+ z.mmitem_id,
+ z.dim1,
+ COALESCE(z.dim3, 0) AS dim3_key,
+ z.sdcampaign_id
+ FROM zbggcampaign z
+ JOIN mm ON mm.mmitem_id = z.mmitem_id
+ ORDER BY z.mmitem_id, z.dim1, COALESCE(z.dim3, 0), z.id DESC
+),
+agg AS (
+ SELECT
+ mm.code AS product_code,
+ COUNT(*)::int AS variant_rows,
+ COUNT(DISTINCT l.dim1) FILTER (WHERE l.sdcampaign_id IS NOT NULL)::int AS assigned_dim1s,
+ COUNT(DISTINCT l.dim3_key) FILTER (WHERE l.sdcampaign_id IS NOT NULL)::int AS assigned_dim3s,
+ COUNT(DISTINCT l.sdcampaign_id) FILTER (WHERE l.sdcampaign_id IS NOT NULL)::int AS distinct_campaigns,
+ MAX(l.sdcampaign_id)::bigint AS any_campaign_id
+ FROM mm
+ LEFT JOIN latest l
+ ON l.mmitem_id = mm.mmitem_id
+ GROUP BY mm.code
+),
+single AS (
+ SELECT
+ a.product_code,
+ CASE WHEN a.distinct_campaigns = 1 THEN a.any_campaign_id ELSE NULL END AS campaign_id,
+ CASE WHEN a.distinct_campaigns > 1 THEN TRUE ELSE FALSE END AS is_mixed,
+ a.variant_rows,
+ a.assigned_dim1s,
+ a.assigned_dim3s
+ FROM agg a
+)
+SELECT
+ s.product_code,
+ s.campaign_id,
+ COALESCE(sc.code,'') AS campaign_code,
+ COALESCE(sc.discount_rate, 0)::float8 AS discount_rate,
+ s.is_mixed,
+ s.variant_rows,
+ s.assigned_dim1s,
+ s.assigned_dim3s,
+ COALESCE(NULLIF(BTRIM(sc.notes),''),'') AS notes
+FROM single s
+LEFT JOIN sdcampaign sc
+ ON sc.id = s.campaign_id
+ORDER BY s.product_code;
+`, pq.Array(codes))
+ if err != nil {
+ http.Error(w, "assignment list error: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ defer rows.Close()
+
+ out := make([]campaignAssignmentRow, 0, len(codes))
+ for rows.Next() {
+ var it campaignAssignmentRow
+ var cid sql.NullInt64
+ if err := rows.Scan(&it.ProductCode, &cid, &it.CampaignCode, &it.DiscountRate, &it.IsMixed, &it.VariantRows, &it.AssignedDim1s, &it.AssignedDim3s, &it.Notes); err != nil {
+ http.Error(w, "assignment scan error", http.StatusInternalServerError)
+ return
+ }
+ if cid.Valid {
+ v := cid.Int64
+ it.CampaignID = &v
+ }
+ out = append(out, it)
+ }
+
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ _ = json.NewEncoder(w).Encode(out)
+ log.Printf("[WholesaleCampaignAssignments] trace=%s user=%s id=%d products=%d", traceID, claims.Username, claims.ID, len(out))
+ }
+}
+
+type saveWholesaleCampaignItem struct {
+ ProductCode string `json:"product_code"`
+ Dim1 int64 `json:"dim1"`
+ Dim3 *int64 `json:"dim3"`
+ CampaignID *int64 `json:"campaign_id"`
+}
+
+type saveWholesaleCampaignPayload struct {
+ Items []saveWholesaleCampaignItem `json:"items"`
+}
+
+// POST /api/pricing/wholesale-campaigns/save
+// Appends a new row to zbggcampaign per variant (dim1+dim3), preserving history.
+func SaveWholesaleCampaignAssignmentsHandler(pg *sql.DB, ml *mailer.GraphMailer) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ started := time.Now()
+ traceID := buildPricingTraceID(r)
+ w.Header().Set("X-Trace-ID", traceID)
+
+ claims, ok := auth.GetClaimsFromContext(r.Context())
+ if !ok || claims == nil {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+
+ var payload saveWholesaleCampaignPayload
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ http.Error(w, "invalid payload", http.StatusBadRequest)
+ return
+ }
+ if len(payload.Items) == 0 {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "saved": 0})
+ return
+ }
+ if len(payload.Items) > 500 {
+ http.Error(w, "too many items", 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, "tx begin error", http.StatusInternalServerError)
+ return
+ }
+ defer tx.Rollback()
+
+ // Resolve mmitem ids in bulk.
+ codeList := make([]string, 0, len(payload.Items))
+ seenCode := make(map[string]struct{}, len(payload.Items))
+ for _, it := range payload.Items {
+ code := strings.TrimSpace(it.ProductCode)
+ if code == "" {
+ continue
+ }
+ if _, ok := seenCode[code]; ok {
+ continue
+ }
+ seenCode[code] = struct{}{}
+ codeList = append(codeList, code)
+ }
+
+ codeToItemID := make(map[string]int64, len(codeList))
+ if len(codeList) > 0 {
+ rows, err := tx.QueryContext(ctx, `
+SELECT code, id
+FROM mmitem
+WHERE code = ANY($1::text[])
+`, pq.Array(codeList))
+ if err != nil {
+ http.Error(w, "mmitem lookup error: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ for rows.Next() {
+ var code string
+ var id int64
+ if err := rows.Scan(&code, &id); err != nil {
+ rows.Close()
+ http.Error(w, "mmitem scan error", http.StatusInternalServerError)
+ return
+ }
+ code = strings.TrimSpace(code)
+ if code != "" && id > 0 {
+ codeToItemID[code] = id
+ }
+ }
+ rows.Close()
+ }
+
+ saved := 0
+ for _, it := range payload.Items {
+ code := strings.TrimSpace(it.ProductCode)
+ if code == "" {
+ continue
+ }
+ mmitemID := codeToItemID[code]
+ if mmitemID <= 0 {
+ continue
+ }
+ if it.Dim1 <= 0 {
+ continue
+ }
+ d3k := int64(0)
+ if it.Dim3 != nil && *it.Dim3 > 0 {
+ d3k = *it.Dim3
+ }
+
+ // Normalize requested campaign id (nullable).
+ var requested any = nil
+ if it.CampaignID != nil && *it.CampaignID > 0 {
+ requested = *it.CampaignID
+ }
+
+ // Skip write if "current" assignment is already the same (latest row).
+ {
+ var cur sql.NullInt64
+ err := tx.QueryRowContext(ctx, `
+SELECT sdcampaign_id
+FROM zbggcampaign
+WHERE mmitem_id = $1 AND dim1 = $2 AND COALESCE(dim3, 0) = $3
+ORDER BY id DESC
+LIMIT 1
+`, mmitemID, it.Dim1, d3k).Scan(&cur)
+ if err != nil && err != sql.ErrNoRows {
+ http.Error(w, "current campaign lookup error: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ if err == sql.ErrNoRows && requested == nil {
+ // Clearing a non-existent assignment: no-op.
+ continue
+ }
+ if err == nil {
+ // requested == nil means "clear"
+ if requested == nil && !cur.Valid {
+ continue
+ }
+ if requested != nil && cur.Valid && cur.Int64 == requested.(int64) {
+ continue
+ }
+ }
+ }
+
+ if _, err := tx.ExecContext(ctx, `
+INSERT INTO zbggcampaign (mmitem_id, dim1, dim3, sdcampaign_id)
+VALUES ($1,$2,$3,$4)
+`, mmitemID, it.Dim1, func() any {
+ if d3k > 0 {
+ return d3k
+ }
+ return nil
+ }(), requested); err != nil {
+ http.Error(w, "insert campaign row error: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ saved++
+ }
+
+ if err := tx.Commit(); err != nil {
+ http.Error(w, "commit error", http.StatusInternalServerError)
+ return
+ }
+
+ // Send campaign mail (post-commit, best-effort).
+ if ml != nil {
+ codes := make([]string, 0, len(payload.Items))
+ seen := map[string]struct{}{}
+ for _, it := range payload.Items {
+ c := strings.TrimSpace(it.ProductCode)
+ if c == "" {
+ continue
+ }
+ if _, ok := seen[c]; ok {
+ continue
+ }
+ seen[c] = struct{}{}
+ codes = append(codes, c)
+ }
+ go sendWholesaleCampaignChangeMails(context.Background(), ml, codes, claims.Username)
+ }
+
+ log.Printf("[WholesaleCampaignSave] trace=%s user=%s id=%d items=%d saved=%d duration_ms=%d",
+ traceID, claims.Username, claims.ID, len(payload.Items), saved, time.Since(started).Milliseconds(),
+ )
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ _ = json.NewEncoder(w).Encode(map[string]any{
+ "success": true,
+ "saved": saved,
+ })
+ }
+}
+
+type wholesaleVariantStockRow struct {
+ ProductCode string `json:"product_code"`
+ VariantCode string `json:"variant_code"`
+ StockQty float64 `json:"stock_qty"`
+}
+
+type wholesaleVariantRow struct {
+ ProductCode string `json:"product_code"`
+ VariantCode string `json:"variant_code"`
+ StockQty float64 `json:"stock_qty"`
+ Dim1 int64 `json:"dim1"`
+ Dim3 *int64 `json:"dim3"`
+ CampaignID *int64 `json:"campaign_id"`
+ CampaignCode string `json:"campaign_code"`
+ CampaignTitle string `json:"campaign_title"`
+ DiscountRate float64 `json:"discount_rate"`
+ CampaignLast string `json:"campaign_last_dttm"`
+}
+
+type wholesaleCampaignHistoryRow struct {
+ ID int64 `json:"id"`
+ CampaignID *int64 `json:"campaign_id"`
+ CampaignCode string `json:"campaign_code"`
+ Title string `json:"campaign_title"`
+ DiscountRate float64 `json:"discount_rate"`
+ At string `json:"at"`
+}
+
+type wholesaleCampaignHistoryResponse struct {
+ Rows []wholesaleCampaignHistoryRow `json:"rows"`
+}
+
+type deleteSelectedIDsPayload struct {
+ IDs []int64 `json:"ids"`
+}
+
+// GET /api/pricing/wholesale-campaigns/{code}/campaign-history?dim1=..&dim3=..
+func GetWholesaleCampaignHistoryHandler(pg *sql.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ traceID := buildPricingTraceID(r)
+ w.Header().Set("X-Trace-ID", traceID)
+
+ claims, ok := auth.GetClaimsFromContext(r.Context())
+ if !ok || claims == nil {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ code := strings.TrimSpace(mux.Vars(r)["code"])
+ if code == "" {
+ http.Error(w, "missing code", http.StatusBadRequest)
+ return
+ }
+
+ dim1, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("dim1")), 10, 64)
+ dim3, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("dim3")), 10, 64)
+ if dim1 <= 0 {
+ http.Error(w, "missing dim1", http.StatusBadRequest)
+ return
+ }
+ d3k := dim3
+ if d3k < 0 {
+ d3k = 0
+ }
+
+ ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
+ defer cancel()
+
+ var mmitemID int64
+ if err := pg.QueryRowContext(ctx, `SELECT id FROM mmitem WHERE code = $1`, code).Scan(&mmitemID); err != nil {
+ if err == sql.ErrNoRows {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ _ = json.NewEncoder(w).Encode(wholesaleCampaignHistoryResponse{Rows: []wholesaleCampaignHistoryRow{}})
+ return
+ }
+ http.Error(w, "mmitem lookup error: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ rows, err := pg.QueryContext(ctx, `
+SELECT
+ z.id,
+ z.sdcampaign_id,
+ COALESCE(sc.code,'') AS code,
+ COALESCE(sc.title,'') AS title,
+ COALESCE(sc.discount_rate, 0)::float8 AS discount_rate,
+ COALESCE(to_char(COALESCE(z.zlupd_dttm, z.zlins_dttm), 'YYYY-MM-DD HH24:MI:SS'), '') AS at
+FROM zbggcampaign z
+LEFT JOIN sdcampaign sc
+ ON sc.id = z.sdcampaign_id
+WHERE z.mmitem_id = $1
+ AND z.dim1 = $2
+ AND COALESCE(z.dim3, 0) = $3
+ORDER BY z.id DESC
+LIMIT 200;
+`, mmitemID, dim1, d3k)
+ if err != nil {
+ http.Error(w, "campaign history query error: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ defer rows.Close()
+
+ out := make([]wholesaleCampaignHistoryRow, 0, 64)
+ for rows.Next() {
+ var it wholesaleCampaignHistoryRow
+ var cid sql.NullInt64
+ if err := rows.Scan(&it.ID, &cid, &it.CampaignCode, &it.Title, &it.DiscountRate, &it.At); err != nil {
+ http.Error(w, "campaign history scan error", http.StatusInternalServerError)
+ return
+ }
+ if cid.Valid && cid.Int64 > 0 {
+ v := cid.Int64
+ it.CampaignID = &v
+ }
+ it.CampaignCode = strings.TrimSpace(it.CampaignCode)
+ it.Title = strings.TrimSpace(it.Title)
+ it.At = strings.TrimSpace(it.At)
+ out = append(out, it)
+ }
+
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ _ = json.NewEncoder(w).Encode(wholesaleCampaignHistoryResponse{Rows: out})
+ log.Printf("[WholesaleCampaignHistory] trace=%s user=%s id=%d code=%s dim1=%d dim3=%d rows=%d",
+ traceID, claims.Username, claims.ID, code, dim1, d3k, len(out),
+ )
+ }
+}
+
+// POST /api/pricing/wholesale-campaigns/{code}/campaign-history/delete-selected?dim1=..&dim3=..
+func PostDeleteSelectedWholesaleCampaignHistoryHandler(pg *sql.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ traceID := buildPricingTraceID(r)
+ w.Header().Set("X-Trace-ID", traceID)
+
+ claims, ok := auth.GetClaimsFromContext(r.Context())
+ if !ok || claims == nil {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ code := strings.TrimSpace(mux.Vars(r)["code"])
+ if code == "" {
+ http.Error(w, "missing code", http.StatusBadRequest)
+ return
+ }
+ dim1, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("dim1")), 10, 64)
+ dim3, _ := strconv.ParseInt(strings.TrimSpace(r.URL.Query().Get("dim3")), 10, 64)
+ if dim1 <= 0 {
+ http.Error(w, "missing dim1", http.StatusBadRequest)
+ return
+ }
+ d3k := dim3
+ if d3k < 0 {
+ d3k = 0
+ }
+
+ var payload deleteSelectedIDsPayload
+ if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
+ http.Error(w, "invalid payload", http.StatusBadRequest)
+ return
+ }
+ if len(payload.IDs) == 0 {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "deleted": 0})
+ return
+ }
+ if len(payload.IDs) > 500 {
+ http.Error(w, "too many ids", http.StatusBadRequest)
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(r.Context(), 10*time.Second)
+ defer cancel()
+
+ var mmitemID int64
+ if err := pg.QueryRowContext(ctx, `SELECT id FROM mmitem WHERE code = $1`, code).Scan(&mmitemID); err != nil {
+ if err == sql.ErrNoRows {
+ http.Error(w, "unknown code", http.StatusBadRequest)
+ return
+ }
+ http.Error(w, "mmitem lookup error: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ res, err := pg.ExecContext(ctx, `
+DELETE FROM zbggcampaign
+WHERE id = ANY($1::bigint[])
+ AND mmitem_id = $2
+ AND dim1 = $3
+ AND COALESCE(dim3, 0) = $4
+`, pq.Array(payload.IDs), mmitemID, dim1, d3k)
+ if err != nil {
+ http.Error(w, "delete error: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ deleted, _ := res.RowsAffected()
+
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "deleted": deleted})
+ log.Printf("[WholesaleCampaignHistoryDelete] trace=%s user=%s id=%d code=%s dim1=%d dim3=%d deleted=%d",
+ traceID, claims.Username, claims.ID, code, dim1, d3k, deleted,
+ )
+ }
+}
+
+// GET /api/pricing/wholesale-campaigns/variant-rows?product_code=A,B,C
+// Returns variant-level rows with resolved PG dims and current campaign assignment (if any).
+func GetWholesaleCampaignVariantRowsHandler(pg *sql.DB, mssql *sql.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ traceID := buildPricingTraceID(r)
+ w.Header().Set("X-Trace-ID", traceID)
+
+ claims, ok := auth.GetClaimsFromContext(r.Context())
+ if !ok || claims == nil {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ if pg == nil {
+ http.Error(w, "pg not ready", http.StatusServiceUnavailable)
+ return
+ }
+ if mssql == nil {
+ http.Error(w, "mssql not ready", http.StatusServiceUnavailable)
+ return
+ }
+
+ codes := splitCSVParam(r.URL.Query().Get("product_code"))
+ if len(codes) == 0 {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ _ = json.NewEncoder(w).Encode([]wholesaleVariantRow{})
+ return
+ }
+ if len(codes) > 250 {
+ http.Error(w, "product_code too many", http.StatusBadRequest)
+ return
+ }
+
+ // MSSQL 2008 + stock breakdown over many items can be slow; keep a generous timeout.
+ ctx, cancel := context.WithTimeout(r.Context(), 120*time.Second)
+ defer cancel()
+
+ // Resolve mmitem ids in bulk.
+ codeToItem := make(map[string]int64, len(codes))
+ {
+ rows, err := pg.QueryContext(ctx, `
+SELECT code, id
+FROM mmitem
+WHERE code = ANY($1::text[])
+`, pq.Array(codes))
+ if err != nil {
+ http.Error(w, "mmitem lookup error: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ for rows.Next() {
+ var c string
+ var id int64
+ if err := rows.Scan(&c, &id); err != nil {
+ rows.Close()
+ http.Error(w, "mmitem scan error", http.StatusInternalServerError)
+ return
+ }
+ c = strings.TrimSpace(c)
+ if c != "" && id > 0 {
+ codeToItem[c] = id
+ }
+ }
+ rows.Close()
+ }
+
+ // Dim token -> id resolver (fast path: mk_dim_token_map; fallback: dfblob file_name token inference).
+ dimCache := make(map[string]int64, 1024)
+ parseDimID := func(s string) (int64, bool) {
+ s = strings.TrimSpace(s)
+ if s == "" {
+ return 0, false
+ }
+ s2 := strings.TrimLeft(s, "0")
+ if s2 == "" {
+ s2 = "0"
+ }
+ n, err := strconv.ParseInt(s2, 10, 64)
+ if err != nil || n <= 0 {
+ return 0, false
+ }
+ return n, true
+ }
+ resolveDimID := func(column, token string) (int64, bool) {
+ token = strings.ToUpper(normalizeDimParam(token))
+ if token == "" {
+ return 0, false
+ }
+ k := column + "|" + token
+ if v, ok := dimCache[k]; ok {
+ return v, v > 0
+ }
+ // persistent cache
+ {
+ var id int64
+ if err := pg.QueryRowContext(ctx, `
+SELECT dim_id
+FROM mk_dim_token_map
+WHERE dim_column = $1 AND token = $2
+`, column, token).Scan(&id); err == nil && id > 0 {
+ dimCache[k] = id
+ return id, true
+ }
+ }
+ // fallback: infer id from dfblob metadata (token -> dimval id)
+ v := resolveDimvalFromFileNameToken(pg, column, token)
+ if v == "" {
+ dimCache[k] = 0
+ return 0, false
+ }
+ id, ok := parseDimID(v)
+ if !ok {
+ dimCache[k] = 0
+ return 0, false
+ }
+ _, _ = pg.ExecContext(ctx, `
+INSERT INTO mk_dim_token_map (dim_column, token, dim_id, updated_at)
+VALUES ($1,$2,$3,now())
+ON CONFLICT (dim_column, token)
+DO UPDATE SET dim_id = EXCLUDED.dim_id, updated_at = EXCLUDED.updated_at
+`, column, token, id)
+ dimCache[k] = id
+ return id, true
+ }
+
+ // MSSQL: variant+stock list for selected products.
+ joined := strings.Join(codes, ",")
+ msRows, err := mssql.QueryContext(ctx, queries.GetWholesaleCampaignVariantStockByProducts, joined)
+ if err != nil {
+ http.Error(w, "variant stock query error: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ defer msRows.Close()
+
+ type tmpRow struct {
+ ProductCode string
+ VariantCode string
+ StockQty float64
+ ItemID int64
+ Dim1 int64
+ Dim3Key int64
+ }
+ // Deduplicate by (mmitem_id, dim1, dim3_key) and aggregate stock qty.
+ tmpMap := make(map[string]tmpRow, 4096)
+ for msRows.Next() {
+ var itemCode, colorCode, dim1Code, dim3Code string
+ var qty sql.NullFloat64
+ if err := msRows.Scan(&itemCode, &colorCode, &dim1Code, &dim3Code, &qty); err != nil {
+ http.Error(w, "variant stock scan error", http.StatusInternalServerError)
+ return
+ }
+ itemCode = strings.TrimSpace(itemCode)
+ if itemCode == "" {
+ continue
+ }
+ itemID := codeToItem[itemCode]
+ if itemID <= 0 {
+ continue
+ }
+
+ // Variant token: prefer ColorCode; ItemDim1Code may represent a different attribute.
+ t1 := strings.TrimSpace(colorCode)
+ if t1 == "" || t1 == "0" {
+ t1 = strings.TrimSpace(dim1Code)
+ }
+ t3 := strings.TrimSpace(dim3Code)
+ varCode := strings.TrimSpace(t1)
+ if varCode != "" && t3 != "" && t3 != "0" {
+ varCode = varCode + "-" + strings.TrimSpace(t3)
+ }
+ if varCode == "" {
+ continue
+ }
+
+ d1 := int64(0)
+ // Resolve dim1: prefer ColorCode first (matches e-comm expectation: dim1=Color).
+ if id, ok := resolveDimID("dimval1", colorCode); ok {
+ d1 = id
+ } else if id, ok := resolveDimID("dimval1", dim1Code); ok {
+ d1 = id
+ }
+ if d1 <= 0 {
+ continue
+ }
+ d3k := int64(0)
+ if id, ok := resolveDimID("dimval3", t3); ok {
+ d3k = id
+ }
+
+ q := 0.0
+ if qty.Valid {
+ q = qty.Float64
+ }
+ key := fmt.Sprintf("%d|%d|%d", itemID, d1, d3k)
+ if prev, ok := tmpMap[key]; ok {
+ prev.StockQty += q
+ // Keep the first non-empty variant code.
+ if prev.VariantCode == "" {
+ prev.VariantCode = varCode
+ }
+ tmpMap[key] = prev
+ } else {
+ tmpMap[key] = tmpRow{
+ ProductCode: itemCode,
+ VariantCode: varCode,
+ StockQty: q,
+ ItemID: itemID,
+ Dim1: d1,
+ Dim3Key: d3k,
+ }
+ }
+ }
+ if err := msRows.Err(); err != nil {
+ http.Error(w, "variant stock read error: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+
+ tmp := make([]tmpRow, 0, len(tmpMap))
+ for _, v := range tmpMap {
+ tmp = append(tmp, v)
+ }
+
+ // Bulk load campaign assignment for each (mmitem_id, dim1, dim3_key)
+ type keyRec struct {
+ ItemID int64 `json:"mmitem_id"`
+ Dim1 int64 `json:"dim1"`
+ Dim3Key int64 `json:"dim3_key"`
+ }
+ keys := make([]keyRec, 0, len(tmp))
+ seenKey := make(map[string]struct{}, len(tmp))
+ for _, t := range tmp {
+ k := fmt.Sprintf("%d|%d|%d", t.ItemID, t.Dim1, t.Dim3Key)
+ if _, ok := seenKey[k]; ok {
+ continue
+ }
+ seenKey[k] = struct{}{}
+ keys = append(keys, keyRec{ItemID: t.ItemID, Dim1: t.Dim1, Dim3Key: t.Dim3Key})
+ }
+ rawKeys, _ := json.Marshal(keys)
+
+ type campAgg struct {
+ CampaignID sql.NullInt64
+ CampaignCode string
+ CampaignTitle string
+ DiscountRate float64
+ CampaignLast string
+ }
+ campMap := make(map[string]campAgg, len(keys))
+ if len(keys) > 0 {
+ rows, err := pg.QueryContext(ctx, `
+WITH input AS (
+ SELECT *
+ FROM jsonb_to_recordset($1::jsonb) AS x(mmitem_id bigint, dim1 int, dim3_key int)
+),
+latest AS (
+ SELECT
+ i.mmitem_id,
+ i.dim1,
+ i.dim3_key,
+ MAX(z.id)::bigint AS z_id
+ FROM input i
+ LEFT JOIN zbggcampaign z
+ ON z.mmitem_id = i.mmitem_id
+ AND z.dim1 = i.dim1
+ AND COALESCE(z.dim3, 0) = i.dim3_key
+ GROUP BY i.mmitem_id, i.dim1, i.dim3_key
+)
+SELECT
+ l.mmitem_id,
+ l.dim1,
+ l.dim3_key,
+ z.sdcampaign_id,
+ COALESCE(sc.code,'') AS code,
+ COALESCE(sc.title,'') AS title,
+ COALESCE(sc.discount_rate, 0)::float8 AS discount_rate,
+ COALESCE(to_char(COALESCE(z.zlupd_dttm, z.zlins_dttm), 'YYYY-MM-DD HH24:MI:SS'), '') AS campaign_last_dttm
+ FROM latest l
+ LEFT JOIN zbggcampaign z
+ ON z.id = l.z_id
+ LEFT JOIN sdcampaign sc
+ ON sc.id = z.sdcampaign_id
+`, rawKeys)
+ if err != nil {
+ http.Error(w, "campaign lookup error: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ for rows.Next() {
+ var itemID, d1, d3k int64
+ var cid sql.NullInt64
+ var code, title string
+ var rate float64
+ var last string
+ if err := rows.Scan(&itemID, &d1, &d3k, &cid, &code, &title, &rate, &last); err != nil {
+ rows.Close()
+ http.Error(w, "campaign scan error", http.StatusInternalServerError)
+ return
+ }
+ campMap[fmt.Sprintf("%d|%d|%d", itemID, d1, d3k)] = campAgg{
+ CampaignID: cid,
+ CampaignCode: strings.TrimSpace(code),
+ CampaignTitle: strings.TrimSpace(title),
+ DiscountRate: rate,
+ CampaignLast: strings.TrimSpace(last),
+ }
+ }
+ rows.Close()
+ }
+
+ out := make([]wholesaleVariantRow, 0, len(tmp))
+ for _, t := range tmp {
+ agg := campMap[fmt.Sprintf("%d|%d|%d", t.ItemID, t.Dim1, t.Dim3Key)]
+ var cidp *int64
+ if agg.CampaignID.Valid && agg.CampaignID.Int64 > 0 {
+ v := agg.CampaignID.Int64
+ cidp = &v
+ }
+ var d3p *int64
+ if t.Dim3Key > 0 {
+ v := t.Dim3Key
+ d3p = &v
+ }
+ out = append(out, wholesaleVariantRow{
+ ProductCode: t.ProductCode,
+ VariantCode: t.VariantCode,
+ StockQty: t.StockQty,
+ Dim1: t.Dim1,
+ Dim3: d3p,
+ CampaignID: cidp,
+ CampaignCode: agg.CampaignCode,
+ CampaignTitle: agg.CampaignTitle,
+ DiscountRate: agg.DiscountRate,
+ CampaignLast: agg.CampaignLast,
+ })
+ }
+
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ _ = json.NewEncoder(w).Encode(out)
+ log.Printf("[WholesaleCampaignVariantRows] trace=%s user=%s id=%d products=%d rows=%d",
+ traceID, claims.Username, claims.ID, len(codes), len(out),
+ )
+ }
+}
+
+// GET /api/pricing/wholesale-campaigns/variants?product_code=A,B,C
+func GetWholesaleCampaignVariantStockHandler(mssql *sql.DB) http.HandlerFunc {
+ return func(w http.ResponseWriter, r *http.Request) {
+ traceID := buildPricingTraceID(r)
+ w.Header().Set("X-Trace-ID", traceID)
+
+ claims, ok := auth.GetClaimsFromContext(r.Context())
+ if !ok || claims == nil {
+ http.Error(w, "unauthorized", http.StatusUnauthorized)
+ return
+ }
+ if mssql == nil {
+ http.Error(w, "mssql not ready", http.StatusServiceUnavailable)
+ return
+ }
+
+ codes := splitCSVParam(r.URL.Query().Get("product_code"))
+ if len(codes) == 0 {
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ _ = json.NewEncoder(w).Encode([]wholesaleVariantStockRow{})
+ return
+ }
+ if len(codes) > 300 {
+ http.Error(w, "product_code too many", http.StatusBadRequest)
+ return
+ }
+
+ ctx, cancel := context.WithTimeout(r.Context(), 25*time.Second)
+ defer cancel()
+
+ joined := strings.Join(codes, ",")
+ rows, err := mssql.QueryContext(ctx, queries.GetWholesaleCampaignVariantStockByProducts, joined)
+ if err != nil {
+ http.Error(w, "variant stock query error: "+err.Error(), http.StatusInternalServerError)
+ return
+ }
+ defer rows.Close()
+
+ out := make([]wholesaleVariantStockRow, 0, 2048)
+ for rows.Next() {
+ var itemCode, colorCode, dim1Code, dim3Code string
+ var qty sql.NullFloat64
+ if err := rows.Scan(&itemCode, &colorCode, &dim1Code, &dim3Code, &qty); err != nil {
+ http.Error(w, "variant stock scan error", http.StatusInternalServerError)
+ return
+ }
+ itemCode = strings.TrimSpace(itemCode)
+ if itemCode == "" {
+ continue
+ }
+ // Variant token: prefer ColorCode; ItemDim1Code may represent a different attribute.
+ t1 := strings.TrimSpace(colorCode)
+ if t1 == "" || t1 == "0" {
+ t1 = strings.TrimSpace(dim1Code)
+ }
+ t3 := strings.TrimSpace(dim3Code)
+ varCode := t1
+ if t1 != "" && t3 != "" && t3 != "0" {
+ varCode = t1 + "-" + t3
+ }
+ if varCode == "" {
+ continue
+ }
+ q := 0.0
+ if qty.Valid {
+ q = qty.Float64
+ }
+ out = append(out, wholesaleVariantStockRow{
+ ProductCode: itemCode,
+ VariantCode: varCode,
+ StockQty: q,
+ })
+ }
+
+ w.Header().Set("Content-Type", "application/json; charset=utf-8")
+ _ = json.NewEncoder(w).Encode(out)
+ log.Printf("[WholesaleCampaignVariants] trace=%s user=%s id=%d products=%d rows=%d",
+ traceID, claims.Username, claims.ID, len(codes), len(out),
+ )
+ }
+}
diff --git a/ui/quasar.config.js.temporary.compiled.1781721394097.mjs b/ui/quasar.config.js.temporary.compiled.1781734575091.mjs
similarity index 100%
rename from ui/quasar.config.js.temporary.compiled.1781721394097.mjs
rename to ui/quasar.config.js.temporary.compiled.1781734575091.mjs
diff --git a/ui/src/layouts/MainLayout.vue b/ui/src/layouts/MainLayout.vue
index b0d48fb..5b8e35e 100644
--- a/ui/src/layouts/MainLayout.vue
+++ b/ui/src/layouts/MainLayout.vue
@@ -358,6 +358,11 @@ const menuItems = [
to: '/app/pricing/product-pricing',
permission: 'pricing:view'
},
+ {
+ label: 'Toptan Kampanya Yönetimi',
+ to: '/app/pricing/wholesale-campaigns',
+ permission: 'pricing:view'
+ },
{
label: 'Marka Sınıflandırma',
to: '/app/pricing/brand-classification',
diff --git a/ui/src/pages/WholesaleCampaigns.vue b/ui/src/pages/WholesaleCampaigns.vue
new file mode 100644
index 0000000..9919134
--- /dev/null
+++ b/ui/src/pages/WholesaleCampaigns.vue
@@ -0,0 +1,3261 @@
+
+
+
+
Toptan Kampanya Yonetimi
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Sayfayi Excel'e Aktar
+
+
+
+ Tum Filtreyi Excel'e Aktar
+
+
+
+
+ Fiyat Listesi Ciktisi...
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
Calismaya Baslamak Icin
+
+ Urun Ilk Grubu veya Urun Ana Grubu secin ve GRUPLARI GETIR'e basin.
+
+
+
+
+
+
+
+
+
+
+
+ onRowCheckboxChange(props.row, val)"
+ @click.stop
+ />
+
+
+
+
+
+
+ Kampanya gecmisi
+
+
+
+
+
+
+ {{ props.value }}
+
+
+
+
+
+ {{ props.row.variantCodes || '' }}
+
+
+
+
+
+ {{ props.row.variantStocks || '' }}
+
+
+
+
+
+ onRowCampaignChange(props.row, val)"
+ />
+
+
+
+
+
+
+ {{ formatRateDisplay(props.row.campaignRate) }}
+
+
+
+
+
+
+
+ {{ formatPriceMaybe(props.value) }}
+
+
+
+
+
+
+ {{ formatStock(props.value) }}
+
+
+
+
+
+ {{ formatDateDisplay(props.value) }}
+
+
+
+
+
+ {{ formatDateDisplay(props.value) }}
+
+
+
+
+
+
+ {{ props.row.brandGroupSelection || '' }}
+
+
+
+
+
+
+
+ onEditableCellChange(props.row, props.col.field, e.target.value)"
+ />
+
+ {{ formatPrice(getOriginalCellValue(props.row, props.col.field)) }}
+
+
+ {{ formatGenericCellValue(props.col, props.value) }}
+
+
+
+
+
+
+ {{ store.error }}
+
+
+
+
+
+
+
Kampanya Gecmisi
+
+ {{ campaignHistoryRow?.productCode || '-' }} | {{ campaignHistoryRow?.variantCodes || '-' }}
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+ Kayit bulunamadi.
+
+
+
+
+ toggleSelectedCampaignHistoryId(r.id, val)"
+ @click.stop
+ />
+
+
+
+ {{ r.campaign_code ? `${r.campaign_code} - ${r.campaign_title}` : '(Bos)' }}
+
+ (%{{ formatRateDisplay(r.discount_rate) }})
+
+
+ {{ r.at || '-' }}
+
+
+
+
+
+
+
+
+
+
+
+ Fiyat Listesi Ciktisi
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/ui/src/router/routes.js b/ui/src/router/routes.js
index a67a326..457cfe0 100644
--- a/ui/src/router/routes.js
+++ b/ui/src/router/routes.js
@@ -395,6 +395,12 @@ const routes = [
component: () => import('pages/PricingRules.vue'),
meta: { permission: 'pricing:view' }
},
+ {
+ path: 'pricing/wholesale-campaigns',
+ name: 'wholesale-campaigns',
+ component: () => import('pages/WholesaleCampaigns.vue'),
+ meta: { permission: 'pricing:view' }
+ },
{
path: 'costing/production-product-costing',
name: 'production-product-costing',