Files
bssapp/svc/routes/product_pricing_history.go
2026-06-17 21:57:02 +03:00

506 lines
15 KiB
Go

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,
})
}
}