From a3d143ad70372a41043d359ae4c64da67e90d37d Mon Sep 17 00:00:00 2001 From: M_Kececi Date: Thu, 18 Jun 2026 12:28:05 +0300 Subject: [PATCH] Merge remote-tracking branch 'origin/master' --- logs/ui-dev-20260617-171953.err.log | 18 + logs/ui-dev-20260617-171953.out.log | 106 + svc/main.go | 35 + svc/queries/product_pricing.go | 38 + svc/queries/product_pricing_options.go | 83 +- .../wholesale_campaign_variants_mssql.go | 99 + svc/routes/product_pricing.go | 147 +- .../product_pricing_price_list_export.go | 32 + svc/routes/wholesale_campaign_mail.go | 286 ++ svc/routes/wholesale_campaigns.go | 1028 ++++++ ...g.js.temporary.compiled.1781734575091.mjs} | 0 ui/src/layouts/MainLayout.vue | 5 + ui/src/pages/WholesaleCampaigns.vue | 3261 +++++++++++++++++ ui/src/router/routes.js | 6 + 14 files changed, 5123 insertions(+), 21 deletions(-) create mode 100644 svc/queries/wholesale_campaign_variants_mssql.go create mode 100644 svc/routes/wholesale_campaign_mail.go create mode 100644 svc/routes/wholesale_campaigns.go rename ui/{quasar.config.js.temporary.compiled.1781721394097.mjs => quasar.config.js.temporary.compiled.1781734575091.mjs} (100%) create mode 100644 ui/src/pages/WholesaleCampaigns.vue 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(``) + } + 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(``) + } + b.WriteString(``) + } + + b.WriteString(`
` + htmlEscapeMini(h) + `
` + htmlEscapeMini(strings.TrimSpace(c)) + `
`) + 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 @@ + + + + + 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',