Files
bssapp/svc/routes/wholesale_campaigns.go
2026-06-19 12:03:46 +03:00

1238 lines
35 KiB
Go

package routes
import (
"bssapp-backend/auth"
"bssapp-backend/queries"
"context"
"database/sql"
"encoding/json"
"fmt"
"log"
"net/http"
"sort"
"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, 'DD.MM.YYYY'), '') AS dtst,
COALESCE(to_char(dtfn, 'DD.MM.YYYY'), '') 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"`
}
func buildNebimVariantDisplayCode(colorCode string, dim3Code string) string {
colorCode = strings.TrimSpace(colorCode)
dim3Code = strings.TrimSpace(dim3Code)
if colorCode != "" && dim3Code != "" {
return colorCode + "-" + dim3Code
}
if colorCode != "" {
return colorCode
}
return dim3Code
}
func chooseDisplayDimToken(raw string, resolvedID int64, reverse map[int64]string) string {
raw = strings.TrimSpace(raw)
if resolvedID > 0 {
if tok := strings.TrimSpace(reverse[resolvedID]); tok != "" {
return tok
}
}
return raw
}
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), 'DD.MM.YYYY'), '') 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))
itemToCode := make(map[int64]string, 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
itemToCode[id] = c
}
}
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
}
type tmpRow struct {
ProductCode string
VariantCode string
StockQty float64
ItemID int64
Dim1 int64
Dim3Key int64
HasMSSQL bool
}
// Build base variant keys from PG's authoritative table (mmitem_dim).
itemIDs := make([]int64, 0, len(codeToItem))
for _, id := range codeToItem {
itemIDs = append(itemIDs, id)
}
tmpMap := make(map[string]tmpRow, 4096)
hasMMItemDim := make(map[int64]bool, len(itemIDs))
dimIDs := make([]int64, 0, 8192)
if len(itemIDs) > 0 {
rows, err := pg.QueryContext(ctx, `
SELECT mmitem_id, mmdim_id, val1, val2, val3
FROM mmitem_dim
WHERE mmitem_id = ANY($1::bigint[])
AND COALESCE(is_active, TRUE) = TRUE
`, pq.Array(itemIDs))
if err != nil {
http.Error(w, "mmitem_dim lookup error: "+err.Error(), http.StatusInternalServerError)
return
}
for rows.Next() {
var itemID int64
var mmdimID sql.NullInt64
var v1 sql.NullInt64
var v2 sql.NullInt64
var v3 sql.NullInt64
if err := rows.Scan(&itemID, &mmdimID, &v1, &v2, &v3); err != nil {
rows.Close()
http.Error(w, "mmitem_dim scan error", http.StatusInternalServerError)
return
}
hasMMItemDim[itemID] = true
if !v1.Valid || v1.Int64 <= 0 {
continue
}
// Variant key in this installation: (val1=color, val3=itemdim3_if_any). Ignore val2 (size).
d1 := v1.Int64
_ = mmdimID
_ = v2
d3k := int64(0)
if v3.Valid && v3.Int64 > 0 {
d3k = v3.Int64
}
code := strings.TrimSpace(itemToCode[itemID])
if code == "" {
continue
}
key := fmt.Sprintf("%d|%d|%d", itemID, d1, d3k)
if _, ok := tmpMap[key]; ok {
continue
}
tmpMap[key] = tmpRow{
ProductCode: code,
VariantCode: "",
StockQty: 0,
ItemID: itemID,
Dim1: d1,
Dim3Key: d3k,
}
dimIDs = append(dimIDs, d1)
if d3k > 0 {
dimIDs = append(dimIDs, d3k)
}
}
rows.Close()
}
// Resolve dim ids -> tokens for a fallback readable VariantCode.
// MSSQL/Nebim tokens override this below; PG ids are only storage keys.
idToToken := map[int64]string{}
if len(dimIDs) > 0 {
// uniq
uniq := make([]int64, 0, len(dimIDs))
seen := make(map[int64]struct{}, len(dimIDs))
for _, id := range dimIDs {
if id <= 0 {
continue
}
if _, ok := seen[id]; ok {
continue
}
seen[id] = struct{}{}
uniq = append(uniq, id)
}
if len(uniq) > 0 {
rows, err := pg.QueryContext(ctx, `
SELECT DISTINCT ON (dim_id) dim_id, token
FROM mk_dim_token_map
WHERE dim_column = 'dimval1'
AND dim_id = ANY($1::bigint[])
ORDER BY dim_id, updated_at DESC;
`, pq.Array(uniq))
if err == nil {
for rows.Next() {
var id int64
var tok string
_ = rows.Scan(&id, &tok)
tok = strings.TrimSpace(tok)
if tok != "" {
idToToken[id] = tok
}
}
rows.Close()
}
}
}
for k, v := range tmpMap {
t1 := strings.TrimSpace(idToToken[v.Dim1])
if t1 == "" {
t1 = fmt.Sprintf("%d", v.Dim1)
}
if v.Dim3Key > 0 {
t3 := strings.TrimSpace(idToToken[v.Dim3Key])
if t3 == "" {
t3 = fmt.Sprintf("%d", v.Dim3Key)
}
v.VariantCode = t1 + "-" + t3
} else {
v.VariantCode = t1
}
tmpMap[k] = v
}
canonicalToken := func(id int64) string {
if id <= 0 {
return ""
}
if tok := strings.TrimSpace(idToToken[id]); tok != "" {
return tok
}
var tok string
if err := pg.QueryRowContext(ctx, `
SELECT token
FROM mk_dim_token_map
WHERE dim_column = 'dimval1'
AND dim_id = $1
ORDER BY
CASE WHEN token ~ '^[0-9]{3}$' THEN 0 ELSE 1 END,
length(token),
updated_at DESC
LIMIT 1
`, id).Scan(&tok); err == nil {
tok = strings.TrimSpace(tok)
if tok != "" {
idToToken[id] = tok
return tok
}
}
return ""
}
// MSSQL: stock list for selected products; map to (mmitem_id, dim1, dim3_key) via token->id mapping.
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()
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
}
// Map Nebim tokens to PG integer ids (dimval1 namespace).
// This app uses key: dim1=<color>, dim3=<itemdim3> to match mmitem_dim (val1,val3).
d1 := int64(0)
if id, ok := resolveDimID("dimval1", colorCode); ok {
d1 = id
}
if d1 <= 0 {
continue
}
d3k := int64(0)
if id, ok := resolveDimID("dimval1", dim3Code); ok {
d3k = id
}
key := fmt.Sprintf("%d|%d|%d", itemID, d1, d3k)
prev, ok := tmpMap[key]
if !ok {
// Seed missing MSSQL combos even when the product already has some mmitem_dim rows.
// PG remains the storage key source, but MSSQL may reveal new/missing color or dim3 combos.
var v2 any = nil
if sizeID, ok := resolveDimID("dimval1", dim1Code); ok && sizeID > 0 {
v2 = sizeID
}
v3 := int64(0)
if id, ok := resolveDimID("dimval1", dim3Code); ok {
v3 = id
}
mmdimID := int64(2)
var v3any any = nil
if v3 > 0 {
mmdimID = 3
v3any = v3
}
_, _ = pg.ExecContext(ctx, `
INSERT INTO mmitem_dim (mmitem_id, mmdim_id, val1, val2, val3, is_active, qty)
SELECT $1, $2, $3, $4, $5, TRUE, 0
WHERE NOT EXISTS (
SELECT 1
FROM mmitem_dim
WHERE mmitem_id = $1
AND mmdim_id = $2
AND val1 = $3
AND COALESCE(val2, 0) = COALESCE($4::bigint, 0)
AND COALESCE(val3, 0) = COALESCE($5::bigint, 0)
LIMIT 1
);
`, itemID, mmdimID, d1, v2, v3any)
hasMMItemDim[itemID] = true
code := strings.TrimSpace(itemToCode[itemID])
if code != "" {
tmpMap[key] = tmpRow{
ProductCode: code,
VariantCode: "",
StockQty: 0,
ItemID: itemID,
Dim1: d1,
Dim3Key: d3k,
}
// Keep dim token cache for VariantCode formatting.
dimIDs = append(dimIDs, d1)
if d3k > 0 {
dimIDs = append(dimIDs, d3k)
}
prev = tmpMap[key]
ok = true
}
if !ok {
continue
}
}
q := 0.0
if qty.Valid {
q = qty.Float64
}
prev.StockQty += q
displayColor := chooseDisplayDimToken(colorCode, d1, map[int64]string{d1: canonicalToken(d1)})
displayDim3 := chooseDisplayDimToken(dim3Code, d3k, map[int64]string{d3k: canonicalToken(d3k)})
prev.VariantCode = buildNebimVariantDisplayCode(displayColor, displayDim3)
prev.HasMSSQL = true
tmpMap[key] = prev
_ = colorCode // display-only
}
tmp := make([]tmpRow, 0, len(tmpMap))
for _, v := range tmpMap {
tmp = append(tmp, v)
}
sort.SliceStable(tmp, func(i, j int) bool {
if tmp[i].ProductCode != tmp[j].ProductCode {
return tmp[i].ProductCode < tmp[j].ProductCode
}
if tmp[i].HasMSSQL != tmp[j].HasMSSQL {
return tmp[i].HasMSSQL
}
return strings.TrimSpace(tmp[i].VariantCode) < strings.TrimSpace(tmp[j].VariantCode)
})
// 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), 'DD.MM.YYYY'), '') 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),
)
}
}