ui: update ProductPerformanceProfitability page to enhance table views and tabs management
This commit is contained in:
+10
@@ -829,6 +829,11 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
|
||||
"order", "view",
|
||||
wrapV3(http.HandlerFunc(routes.GetProductImagesHandler(pgDB))),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/product-images/batch", "POST",
|
||||
"order", "view",
|
||||
wrapV3(http.HandlerFunc(routes.PostProductImagesBatchHandler(pgDB))),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/product-images/{id}/content", "GET",
|
||||
"order", "view",
|
||||
@@ -984,6 +989,11 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
|
||||
"pricing", "view",
|
||||
wrapV3(routes.GetProductPerformanceSalesBreakdownHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/product-performance/grouped", "GET",
|
||||
"pricing", "view",
|
||||
wrapV3(routes.GetProductPerformanceGroupedHandler(pgDB)),
|
||||
)
|
||||
bindV3(r, pgDB,
|
||||
"/api/pricing/product-performance/sales-details", "GET",
|
||||
"pricing", "view",
|
||||
|
||||
@@ -66,6 +66,7 @@ var routeMetaCache sync.Map
|
||||
|
||||
var routeMetaFallback = map[string]routeMeta{
|
||||
"GET /api/product-images": {module: "order", action: "view"},
|
||||
"POST /api/product-images/batch": {module: "order", action: "view"},
|
||||
"GET /api/product-images/{id}/content": {module: "order", action: "view"},
|
||||
"GET /api/product-stock-query-by-attributes": {module: "order", action: "view"},
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"bssapp-backend/models"
|
||||
"context"
|
||||
"database/sql"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log"
|
||||
"sort"
|
||||
@@ -2583,6 +2584,419 @@ LIMIT $1
|
||||
return out, rows.Err()
|
||||
}
|
||||
|
||||
type ProductPerformanceGroupedRequest struct {
|
||||
Mode string
|
||||
GroupLevels []string
|
||||
ExpandedKeys map[string]bool
|
||||
Limit int
|
||||
}
|
||||
|
||||
func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductPerformanceGroupedRequest) ([]map[string]any, error) {
|
||||
if req.Limit <= 0 || req.Limit > 50000 {
|
||||
req.Limit = 50000
|
||||
}
|
||||
levels := sanitizeProductPerformanceGroupLevels(req.GroupLevels)
|
||||
if len(levels) == 0 {
|
||||
levels = defaultProductPerformanceGroupLevels(req.Mode)
|
||||
}
|
||||
|
||||
sourceRows, err := productPerformanceGroupedSourceRows(ctx, pg, req.Mode, req.Limit)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
out := make([]map[string]any, 0, len(sourceRows))
|
||||
appendProductPerformanceGroupedRows(&out, sourceRows, levels, 0, []string{"tab:" + req.Mode}, req.ExpandedKeys)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
func productPerformanceGroupedSourceRows(ctx context.Context, pg *sql.DB, mode string, limit int) ([]map[string]any, error) {
|
||||
switch mode {
|
||||
case "products":
|
||||
rows, _, err := ListProductPerformance(ctx, pg, ProductPerformanceFilters{Limit: limit, Page: 1, SortBy: "performance_score", Descending: true})
|
||||
return structsToMaps(rows), err
|
||||
case "idle":
|
||||
rows, _, err := ListProductPerformance(ctx, pg, ProductPerformanceFilters{Limit: limit, Page: 1, SortBy: "performance_score", Descending: true})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return productPerformanceIdleSourceRows(structsToMaps(rows)), nil
|
||||
case "sales_color_yaka_market_customer", "sales_product_country_segment_market_customer", "sales_market_customer_product", "sales_country_segment_market_customer_product":
|
||||
rows, err := ListProductPerformanceSalesBreakdown(ctx, pg, mode, limit)
|
||||
return structsToMaps(rows), err
|
||||
case "order_product_customers":
|
||||
rows, err := ListProductPerformanceOrderProductCustomers(ctx, pg, limit)
|
||||
return structsToMaps(rows), err
|
||||
case "order_market_details":
|
||||
rows, err := ListProductPerformanceOrderMarketDetails(ctx, pg, limit)
|
||||
return structsToMaps(rows), err
|
||||
default:
|
||||
rows, _, err := ListProductPerformance(ctx, pg, ProductPerformanceFilters{Limit: limit, Page: 1, SortBy: "performance_score", Descending: true})
|
||||
return structsToMaps(rows), err
|
||||
}
|
||||
}
|
||||
|
||||
func productPerformanceIdleSourceRows(rows []map[string]any) []map[string]any {
|
||||
out := make([]map[string]any, 0, len(rows))
|
||||
for _, row := range rows {
|
||||
stockQty := floatFromMap(row, "stock_qty")
|
||||
salesQty90 := floatFromMap(row, "sales_qty_90d")
|
||||
stockDays90 := floatFromMap(row, "stock_days_90d")
|
||||
if stockQty <= 0 {
|
||||
continue
|
||||
}
|
||||
if stringFromMap(row, "performance_bucket") != "STOK_RISKI" && salesQty90 != 0 && stockDays90 <= 180 {
|
||||
continue
|
||||
}
|
||||
next := cloneMap(row)
|
||||
next["idle_cost_usd"] = stockQty * floatFromMap(row, "cost_price_usd")
|
||||
out = append(out, next)
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func appendProductPerformanceGroupedRows(out *[]map[string]any, sourceRows []map[string]any, levels []string, level int, parentKeys []string, expandedKeys map[string]bool) {
|
||||
if level >= len(levels) {
|
||||
*out = append(*out, sourceRows...)
|
||||
return
|
||||
}
|
||||
|
||||
field := levels[level]
|
||||
grouped := make(map[string][]map[string]any)
|
||||
for _, row := range sourceRows {
|
||||
value := normalizeProductPerformanceGroupValue(mapGroupValue(row, field))
|
||||
grouped[value] = append(grouped[value], row)
|
||||
}
|
||||
|
||||
values := make([]string, 0, len(grouped))
|
||||
for value := range grouped {
|
||||
values = append(values, value)
|
||||
}
|
||||
sort.Slice(values, func(i, j int) bool {
|
||||
return strings.Compare(values[i], values[j]) < 0
|
||||
})
|
||||
|
||||
for _, value := range values {
|
||||
keyPart := field + ":" + value
|
||||
key := strings.Join(append(parentKeys, keyPart), "|")
|
||||
groupRows := grouped[value]
|
||||
*out = append(*out, makeProductPerformanceGroupedRow(key, level, field, value, groupRows))
|
||||
if expandedKeys[key] {
|
||||
appendProductPerformanceGroupedRows(out, groupRows, levels, level+1, append(parentKeys, keyPart), expandedKeys)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func makeProductPerformanceGroupedRow(key string, level int, field, value string, rows []map[string]any) map[string]any {
|
||||
row := aggregateProductPerformanceRows(rows)
|
||||
row["__group"] = true
|
||||
row["row_key"] = "group|" + key
|
||||
row["key"] = key
|
||||
row["level"] = level
|
||||
row["group_field"] = field
|
||||
row["group_value"] = value
|
||||
row["label"] = value
|
||||
row["count"] = len(rows)
|
||||
row[field] = value
|
||||
row["recommendation"] = fmt.Sprintf("%d satır", len(rows))
|
||||
|
||||
image := firstProductPerformanceImageSource(rows)
|
||||
row["image_product_code"] = stringFromMap(image, "product_code")
|
||||
row["image_color_code"] = stringFromMap(image, "color_code")
|
||||
row["image_yaka_kodu"] = stringFromMap(image, "yaka_kodu")
|
||||
return row
|
||||
}
|
||||
|
||||
func aggregateProductPerformanceRows(rows []map[string]any) map[string]any {
|
||||
out := map[string]any{}
|
||||
for _, row := range rows {
|
||||
for key, value := range row {
|
||||
if key == "row_key" || key == "key" {
|
||||
continue
|
||||
}
|
||||
if shouldSumProductPerformanceField(key) {
|
||||
out[key] = floatFromAny(out[key]) + floatFromAny(value)
|
||||
} else if _, ok := out[key]; !ok && !shouldAverageProductPerformanceField(key) {
|
||||
out[key] = value
|
||||
}
|
||||
}
|
||||
}
|
||||
for _, row := range rows {
|
||||
for key := range row {
|
||||
if shouldAverageProductPerformanceField(key) {
|
||||
out[key] = weightedAverageProductPerformanceRows(rows, key, productPerformanceMetricWeightField(key))
|
||||
}
|
||||
}
|
||||
}
|
||||
deriveProductPerformanceGroupMetrics(out)
|
||||
out["performance_bucket"] = dominantProductPerformanceValue(rows, "performance_bucket")
|
||||
return out
|
||||
}
|
||||
|
||||
func deriveProductPerformanceGroupMetrics(out map[string]any) {
|
||||
for _, suffix := range []string{"90d", "180d", "365d", "total"} {
|
||||
sales := floatFromMap(out, "sales_usd_"+suffix)
|
||||
qty := floatFromMap(out, "sales_qty_"+suffix)
|
||||
stockQty := floatFromMap(out, "stock_qty")
|
||||
if stockQty > 0 {
|
||||
out["stock_turnover_"+suffix] = qty / stockQty
|
||||
}
|
||||
if qty > 0 {
|
||||
out["avg_price_usd_"+suffix] = sales / qty
|
||||
}
|
||||
if sales > 0 {
|
||||
if _, ok := out["gross_profit_base_usd_"+suffix]; ok {
|
||||
out["gross_margin_base_"+suffix] = floatFromMap(out, "gross_profit_base_usd_"+suffix) / sales
|
||||
}
|
||||
if _, ok := out["gross_profit_cost_usd_"+suffix]; ok {
|
||||
out["gross_margin_cost_"+suffix] = floatFromMap(out, "gross_profit_cost_usd_"+suffix) / sales
|
||||
}
|
||||
if _, ok := out["gross_profit_usd_"+suffix]; ok {
|
||||
out["gross_margin_"+suffix] = floatFromMap(out, "gross_profit_usd_"+suffix) / sales
|
||||
}
|
||||
}
|
||||
}
|
||||
orderUSD := floatFromMap(out, "order_usd")
|
||||
if orderUSD > 0 {
|
||||
out["expected_margin_base"] = floatFromMap(out, "expected_profit_base_usd") / orderUSD
|
||||
out["expected_margin_cost"] = floatFromMap(out, "expected_profit_cost_usd") / orderUSD
|
||||
}
|
||||
orderQty := floatFromMap(out, "order_qty")
|
||||
if orderQty > 0 {
|
||||
out["avg_order_price_usd"] = orderUSD / orderQty
|
||||
}
|
||||
}
|
||||
|
||||
func sanitizeProductPerformanceGroupLevels(levels []string) []string {
|
||||
allowed := map[string]bool{
|
||||
"kategori": true, "askili_yan": true, "urun_ilk_grubu": true, "urun_ana_grubu": true, "urun_alt_grubu": true,
|
||||
"product_code": true, "color_code": true, "yaka_kodu": true, "market_key": true, "customer_code": true,
|
||||
"country": true, "customer_segment": true,
|
||||
}
|
||||
out := make([]string, 0, len(levels))
|
||||
seen := map[string]bool{}
|
||||
for _, level := range levels {
|
||||
level = strings.TrimSpace(level)
|
||||
if allowed[level] && !seen[level] {
|
||||
out = append(out, level)
|
||||
seen[level] = true
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func defaultProductPerformanceGroupLevels(mode string) []string {
|
||||
switch mode {
|
||||
case "sales_color_yaka_market_customer":
|
||||
return []string{"color_code", "yaka_kodu", "market_key", "customer_code"}
|
||||
case "idle":
|
||||
return []string{"kategori", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "product_code"}
|
||||
case "sales_product_country_segment_market_customer":
|
||||
return []string{"product_code", "country", "customer_segment", "market_key", "customer_code"}
|
||||
case "sales_market_customer_product":
|
||||
return []string{"market_key", "customer_code", "product_code", "color_code", "yaka_kodu"}
|
||||
case "sales_country_segment_market_customer_product":
|
||||
return []string{"country", "customer_segment", "market_key", "customer_code", "product_code", "color_code", "yaka_kodu"}
|
||||
case "order_product_customers":
|
||||
return []string{"product_code", "market_key", "customer_code"}
|
||||
case "order_market_details":
|
||||
return []string{"market_key", "customer_code", "product_code", "color_code", "yaka_kodu"}
|
||||
default:
|
||||
return []string{"kategori", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu", "market_key", "product_code"}
|
||||
}
|
||||
}
|
||||
|
||||
func mapGroupValue(row map[string]any, field string) string {
|
||||
switch field {
|
||||
case "urun_ilk_grubu":
|
||||
if value := stringFromMap(row, "urun_ilk_grubu"); value != "" {
|
||||
return value
|
||||
}
|
||||
return stringFromMap(row, "yas_grubu")
|
||||
case "urun_ana_grubu":
|
||||
if value := stringFromMap(row, "urun_ana_grubu"); value != "" {
|
||||
return value
|
||||
}
|
||||
return stringFromMap(row, "seri")
|
||||
case "market_key":
|
||||
return displayProductPerformanceMarketName(stringFromMap(row, "market_key"))
|
||||
default:
|
||||
return stringFromMap(row, field)
|
||||
}
|
||||
}
|
||||
|
||||
func shouldSumProductPerformanceField(field string) bool {
|
||||
return strings.HasSuffix(field, "_qty") ||
|
||||
strings.HasSuffix(field, "_usd") ||
|
||||
strings.HasSuffix(field, "_count") ||
|
||||
strings.HasSuffix(field, "_value_usd") ||
|
||||
field == "stock_qty" ||
|
||||
field == "line_count" ||
|
||||
field == "invoice_count" ||
|
||||
field == "order_count" ||
|
||||
field == "product_count" ||
|
||||
field == "market_count" ||
|
||||
field == "customer_count" ||
|
||||
field == "overdue_qty" ||
|
||||
field == "net_stock_after_order" ||
|
||||
field == "idle_cost_usd"
|
||||
}
|
||||
|
||||
func shouldAverageProductPerformanceField(field string) bool {
|
||||
return strings.HasPrefix(field, "avg_") ||
|
||||
strings.HasPrefix(field, "unit_") ||
|
||||
strings.HasPrefix(field, "base_price") ||
|
||||
strings.HasPrefix(field, "cost_price") ||
|
||||
strings.HasPrefix(field, "gross_margin") ||
|
||||
strings.HasPrefix(field, "expected_margin") ||
|
||||
strings.HasPrefix(field, "sales_index") ||
|
||||
strings.HasPrefix(field, "performance_score") ||
|
||||
strings.HasPrefix(field, "stock_days") ||
|
||||
strings.HasPrefix(field, "stock_turnover")
|
||||
}
|
||||
|
||||
func productPerformanceMetricWeightField(field string) string {
|
||||
if strings.Contains(field, "_180d") {
|
||||
return "sales_qty_180d"
|
||||
}
|
||||
if strings.Contains(field, "_365d") {
|
||||
return "sales_qty_365d"
|
||||
}
|
||||
if strings.Contains(field, "_total") {
|
||||
return "sales_qty_total"
|
||||
}
|
||||
if strings.Contains(field, "order") || strings.Contains(field, "expected_") {
|
||||
return "order_qty"
|
||||
}
|
||||
if strings.Contains(field, "stock") {
|
||||
return "stock_qty"
|
||||
}
|
||||
return "sales_qty_90d"
|
||||
}
|
||||
|
||||
func weightedAverageProductPerformanceRows(rows []map[string]any, valueField, qtyField string) float64 {
|
||||
var weighted, qty, sum float64
|
||||
var count int
|
||||
for _, row := range rows {
|
||||
value := floatFromMap(row, valueField)
|
||||
weight := floatFromMap(row, qtyField)
|
||||
if weight > 0 {
|
||||
weighted += value * weight
|
||||
qty += weight
|
||||
}
|
||||
if value != 0 {
|
||||
sum += value
|
||||
count++
|
||||
}
|
||||
}
|
||||
if qty > 0 {
|
||||
return weighted / qty
|
||||
}
|
||||
if count > 0 {
|
||||
return sum / float64(count)
|
||||
}
|
||||
return 0
|
||||
}
|
||||
|
||||
func dominantProductPerformanceValue(rows []map[string]any, field string) string {
|
||||
counts := map[string]int{}
|
||||
for _, row := range rows {
|
||||
value := stringFromMap(row, field)
|
||||
if value != "" {
|
||||
counts[value]++
|
||||
}
|
||||
}
|
||||
var best string
|
||||
var bestCount int
|
||||
for value, count := range counts {
|
||||
if count > bestCount {
|
||||
best = value
|
||||
bestCount = count
|
||||
}
|
||||
}
|
||||
return best
|
||||
}
|
||||
|
||||
func firstProductPerformanceImageSource(rows []map[string]any) map[string]any {
|
||||
for _, row := range rows {
|
||||
if stringFromMap(row, "product_code") != "" {
|
||||
return row
|
||||
}
|
||||
}
|
||||
if len(rows) > 0 {
|
||||
return rows[0]
|
||||
}
|
||||
return map[string]any{}
|
||||
}
|
||||
|
||||
func structsToMaps[T any](rows []T) []map[string]any {
|
||||
raw, err := json.Marshal(rows)
|
||||
if err != nil {
|
||||
return nil
|
||||
}
|
||||
var out []map[string]any
|
||||
if err := json.Unmarshal(raw, &out); err != nil {
|
||||
return nil
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func cloneMap(row map[string]any) map[string]any {
|
||||
out := make(map[string]any, len(row))
|
||||
for key, value := range row {
|
||||
out[key] = value
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func normalizeProductPerformanceGroupValue(value string) string {
|
||||
value = strings.TrimSpace(value)
|
||||
if value == "" {
|
||||
return "-"
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
func displayProductPerformanceMarketName(value string) string {
|
||||
parts := strings.Split(value, "|")
|
||||
for i := len(parts) - 1; i >= 0; i-- {
|
||||
part := strings.TrimSpace(parts[i])
|
||||
if part != "" {
|
||||
return part
|
||||
}
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func stringFromMap(row map[string]any, field string) string {
|
||||
value, ok := row[field]
|
||||
if !ok || value == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(fmt.Sprint(value))
|
||||
}
|
||||
|
||||
func floatFromMap(row map[string]any, field string) float64 {
|
||||
return floatFromAny(row[field])
|
||||
}
|
||||
|
||||
func floatFromAny(value any) float64 {
|
||||
switch v := value.(type) {
|
||||
case float64:
|
||||
return v
|
||||
case float32:
|
||||
return float64(v)
|
||||
case int:
|
||||
return float64(v)
|
||||
case int64:
|
||||
return float64(v)
|
||||
case json.Number:
|
||||
f, _ := v.Float64()
|
||||
return f
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func ListProductPerformanceSalesDetails(ctx context.Context, pg *sql.DB, productCode, colorCode, yakaKodu string, limit int) ([]models.ProductPerformanceSalesDetailRow, error) {
|
||||
if limit <= 0 || limit > 500 {
|
||||
limit = 100
|
||||
|
||||
@@ -27,6 +27,23 @@ type ProductImageItem struct {
|
||||
FullURL string `json:"full_url,omitempty"`
|
||||
}
|
||||
|
||||
type ProductImageBatchItem struct {
|
||||
Key string `json:"key"`
|
||||
Code string `json:"code"`
|
||||
Dim1 string `json:"dim1"`
|
||||
Dim3 string `json:"dim3"`
|
||||
Limit int `json:"limit,omitempty"`
|
||||
}
|
||||
|
||||
type ProductImageBatchRequest struct {
|
||||
Items []ProductImageBatchItem `json:"items"`
|
||||
}
|
||||
|
||||
type ProductImageBatchResponseItem struct {
|
||||
Key string `json:"key"`
|
||||
Images []ProductImageItem `json:"images"`
|
||||
}
|
||||
|
||||
var uuidPattern = regexp.MustCompile(`(?i)[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}`)
|
||||
|
||||
func normalizeDimParam(v string) string {
|
||||
@@ -119,6 +136,138 @@ func extractImageUUID(storagePath, fileName string) string {
|
||||
return ""
|
||||
}
|
||||
|
||||
// POST /api/product-images/batch
|
||||
func PostProductImagesBatchHandler(pg *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
reqID := strings.TrimSpace(r.Header.Get("X-Request-ID"))
|
||||
if reqID == "" {
|
||||
reqID = uuid.NewString()
|
||||
}
|
||||
w.Header().Set("X-Request-ID", reqID)
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
|
||||
var req ProductImageBatchRequest
|
||||
if err := json.NewDecoder(r.Body).Decode(&req); err != nil {
|
||||
http.Error(w, "Gecersiz gorsel batch istegi: "+err.Error(), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
if len(req.Items) > 300 {
|
||||
req.Items = req.Items[:300]
|
||||
}
|
||||
|
||||
cleanItems := make([]ProductImageBatchItem, 0, len(req.Items))
|
||||
seen := map[string]bool{}
|
||||
for _, item := range req.Items {
|
||||
item.Key = strings.TrimSpace(item.Key)
|
||||
item.Code = strings.TrimSpace(item.Code)
|
||||
item.Dim1 = normalizeDimParam(item.Dim1)
|
||||
item.Dim3 = normalizeDimParam(item.Dim3)
|
||||
if item.Key == "" {
|
||||
item.Key = item.Code + "|" + item.Dim1 + "|" + item.Dim3
|
||||
}
|
||||
if item.Code == "" || seen[item.Key] {
|
||||
continue
|
||||
}
|
||||
seen[item.Key] = true
|
||||
cleanItems = append(cleanItems, item)
|
||||
}
|
||||
if len(cleanItems) == 0 {
|
||||
_ = json.NewEncoder(w).Encode([]ProductImageBatchResponseItem{})
|
||||
return
|
||||
}
|
||||
|
||||
payload, err := json.Marshal(cleanItems)
|
||||
if err != nil {
|
||||
http.Error(w, "Gorsel batch hazirlanamadi: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
rows, err := pg.Query(`
|
||||
WITH req AS (
|
||||
SELECT *
|
||||
FROM jsonb_to_recordset($1::jsonb) AS x(key text, code text, dim1 text, dim3 text)
|
||||
),
|
||||
matched_item AS (
|
||||
SELECT DISTINCT ON (r.key)
|
||||
r.key,
|
||||
r.code,
|
||||
r.dim1,
|
||||
r.dim3,
|
||||
m.id AS mmitem_id
|
||||
FROM req r
|
||||
JOIN mmitem m ON
|
||||
UPPER(REPLACE(COALESCE(m.code,''), ' ', '')) = UPPER(REPLACE(COALESCE(r.code,''), ' ', ''))
|
||||
OR UPPER(REPLACE(REGEXP_REPLACE(COALESCE(m.code,''), '^.*-', ''), ' ', '')) =
|
||||
UPPER(REPLACE(REGEXP_REPLACE(COALESCE(r.code,''), '^.*-', ''), ' ', ''))
|
||||
ORDER BY r.key, m.id
|
||||
),
|
||||
ranked AS (
|
||||
SELECT
|
||||
mi.key,
|
||||
b.id,
|
||||
COALESCE(b.file_name,'') AS file_name,
|
||||
COALESCE(b.file_size,0) AS file_size,
|
||||
COALESCE(b.storage_path,'') AS storage_path,
|
||||
ROW_NUMBER() OVER (
|
||||
PARTITION BY mi.key
|
||||
ORDER BY
|
||||
CASE
|
||||
WHEN mi.dim1 <> '' AND mi.dim3 <> '' AND COALESCE(b.dimval1::text,'') = mi.dim1 AND COALESCE(b.dimval3::text,'') = mi.dim3 THEN 0
|
||||
WHEN mi.dim1 <> '' AND COALESCE(b.dimval1::text,'') = mi.dim1 AND COALESCE(b.dimval3::text,'') = '' THEN 1
|
||||
WHEN COALESCE(b.dimval1::text,'') = '' AND COALESCE(b.dimval3::text,'') = '' THEN 2
|
||||
ELSE 3
|
||||
END,
|
||||
COALESCE(b.sort_order,999999),
|
||||
b.id
|
||||
) AS rn
|
||||
FROM matched_item mi
|
||||
JOIN dfblob b ON b.src_table='mmitem'
|
||||
AND b.typ='img'
|
||||
AND b.src_id=mi.mmitem_id
|
||||
)
|
||||
SELECT key, id, file_name, file_size, storage_path
|
||||
FROM ranked
|
||||
WHERE rn <= 1
|
||||
ORDER BY key, rn
|
||||
`, string(payload))
|
||||
if err != nil {
|
||||
slog.Error("product_images.batch.query_failed", "req_id", reqID, "err", err.Error())
|
||||
http.Error(w, "Gorsel batch sorgu hatasi: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
defer rows.Close()
|
||||
|
||||
grouped := map[string][]ProductImageItem{}
|
||||
for rows.Next() {
|
||||
var key string
|
||||
var it ProductImageItem
|
||||
if err := rows.Scan(&key, &it.ID, &it.FileName, &it.FileSize, &it.Storage); err != nil {
|
||||
continue
|
||||
}
|
||||
it.ContentURL = fmt.Sprintf("/api/product-images/%d/content", it.ID)
|
||||
if u := extractImageUUID(it.Storage, it.FileName); u != "" {
|
||||
it.UUID = u
|
||||
it.ThumbURL = "/uploads/image/t300/" + u + ".jpg"
|
||||
it.FullURL = "/uploads/image/" + u + ".jpg"
|
||||
}
|
||||
grouped[key] = append(grouped[key], it)
|
||||
}
|
||||
if err := rows.Err(); err != nil {
|
||||
http.Error(w, "Gorsel batch okuma hatasi: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
|
||||
out := make([]ProductImageBatchResponseItem, 0, len(cleanItems))
|
||||
for _, item := range cleanItems {
|
||||
out = append(out, ProductImageBatchResponseItem{
|
||||
Key: item.Key,
|
||||
Images: grouped[item.Key],
|
||||
})
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(out)
|
||||
}
|
||||
}
|
||||
|
||||
// GET /api/product-images?code=...&dim1=...&dim3=...
|
||||
func GetProductImagesHandler(pg *sql.DB) http.HandlerFunc {
|
||||
return func(w http.ResponseWriter, r *http.Request) {
|
||||
|
||||
@@ -261,6 +261,47 @@ func GetProductPerformanceSalesBreakdownHandler(pg *sql.DB) http.HandlerFunc {
|
||||
}
|
||||
}
|
||||
|
||||
func GetProductPerformanceGroupedHandler(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)
|
||||
ctx, cancel := context.WithTimeout(utils.ContextWithTraceID(r.Context(), traceID), 90*time.Second)
|
||||
defer cancel()
|
||||
|
||||
expanded := map[string]bool{}
|
||||
for _, key := range strings.Split(r.URL.Query().Get("expanded_keys"), ",") {
|
||||
key = strings.TrimSpace(key)
|
||||
if key != "" {
|
||||
expanded[key] = true
|
||||
}
|
||||
}
|
||||
|
||||
rows, err := queries.ListProductPerformanceGrouped(ctx, pg, queries.ProductPerformanceGroupedRequest{
|
||||
Mode: strings.TrimSpace(r.URL.Query().Get("mode")),
|
||||
GroupLevels: splitCSVQuery(r.URL.Query().Get("group_levels")),
|
||||
ExpandedKeys: expanded,
|
||||
Limit: intQuery(r, "limit", 50000),
|
||||
})
|
||||
if err != nil {
|
||||
http.Error(w, "urun performans grup verisi alinamadi: "+err.Error(), http.StatusInternalServerError)
|
||||
return
|
||||
}
|
||||
_ = json.NewEncoder(w).Encode(rows)
|
||||
}
|
||||
}
|
||||
|
||||
func splitCSVQuery(value string) []string {
|
||||
parts := strings.Split(value, ",")
|
||||
out := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "" {
|
||||
out = append(out, part)
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func intQuery(r *http.Request, key string, fallback int) int {
|
||||
raw := strings.TrimSpace(r.URL.Query().Get(key))
|
||||
if raw == "" {
|
||||
|
||||
Reference in New Issue
Block a user