1238 lines
35 KiB
Go
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),
|
|
)
|
|
}
|
|
}
|