Merge remote-tracking branch 'origin/master'
This commit is contained in:
35
svc/main.go
35
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",
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
99
svc/queries/wholesale_campaign_variants_mssql.go
Normal file
99
svc/queries/wholesale_campaign_variants_mssql.go
Normal file
@@ -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('<i>' + REPLACE(REPLACE(@Codes, '&', '&'), ',', '</i><i>') + '</i>' 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;
|
||||
`
|
||||
@@ -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":
|
||||
|
||||
@@ -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)
|
||||
|
||||
286
svc/routes/wholesale_campaign_mail.go
Normal file
286
svc/routes/wholesale_campaign_mail.go
Normal file
@@ -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(`<div style="font-family:Segoe UI, Arial, sans-serif; font-size:18px; line-height:1.35; -webkit-text-size-adjust:100%;">`)
|
||||
b.WriteString(`<div style="margin-bottom:10px;">`)
|
||||
b.WriteString(`<div style="font-size:22px; margin-bottom:4px;"><b>Kampanya Degisikligi</b></div>`)
|
||||
b.WriteString(`<div>Urun Ilk Grubu: <b>` + htmlEscapeMini(firstGroupCode) + `</b></div>`)
|
||||
if strings.TrimSpace(actor) != "" {
|
||||
b.WriteString(`<div>Islem Yapan: <b>` + htmlEscapeMini(actor) + `</b></div>`)
|
||||
}
|
||||
b.WriteString(`<div>Tarih: <b>` + htmlEscapeMini(at.Format("02.01.2006 15:04")) + `</b></div>`)
|
||||
b.WriteString(`<div>Varyant Sayisi: <b>` + fmt.Sprintf("%d", len(rows)) + `</b></div>`)
|
||||
b.WriteString(`</div>`)
|
||||
|
||||
b.WriteString(`<div style="max-width:100%; overflow-x:auto;">`)
|
||||
b.WriteString(`<table style="border-collapse:collapse; font-size:16px; white-space:nowrap;">`)
|
||||
b.WriteString(`<thead><tr>`)
|
||||
heads := []string{
|
||||
"MARKA GRUBU",
|
||||
"MARKA",
|
||||
"URUN KODU",
|
||||
"DIM1",
|
||||
"DIM3",
|
||||
"KAMPANYA",
|
||||
"IND %",
|
||||
}
|
||||
for _, h := range heads {
|
||||
b.WriteString(`<th style="border:1px solid #d0d0d0; background:#f3f3f3; padding:8px 10px; text-align:left; font-size:16px;">` + htmlEscapeMini(h) + `</th>`)
|
||||
}
|
||||
b.WriteString(`</tr></thead><tbody>`)
|
||||
|
||||
for _, r := range rows {
|
||||
b.WriteString(`<tr>`)
|
||||
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(`<td style="border:1px solid #e0e0e0; padding:8px 10px; text-align:` + align + `;">` + htmlEscapeMini(strings.TrimSpace(c)) + `</td>`)
|
||||
}
|
||||
b.WriteString(`</tr>`)
|
||||
}
|
||||
|
||||
b.WriteString(`</tbody></table></div>`)
|
||||
b.WriteString(`<div style="margin-top:12px; font-size:14px; color:#666;">Bu e-posta BSSApp sistemi tarafindan otomatik olusturulmustur.</div>`)
|
||||
b.WriteString(`</div>`)
|
||||
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))
|
||||
}
|
||||
}
|
||||
}
|
||||
1028
svc/routes/wholesale_campaigns.go
Normal file
1028
svc/routes/wholesale_campaigns.go
Normal file
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user