package routes import ( "bssapp-backend/auth" "bssapp-backend/db" "bssapp-backend/queries" "bssapp-backend/utils" "context" "database/sql" "encoding/json" "fmt" "net/http" "strings" "time" "github.com/gorilla/mux" "github.com/lib/pq" ) type productPricingHistoryPGRow struct { ID string `json:"id"` Currency string `json:"currency"` LevelNo int `json:"level_no"` Price float64 `json:"price"` UpdatedAt string `json:"updated_at"` SdprcGrpID int `json:"sdprcgrp_id"` } type productPricingHistoryMSSQLRow struct { PriceListLineID string `json:"price_list_line_id"` Currency string `json:"currency"` PriceGroupCode string `json:"price_group_code"` Price float64 `json:"price"` ValidDate string `json:"valid_date"` ValidTime string `json:"valid_time"` LastUpdatedDate string `json:"last_updated_date"` IsDisabled bool `json:"is_disabled"` } type productPricingHistoryResponse struct { ProductCode string `json:"product_code"` Postgres []productPricingHistoryPGRow `json:"postgres"` Mssql []productPricingHistoryMSSQLRow `json:"mssql"` } func GetProductPricingHistoryHandler(pg *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") traceID := utils.TraceIDFromRequest(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 } productCode := strings.TrimSpace(mux.Vars(r)["code"]) if productCode == "" { http.Error(w, "product code required", http.StatusBadRequest) return } ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 60*time.Second) defer cancel() // Load nebim price groups from PG mapping (18) + base groups (2). priceGroups := []string{"TM-USD", "TM-TRY"} if pg != nil { rows, err := pg.QueryContext(ctx, ` SELECT DISTINCT COALESCE(NULLIF(BTRIM(price_group_code), ''), '') FROM mk_price_target_map_nebim WHERE is_active = TRUE `) if err == nil { for rows.Next() { var code string if err := rows.Scan(&code); err != nil { _ = rows.Close() break } code = strings.TrimSpace(code) if code != "" { priceGroups = append(priceGroups, code) } } _ = rows.Close() } } resp := productPricingHistoryResponse{ ProductCode: productCode, Postgres: []productPricingHistoryPGRow{}, Mssql: []productPricingHistoryMSSQLRow{}, } // Postgres sdprc history. if pg != nil { pgRows, err := pg.QueryContext(ctx, ` SELECT sdprc.id::text, sdprc.crn, sdprc.sdprcgrp_id, COALESCE(sdprc.prc, 0)::float8, TO_CHAR(sdprc.zlins_dttm, 'YYYY-MM-DD HH24:MI:SS') FROM sdprc JOIN mmitem ON mmitem.id = sdprc.mmitem_id WHERE mmitem.code = $1 AND sdprc.crn IN ('USD','EUR','TRY') AND sdprc.sdprcgrp_id BETWEEN 1 AND 6 ORDER BY sdprc.zlins_dttm DESC LIMIT 400; `, productCode) if err == nil { for pgRows.Next() { var id, cur, at string var grp int var prc float64 if err := pgRows.Scan(&id, &cur, &grp, &prc, &at); err != nil { _ = pgRows.Close() http.Error(w, "pg history scan error", http.StatusInternalServerError) return } resp.Postgres = append(resp.Postgres, productPricingHistoryPGRow{ ID: strings.TrimSpace(id), Currency: strings.ToUpper(strings.TrimSpace(cur)), SdprcGrpID: grp, LevelNo: grp, Price: prc, UpdatedAt: strings.TrimSpace(at), }) } _ = pgRows.Close() } } // MSSQL trPriceListLine history (only relevant price groups). mssql := db.GetDB() if mssql != nil { // Build a safe "IN" via OR parameters. conds := make([]string, 0, len(priceGroups)) args := make([]any, 0, len(priceGroups)+1) args = append(args, sql.Named("p1", productCode)) for i, g := range priceGroups { name := fmt.Sprintf("g%d", i+1) conds = append(conds, "LTRIM(RTRIM(p.PriceGroupCode)) = @"+name) args = append(args, sql.Named(name, g)) } wherePG := "1=0" if len(conds) > 0 { wherePG = "(" + strings.Join(conds, " OR ") + ")" } q := ` SELECT TOP (400) CONVERT(NVARCHAR(36), p.PriceListLineID) AS PriceListLineID, LTRIM(RTRIM(p.DocCurrencyCode)) AS DocCurrencyCode, LTRIM(RTRIM(p.PriceGroupCode)) AS PriceGroupCode, CAST(p.Price AS FLOAT) AS Price, CONVERT(VARCHAR(10), p.ValidDate, 23) AS ValidDate, CONVERT(VARCHAR(8), p.ValidTime, 108) AS ValidTime, CONVERT(VARCHAR(19), p.LastUpdatedDate, 120) AS LastUpdatedDate, CAST(ISNULL(p.IsDisabled, 0) AS BIT) AS IsDisabled FROM dbo.trPriceListLine p WITH(NOLOCK) WHERE p.ItemTypeCode = 1 AND LTRIM(RTRIM(p.ItemCode)) = @p1 AND ` + wherePG + ` ORDER BY p.ValidDate DESC, p.ValidTime DESC, p.LastUpdatedDate DESC; ` rows, err := mssql.QueryContext(ctx, q, args...) if err == nil { for rows.Next() { var id, cur, grp, vd, vt, lud string var prc float64 var disabled bool if err := rows.Scan(&id, &cur, &grp, &prc, &vd, &vt, &lud, &disabled); err != nil { _ = rows.Close() http.Error(w, "mssql history scan error", http.StatusInternalServerError) return } resp.Mssql = append(resp.Mssql, productPricingHistoryMSSQLRow{ PriceListLineID: strings.TrimSpace(id), Currency: strings.ToUpper(strings.TrimSpace(cur)), PriceGroupCode: strings.TrimSpace(grp), Price: prc, ValidDate: strings.TrimSpace(vd), ValidTime: strings.TrimSpace(vt), LastUpdatedDate: strings.TrimSpace(lud), IsDisabled: disabled, }) } _ = rows.Close() } } _ = json.NewEncoder(w).Encode(resp) } } type deleteLatestPriceHistoryRequest struct { DeletePostgres bool `json:"delete_postgres"` DeleteMssql bool `json:"delete_mssql"` Currency string `json:"currency"` // USD/EUR/TRY LevelNo int `json:"level_no"` // 1..6 (tier); for base use 0 IsBase bool `json:"is_base"` PriceGroupCode string `json:"price_group_code"` // optional override for MSSQL deletes } func PostDeleteLatestProductPriceHistoryHandler(pg *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") traceID := utils.TraceIDFromRequest(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 } productCode := strings.TrimSpace(mux.Vars(r)["code"]) if productCode == "" { http.Error(w, "product code required", http.StatusBadRequest) return } var req deleteLatestPriceHistoryRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid payload", http.StatusBadRequest) return } if !req.DeletePostgres && !req.DeleteMssql { req.DeletePostgres = true req.DeleteMssql = true } cur := strings.ToUpper(strings.TrimSpace(req.Currency)) if cur != "USD" && cur != "EUR" && cur != "TRY" { http.Error(w, "invalid currency", http.StatusBadRequest) return } if !req.IsBase && req.DeletePostgres && (req.LevelNo < 1 || req.LevelNo > 6) { http.Error(w, "invalid level_no", http.StatusBadRequest) return } ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 60*time.Second) defer cancel() // PG delete (sdprc). deletedPG := int64(0) if req.DeletePostgres && !req.IsBase && pg != nil { tx, err := pg.BeginTx(ctx, nil) if err != nil { http.Error(w, "pg tx error", http.StatusInternalServerError) return } defer tx.Rollback() var mmItemID int64 if err := tx.QueryRowContext(ctx, `SELECT id FROM mmitem WHERE code=$1`, productCode).Scan(&mmItemID); err != nil { http.Error(w, "pg product not found", http.StatusNotFound) return } grp := req.LevelNo // Delete latest row for that currency+level. res, err := tx.ExecContext(ctx, ` DELETE FROM sdprc WHERE id = ( SELECT id FROM sdprc WHERE mmitem_id=$1 AND crn=$2 AND sdprcgrp_id=$3 ORDER BY zlins_dttm DESC LIMIT 1 ); `, mmItemID, cur, grp) if err != nil { http.Error(w, "pg delete error", http.StatusInternalServerError) return } deletedPG, _ = res.RowsAffected() // enqueue delta recompute for this product to keep derived currencies consistent _, _ = queries.EnqueuePriceRecalc(ctx, tx, []string{productCode}, "history_delete") if err := tx.Commit(); err != nil { http.Error(w, "pg commit error", http.StatusInternalServerError) return } } // MSSQL delete (trPriceListLine). deletedMSSQL := int64(0) if req.DeleteMssql { mssql := db.GetDB() if mssql == nil { http.Error(w, "mssql not connected", http.StatusInternalServerError) return } tx, err := mssql.BeginTx(ctx, nil) if err != nil { http.Error(w, "mssql tx error", http.StatusInternalServerError) return } defer tx.Rollback() priceGroup := strings.TrimSpace(req.PriceGroupCode) if req.IsBase { if cur == "USD" { priceGroup = "TM-USD" } else if cur == "TRY" { priceGroup = "TM-TRY" } else { http.Error(w, "base only supports USD/TRY", http.StatusBadRequest) return } } else if priceGroup == "" && pg != nil { _ = pg.QueryRowContext(ctx, ` SELECT COALESCE(NULLIF(BTRIM(price_group_code), ''), '') FROM mk_price_target_map_nebim WHERE is_active=TRUE AND currency=$1 AND level_no=$2 `, cur, req.LevelNo).Scan(&priceGroup) } priceGroup = strings.TrimSpace(priceGroup) if priceGroup == "" { http.Error(w, "missing price group mapping", http.StatusBadRequest) return } res, err := tx.ExecContext(ctx, ` ;WITH latest AS ( SELECT TOP (1) p.PriceListLineID FROM dbo.trPriceListLine p WITH(UPDLOCK, ROWLOCK) WHERE p.ItemTypeCode=1 AND LTRIM(RTRIM(p.ItemCode))=@p1 AND LTRIM(RTRIM(p.DocCurrencyCode))=@p2 AND LTRIM(RTRIM(p.PriceGroupCode))=@p3 AND ISNULL(p.IsDisabled, 0)=0 ORDER BY p.ValidDate DESC, p.ValidTime DESC, p.LastUpdatedDate DESC ) DELETE FROM dbo.trPriceListLine WHERE PriceListLineID IN (SELECT PriceListLineID FROM latest); `, sql.Named("p1", productCode), sql.Named("p2", cur), sql.Named("p3", priceGroup)) if err != nil { http.Error(w, "mssql delete error", http.StatusInternalServerError) return } deletedMSSQL, _ = res.RowsAffected() if err := tx.Commit(); err != nil { http.Error(w, "mssql commit error", http.StatusInternalServerError) return } } _ = json.NewEncoder(w).Encode(map[string]any{ "success": true, "product_code": productCode, "deleted_pg": deletedPG, "deleted_mssql": deletedMSSQL, "actor_user": claims.Username, "actor_user_id": claims.ID, }) } } type deleteSelectedPriceHistoryRequest struct { PGIDs []string `json:"pg_ids"` // sdprc.id (uuid) MSSQLIDs []string `json:"mssql_ids"` // trPriceListLine.PriceListLineID (uuid) } func PostDeleteSelectedProductPriceHistoryHandler(pg *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") traceID := utils.TraceIDFromRequest(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 } productCode := strings.TrimSpace(mux.Vars(r)["code"]) if productCode == "" { http.Error(w, "product code required", http.StatusBadRequest) return } var req deleteSelectedPriceHistoryRequest if err := json.NewDecoder(r.Body).Decode(&req); err != nil { http.Error(w, "invalid payload", http.StatusBadRequest) return } // normalize ids pgIDs := make([]string, 0, len(req.PGIDs)) for _, x := range req.PGIDs { s := strings.TrimSpace(x) if s != "" { pgIDs = append(pgIDs, s) } } msIDs := make([]string, 0, len(req.MSSQLIDs)) for _, x := range req.MSSQLIDs { s := strings.TrimSpace(x) if s != "" { msIDs = append(msIDs, s) } } if len(pgIDs) == 0 && len(msIDs) == 0 { http.Error(w, "no ids selected", http.StatusBadRequest) return } ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 60*time.Second) defer cancel() deletedPG := int64(0) if len(pgIDs) > 0 && pg != nil { tx, err := pg.BeginTx(ctx, nil) if err != nil { http.Error(w, "pg tx error", http.StatusInternalServerError) return } defer tx.Rollback() // Resolve product id to constrain deletes to the given productCode. var mmItemID int64 if err := tx.QueryRowContext(ctx, `SELECT id FROM mmitem WHERE code=$1`, productCode).Scan(&mmItemID); err != nil { http.Error(w, "pg product not found", http.StatusNotFound) return } // Delete only rows matching mmitem_id + id list. res, err := tx.ExecContext(ctx, ` DELETE FROM sdprc WHERE mmitem_id = $1 AND id = ANY($2::uuid[]); `, mmItemID, pq.Array(pgIDs)) if err != nil { http.Error(w, "pg delete error", http.StatusInternalServerError) return } deletedPG, _ = res.RowsAffected() _, _ = queries.EnqueuePriceRecalc(ctx, tx, []string{productCode}, "history_delete_selected") if err := tx.Commit(); err != nil { http.Error(w, "pg commit error", http.StatusInternalServerError) return } } deletedMSSQL := int64(0) if len(msIDs) > 0 { mssql := db.GetDB() if mssql == nil { http.Error(w, "mssql not connected", http.StatusInternalServerError) return } tx, err := mssql.BeginTx(ctx, nil) if err != nil { http.Error(w, "mssql tx error", http.StatusInternalServerError) return } defer tx.Rollback() // Build a safe IN-list via named parameters. placeholders := make([]string, 0, len(msIDs)) args := make([]any, 0, len(msIDs)+1) args = append(args, sql.Named("p1", productCode)) for i, id := range msIDs { name := fmt.Sprintf("id%d", i+1) placeholders = append(placeholders, "@"+name) args = append(args, sql.Named(name, id)) } q := ` DELETE FROM dbo.trPriceListLine WHERE ItemTypeCode = 1 AND LTRIM(RTRIM(ItemCode)) = @p1 AND PriceListLineID IN (` + strings.Join(placeholders, ",") + `); ` res, err := tx.ExecContext(ctx, q, args...) if err != nil { http.Error(w, "mssql delete error", http.StatusInternalServerError) return } deletedMSSQL, _ = res.RowsAffected() if err := tx.Commit(); err != nil { http.Error(w, "mssql commit error", http.StatusInternalServerError) return } } _ = json.NewEncoder(w).Encode(map[string]any{ "success": true, "product_code": productCode, "deleted_pg": deletedPG, "deleted_mssql": deletedMSSQL, "actor_user": claims.Username, "actor_user_id": claims.ID, }) } }