7631 lines
293 KiB
Go
7631 lines
293 KiB
Go
package queries
|
|
|
|
import (
|
|
"bssapp-backend/db"
|
|
"bssapp-backend/models"
|
|
"context"
|
|
"database/sql"
|
|
"encoding/json"
|
|
"fmt"
|
|
"log"
|
|
"math"
|
|
"os"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/lib/pq"
|
|
)
|
|
|
|
type ProductPerformanceFilters struct {
|
|
Search string
|
|
ProductCode string
|
|
MarketKey string
|
|
Kategori string
|
|
Seri string
|
|
Bucket string
|
|
SortBy string
|
|
Descending bool
|
|
Limit int
|
|
Page int
|
|
}
|
|
|
|
type ProductPerformanceRefreshRequest struct {
|
|
Mode string
|
|
Stage string
|
|
ResumeAfter int
|
|
SkipDelete bool
|
|
ProductPrefix string
|
|
StartDate time.Time
|
|
EndDate time.Time
|
|
}
|
|
|
|
type ProductPerformanceRefreshResult struct {
|
|
Mode string `json:"mode"`
|
|
Stage string `json:"stage"`
|
|
StartDate string `json:"start_date"`
|
|
EndDate string `json:"end_date"`
|
|
SalesRows int `json:"sales_rows"`
|
|
StockRows int `json:"stock_rows"`
|
|
KpiRows int `json:"kpi_rows"`
|
|
SnapshotRows int `json:"snapshot_rows"`
|
|
DurationMS int64 `json:"duration_ms"`
|
|
}
|
|
|
|
type productPerformanceSnapshotBypassKey struct{}
|
|
|
|
func productPerformanceStockQuery() string {
|
|
return `
|
|
;WITH ActiveWarehouses AS (
|
|
SELECT WarehouseCode
|
|
FROM (VALUES
|
|
('1-0-14'),('1-0-10'),('1-0-8'),('1-2-5'),('1-2-4'),('1-0-12'),('100'),('1-0-28'),
|
|
('1-0-24'),('1-2-6'),('1-1-14'),('1-0-2'),('1-0-52'),('1-1-2'),('1-0-21'),('1-1-3'),
|
|
('1-0-33'),('101'),('1-014'),('1-0-49'),('1-0-36'),('1-0-4'),('1-0-29')
|
|
) W(WarehouseCode)
|
|
),
|
|
Raw AS (
|
|
SELECT
|
|
ProductCode = UPPER(LTRIM(RTRIM(S.ItemCode))),
|
|
ColorCode = UPPER(LTRIM(RTRIM(ISNULL(S.ColorCode, '')))),
|
|
YakaKodu = UPPER(LTRIM(RTRIM(ISNULL(S.ItemDim2Code, '')))),
|
|
MovementDate = CAST(S.DocumentDate AS date),
|
|
ProcessCode = CASE
|
|
WHEN LTRIM(RTRIM(ISNULL(S.ProcessCode, ''))) <> '' THEN LTRIM(RTRIM(S.ProcessCode))
|
|
ELSE LTRIM(RTRIM(ISNULL(S.InnerProcessCode, '')))
|
|
END,
|
|
InQty = SUM(S.In_Qty1),
|
|
OutQty = SUM(S.Out_Qty1),
|
|
NetQty = SUM(S.In_Qty1 - S.Out_Qty1)
|
|
FROM trStock S WITH(NOLOCK)
|
|
INNER JOIN ActiveWarehouses W
|
|
ON W.WarehouseCode = LTRIM(RTRIM(S.WarehouseCode))
|
|
WHERE S.ItemTypeCode = 1
|
|
AND LEN(LTRIM(RTRIM(S.ItemCode))) = 13
|
|
AND (@p3 = '' OR UPPER(LTRIM(RTRIM(S.ItemCode))) LIKE @p3 + '%')
|
|
AND S.DocumentDate < DATEADD(DAY, 1, @p2)
|
|
GROUP BY
|
|
S.ItemCode,
|
|
S.ColorCode,
|
|
S.ItemDim2Code,
|
|
CAST(S.DocumentDate AS date),
|
|
CASE
|
|
WHEN LTRIM(RTRIM(ISNULL(S.ProcessCode, ''))) <> '' THEN LTRIM(RTRIM(S.ProcessCode))
|
|
ELSE LTRIM(RTRIM(ISNULL(S.InnerProcessCode, '')))
|
|
END
|
|
),
|
|
Opening AS (
|
|
SELECT
|
|
StockDate = @p1,
|
|
ProductCode,
|
|
ColorCode,
|
|
YakaKodu,
|
|
NetQty = SUM(NetQty),
|
|
InQty = CAST(0 AS decimal(18,4)),
|
|
OutQty = CAST(0 AS decimal(18,4)),
|
|
KpiInQty = CAST(0 AS decimal(18,4)),
|
|
KpiOutQty = CAST(0 AS decimal(18,4)),
|
|
SalesMovementQty = CAST(0 AS decimal(18,4)),
|
|
ProductionInQty = CAST(0 AS decimal(18,4)),
|
|
PurchaseInQty = CAST(0 AS decimal(18,4)),
|
|
ConsumptionOutQty = CAST(0 AS decimal(18,4)),
|
|
CountDiffQty = CAST(0 AS decimal(18,4))
|
|
FROM Raw
|
|
WHERE MovementDate < @p1
|
|
GROUP BY ProductCode, ColorCode, YakaKodu
|
|
),
|
|
Daily AS (
|
|
SELECT
|
|
StockDate = MovementDate,
|
|
ProductCode,
|
|
ColorCode,
|
|
YakaKodu,
|
|
NetQty = SUM(NetQty),
|
|
InQty = SUM(InQty),
|
|
OutQty = SUM(OutQty),
|
|
KpiInQty = SUM(CASE WHEN ProcessCode IN ('OP','BP','CI') THEN InQty ELSE 0 END),
|
|
KpiOutQty = SUM(CASE WHEN ProcessCode IN ('R','WS','OC','CO') THEN OutQty ELSE 0 END),
|
|
SalesMovementQty = SUM(CASE WHEN ProcessCode IN ('R','WS') THEN OutQty ELSE 0 END),
|
|
ProductionInQty = SUM(CASE WHEN ProcessCode = 'OP' THEN InQty ELSE 0 END),
|
|
PurchaseInQty = SUM(CASE WHEN ProcessCode = 'BP' THEN InQty ELSE 0 END),
|
|
ConsumptionOutQty = SUM(CASE WHEN ProcessCode = 'OC' THEN OutQty ELSE 0 END),
|
|
CountDiffQty = SUM(CASE WHEN ProcessCode IN ('CO','CI') THEN NetQty ELSE 0 END)
|
|
FROM Raw
|
|
WHERE MovementDate BETWEEN @p1 AND @p2
|
|
GROUP BY MovementDate, ProductCode, ColorCode, YakaKodu
|
|
),
|
|
Series AS (
|
|
SELECT * FROM Opening
|
|
UNION ALL
|
|
SELECT * FROM Daily
|
|
),
|
|
Collapsed AS (
|
|
SELECT
|
|
StockDate,
|
|
ProductCode,
|
|
ColorCode,
|
|
YakaKodu,
|
|
NetQty = SUM(NetQty),
|
|
InQty = SUM(InQty),
|
|
OutQty = SUM(OutQty),
|
|
KpiInQty = SUM(KpiInQty),
|
|
KpiOutQty = SUM(KpiOutQty),
|
|
SalesMovementQty = SUM(SalesMovementQty),
|
|
ProductionInQty = SUM(ProductionInQty),
|
|
PurchaseInQty = SUM(PurchaseInQty),
|
|
ConsumptionOutQty = SUM(ConsumptionOutQty),
|
|
CountDiffQty = SUM(CountDiffQty)
|
|
FROM Series
|
|
GROUP BY StockDate, ProductCode, ColorCode, YakaKodu
|
|
),
|
|
Running AS (
|
|
SELECT
|
|
StockDate,
|
|
ProductCode,
|
|
ColorCode,
|
|
YakaKodu,
|
|
StockQty = SUM(NetQty) OVER (
|
|
PARTITION BY ProductCode, ColorCode, YakaKodu
|
|
ORDER BY StockDate
|
|
ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW
|
|
),
|
|
InQty,
|
|
OutQty,
|
|
KpiInQty,
|
|
KpiOutQty,
|
|
SalesMovementQty,
|
|
ProductionInQty,
|
|
PurchaseInQty,
|
|
ConsumptionOutQty,
|
|
CountDiffQty
|
|
FROM Collapsed
|
|
),
|
|
CurrentSource AS (
|
|
SELECT
|
|
SourceTable = 'PickingStates',
|
|
WarehouseCode = LTRIM(RTRIM(P.WarehouseCode)),
|
|
ProductCode = UPPER(LTRIM(RTRIM(P.ItemCode))),
|
|
ColorCode = UPPER(LTRIM(RTRIM(ISNULL(P.ColorCode, '')))),
|
|
SizeCode = UPPER(LTRIM(RTRIM(ISNULL(P.ItemDim1Code, '')))),
|
|
YakaKodu = UPPER(LTRIM(RTRIM(ISNULL(P.ItemDim2Code, '')))),
|
|
Dim3Code = UPPER(LTRIM(RTRIM(ISNULL(P.ItemDim3Code, '')))),
|
|
PickingQty1 = SUM(P.Qty1),
|
|
ReserveQty1 = CAST(0 AS decimal(18,4)),
|
|
DispOrderQty1 = CAST(0 AS decimal(18,4)),
|
|
InventoryQty1 = CAST(0 AS decimal(18,4))
|
|
FROM PickingStates P WITH(NOLOCK)
|
|
INNER JOIN ActiveWarehouses W ON W.WarehouseCode = LTRIM(RTRIM(P.WarehouseCode))
|
|
WHERE P.ItemTypeCode = 1
|
|
AND LEN(LTRIM(RTRIM(P.ItemCode))) = 13
|
|
AND (@p3 = '' OR UPPER(LTRIM(RTRIM(P.ItemCode))) LIKE @p3 + '%')
|
|
GROUP BY P.WarehouseCode, P.ItemCode, P.ColorCode, P.ItemDim1Code, P.ItemDim2Code, P.ItemDim3Code
|
|
UNION ALL
|
|
SELECT
|
|
SourceTable = 'ReserveStates',
|
|
WarehouseCode = LTRIM(RTRIM(R.WarehouseCode)),
|
|
ProductCode = UPPER(LTRIM(RTRIM(R.ItemCode))),
|
|
ColorCode = UPPER(LTRIM(RTRIM(ISNULL(R.ColorCode, '')))),
|
|
SizeCode = UPPER(LTRIM(RTRIM(ISNULL(R.ItemDim1Code, '')))),
|
|
YakaKodu = UPPER(LTRIM(RTRIM(ISNULL(R.ItemDim2Code, '')))),
|
|
Dim3Code = UPPER(LTRIM(RTRIM(ISNULL(R.ItemDim3Code, '')))),
|
|
PickingQty1 = CAST(0 AS decimal(18,4)),
|
|
ReserveQty1 = SUM(R.Qty1),
|
|
DispOrderQty1 = CAST(0 AS decimal(18,4)),
|
|
InventoryQty1 = CAST(0 AS decimal(18,4))
|
|
FROM ReserveStates R WITH(NOLOCK)
|
|
INNER JOIN ActiveWarehouses W ON W.WarehouseCode = LTRIM(RTRIM(R.WarehouseCode))
|
|
WHERE R.ItemTypeCode = 1
|
|
AND LEN(LTRIM(RTRIM(R.ItemCode))) = 13
|
|
AND (@p3 = '' OR UPPER(LTRIM(RTRIM(R.ItemCode))) LIKE @p3 + '%')
|
|
GROUP BY R.WarehouseCode, R.ItemCode, R.ColorCode, R.ItemDim1Code, R.ItemDim2Code, R.ItemDim3Code
|
|
UNION ALL
|
|
SELECT
|
|
SourceTable = 'DispOrderStates',
|
|
WarehouseCode = LTRIM(RTRIM(D.WarehouseCode)),
|
|
ProductCode = UPPER(LTRIM(RTRIM(D.ItemCode))),
|
|
ColorCode = UPPER(LTRIM(RTRIM(ISNULL(D.ColorCode, '')))),
|
|
SizeCode = UPPER(LTRIM(RTRIM(ISNULL(D.ItemDim1Code, '')))),
|
|
YakaKodu = UPPER(LTRIM(RTRIM(ISNULL(D.ItemDim2Code, '')))),
|
|
Dim3Code = UPPER(LTRIM(RTRIM(ISNULL(D.ItemDim3Code, '')))),
|
|
PickingQty1 = CAST(0 AS decimal(18,4)),
|
|
ReserveQty1 = CAST(0 AS decimal(18,4)),
|
|
DispOrderQty1 = SUM(D.Qty1),
|
|
InventoryQty1 = CAST(0 AS decimal(18,4))
|
|
FROM DispOrderStates D WITH(NOLOCK)
|
|
INNER JOIN ActiveWarehouses W ON W.WarehouseCode = LTRIM(RTRIM(D.WarehouseCode))
|
|
WHERE D.ItemTypeCode = 1
|
|
AND LEN(LTRIM(RTRIM(D.ItemCode))) = 13
|
|
AND (@p3 = '' OR UPPER(LTRIM(RTRIM(D.ItemCode))) LIKE @p3 + '%')
|
|
GROUP BY D.WarehouseCode, D.ItemCode, D.ColorCode, D.ItemDim1Code, D.ItemDim2Code, D.ItemDim3Code
|
|
UNION ALL
|
|
SELECT
|
|
SourceTable = 'trStock',
|
|
WarehouseCode = LTRIM(RTRIM(S.WarehouseCode)),
|
|
ProductCode = UPPER(LTRIM(RTRIM(S.ItemCode))),
|
|
ColorCode = UPPER(LTRIM(RTRIM(ISNULL(S.ColorCode, '')))),
|
|
SizeCode = UPPER(LTRIM(RTRIM(ISNULL(S.ItemDim1Code, '')))),
|
|
YakaKodu = UPPER(LTRIM(RTRIM(ISNULL(S.ItemDim2Code, '')))),
|
|
Dim3Code = UPPER(LTRIM(RTRIM(ISNULL(S.ItemDim3Code, '')))),
|
|
PickingQty1 = CAST(0 AS decimal(18,4)),
|
|
ReserveQty1 = CAST(0 AS decimal(18,4)),
|
|
DispOrderQty1 = CAST(0 AS decimal(18,4)),
|
|
InventoryQty1 = SUM(S.In_Qty1 - S.Out_Qty1)
|
|
FROM trStock S WITH(NOLOCK)
|
|
INNER JOIN ActiveWarehouses W ON W.WarehouseCode = LTRIM(RTRIM(S.WarehouseCode))
|
|
WHERE S.ItemTypeCode = 1
|
|
AND LEN(LTRIM(RTRIM(S.ItemCode))) = 13
|
|
AND (@p3 = '' OR UPPER(LTRIM(RTRIM(S.ItemCode))) LIKE @p3 + '%')
|
|
AND S.DocumentDate < DATEADD(DAY, 1, @p2)
|
|
GROUP BY S.WarehouseCode, S.ItemCode, S.ColorCode, S.ItemDim1Code, S.ItemDim2Code, S.ItemDim3Code
|
|
),
|
|
CurrentInventory AS (
|
|
SELECT
|
|
ProductCode,
|
|
ColorCode,
|
|
SizeCode,
|
|
YakaKodu,
|
|
Dim3Code,
|
|
WarehouseCode,
|
|
PickingQty1 = SUM(PickingQty1),
|
|
ReserveQty1 = SUM(ReserveQty1),
|
|
DispOrderQty1 = SUM(DispOrderQty1),
|
|
InventoryQty1 = SUM(InventoryQty1)
|
|
FROM CurrentSource
|
|
GROUP BY WarehouseCode, ProductCode, ColorCode, SizeCode, YakaKodu, Dim3Code
|
|
),
|
|
CurrentAvailable AS (
|
|
SELECT
|
|
StockDate = @p2,
|
|
I.ProductCode,
|
|
I.ColorCode,
|
|
I.YakaKodu,
|
|
StockQty = CAST(ROUND(SUM(
|
|
ISNULL(I.InventoryQty1, 0)
|
|
- ISNULL(I.PickingQty1, 0)
|
|
- ISNULL(I.ReserveQty1, 0)
|
|
- ISNULL(I.DispOrderQty1, 0)),
|
|
2
|
|
) AS decimal(18,4)),
|
|
InQty = CAST(0 AS decimal(18,4)),
|
|
OutQty = CAST(0 AS decimal(18,4)),
|
|
KpiInQty = CAST(0 AS decimal(18,4)),
|
|
KpiOutQty = CAST(0 AS decimal(18,4)),
|
|
SalesMovementQty = CAST(0 AS decimal(18,4)),
|
|
ProductionInQty = CAST(0 AS decimal(18,4)),
|
|
PurchaseInQty = CAST(0 AS decimal(18,4)),
|
|
ConsumptionOutQty = CAST(0 AS decimal(18,4)),
|
|
CountDiffQty = CAST(0 AS decimal(18,4))
|
|
FROM CurrentInventory I
|
|
INNER JOIN cdItem WITH(NOLOCK)
|
|
ON cdItem.ItemTypeCode = 1
|
|
AND UPPER(LTRIM(RTRIM(cdItem.ItemCode))) = I.ProductCode
|
|
AND cdItem.IsBlocked = 0
|
|
WHERE I.InventoryQty1 >= 0
|
|
AND LEN(I.ProductCode) = 13
|
|
GROUP BY I.ProductCode, I.ColorCode, I.YakaKodu
|
|
)
|
|
SELECT
|
|
StockDate,
|
|
ProductCode,
|
|
ColorCode,
|
|
YakaKodu,
|
|
StockQty,
|
|
InQty,
|
|
OutQty,
|
|
KpiInQty,
|
|
KpiOutQty,
|
|
SalesMovementQty,
|
|
ProductionInQty,
|
|
PurchaseInQty,
|
|
ConsumptionOutQty,
|
|
CountDiffQty
|
|
FROM (
|
|
SELECT * FROM Running WHERE StockDate BETWEEN @p1 AND DATEADD(DAY, -1, @p2)
|
|
UNION ALL
|
|
SELECT * FROM CurrentAvailable
|
|
) X
|
|
ORDER BY StockDate, ProductCode, ColorCode, YakaKodu
|
|
`
|
|
}
|
|
|
|
func EnsureProductPerformanceTables(pg *sql.DB) error {
|
|
stmts := []string{
|
|
`
|
|
CREATE TABLE IF NOT EXISTS mk_product_performance_sales_daily (
|
|
sales_date DATE NOT NULL,
|
|
product_code TEXT NOT NULL,
|
|
color_code TEXT NOT NULL DEFAULT '',
|
|
yaka_kodu TEXT NOT NULL DEFAULT '',
|
|
item_description TEXT NOT NULL DEFAULT '',
|
|
kategori TEXT NOT NULL DEFAULT '',
|
|
seri TEXT NOT NULL DEFAULT '',
|
|
yas_grubu TEXT NOT NULL DEFAULT '',
|
|
askili_yan TEXT NOT NULL DEFAULT '',
|
|
urun_ilk_grubu TEXT NOT NULL DEFAULT '',
|
|
urun_ana_grubu TEXT NOT NULL DEFAULT '',
|
|
urun_alt_grubu TEXT NOT NULL DEFAULT '',
|
|
market_key TEXT NOT NULL DEFAULT '',
|
|
channel_code TEXT NOT NULL DEFAULT '',
|
|
customer_country TEXT NOT NULL DEFAULT '',
|
|
customer_segment TEXT NOT NULL DEFAULT '',
|
|
customer_code TEXT NOT NULL DEFAULT '',
|
|
customer_name TEXT NOT NULL DEFAULT '',
|
|
sales_qty NUMERIC(18,4) NOT NULL DEFAULT 0,
|
|
sales_tl NUMERIC(18,4) NOT NULL DEFAULT 0,
|
|
sales_usd NUMERIC(18,4) NOT NULL DEFAULT 0,
|
|
avg_price_usd NUMERIC(18,6) NOT NULL DEFAULT 0,
|
|
invoice_line_count INTEGER NOT NULL DEFAULT 0,
|
|
invoice_count INTEGER NOT NULL DEFAULT 0,
|
|
customer_count INTEGER NOT NULL DEFAULT 0,
|
|
last_ref_number TEXT NOT NULL DEFAULT '',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
CONSTRAINT pk_mk_product_performance_sales_daily PRIMARY KEY
|
|
(sales_date, product_code, color_code, yaka_kodu, market_key, customer_country, customer_segment, customer_code)
|
|
)`,
|
|
`ALTER TABLE mk_product_performance_sales_daily ADD COLUMN IF NOT EXISTS customer_code TEXT NOT NULL DEFAULT ''`,
|
|
`ALTER TABLE mk_product_performance_sales_daily ADD COLUMN IF NOT EXISTS customer_name TEXT NOT NULL DEFAULT ''`,
|
|
`ALTER TABLE mk_product_performance_sales_daily ADD COLUMN IF NOT EXISTS urun_ilk_grubu TEXT NOT NULL DEFAULT ''`,
|
|
`ALTER TABLE mk_product_performance_sales_daily ADD COLUMN IF NOT EXISTS urun_ana_grubu TEXT NOT NULL DEFAULT ''`,
|
|
`ALTER TABLE mk_product_performance_sales_daily ADD COLUMN IF NOT EXISTS urun_alt_grubu TEXT NOT NULL DEFAULT ''`,
|
|
`
|
|
UPDATE mk_product_performance_sales_daily
|
|
SET urun_ilk_grubu = '', updated_at = now()
|
|
WHERE btrim(urun_ilk_grubu) = '-'
|
|
OR upper(translate(btrim(urun_ilk_grubu), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON')`,
|
|
`
|
|
DELETE FROM mk_product_performance_sales_daily
|
|
WHERE upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')`,
|
|
`
|
|
UPDATE mk_product_performance_sales_daily
|
|
SET askili_yan = '', updated_at = now()
|
|
WHERE btrim(askili_yan) = '-'`,
|
|
`
|
|
DO $$
|
|
BEGIN
|
|
IF EXISTS (
|
|
SELECT 1
|
|
FROM pg_constraint
|
|
WHERE conname = 'pk_mk_product_performance_sales_daily'
|
|
AND conrelid = 'mk_product_performance_sales_daily'::regclass
|
|
) THEN
|
|
ALTER TABLE mk_product_performance_sales_daily DROP CONSTRAINT pk_mk_product_performance_sales_daily;
|
|
END IF;
|
|
ALTER TABLE mk_product_performance_sales_daily
|
|
ADD CONSTRAINT pk_mk_product_performance_sales_daily PRIMARY KEY
|
|
(sales_date, product_code, color_code, yaka_kodu, market_key, customer_country, customer_segment, customer_code);
|
|
END $$`,
|
|
`CREATE INDEX IF NOT EXISTS ix_mk_product_perf_sales_product_date ON mk_product_performance_sales_daily (product_code, sales_date DESC)`,
|
|
`CREATE INDEX IF NOT EXISTS ix_mk_product_perf_sales_market ON mk_product_performance_sales_daily (market_key, sales_date DESC)`,
|
|
`CREATE INDEX IF NOT EXISTS ix_mk_product_perf_sales_customer ON mk_product_performance_sales_daily (customer_code, sales_date DESC)`,
|
|
`
|
|
CREATE TABLE IF NOT EXISTS mk_product_performance_stock_daily (
|
|
stock_date DATE NOT NULL,
|
|
product_code TEXT NOT NULL,
|
|
color_code TEXT NOT NULL DEFAULT '',
|
|
yaka_kodu TEXT NOT NULL DEFAULT '',
|
|
stock_qty NUMERIC(18,4) NOT NULL DEFAULT 0,
|
|
in_qty NUMERIC(18,4) NOT NULL DEFAULT 0,
|
|
out_qty NUMERIC(18,4) NOT NULL DEFAULT 0,
|
|
kpi_in_qty NUMERIC(18,4) NOT NULL DEFAULT 0,
|
|
kpi_out_qty NUMERIC(18,4) NOT NULL DEFAULT 0,
|
|
sales_movement_qty NUMERIC(18,4) NOT NULL DEFAULT 0,
|
|
production_in_qty NUMERIC(18,4) NOT NULL DEFAULT 0,
|
|
purchase_in_qty NUMERIC(18,4) NOT NULL DEFAULT 0,
|
|
consumption_out_qty NUMERIC(18,4) NOT NULL DEFAULT 0,
|
|
count_diff_qty NUMERIC(18,4) NOT NULL DEFAULT 0,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
CONSTRAINT pk_mk_product_performance_stock_daily PRIMARY KEY
|
|
(stock_date, product_code, color_code, yaka_kodu)
|
|
)`,
|
|
`CREATE INDEX IF NOT EXISTS ix_mk_product_perf_stock_product_date ON mk_product_performance_stock_daily (product_code, stock_date DESC)`,
|
|
`
|
|
CREATE TABLE IF NOT EXISTS mk_product_performance_price_dim (
|
|
product_code TEXT PRIMARY KEY,
|
|
item_description TEXT NOT NULL DEFAULT '',
|
|
kategori TEXT NOT NULL DEFAULT '',
|
|
askili_yan TEXT NOT NULL DEFAULT '',
|
|
urun_ilk_grubu TEXT NOT NULL DEFAULT '',
|
|
urun_ana_grubu TEXT NOT NULL DEFAULT '',
|
|
urun_alt_grubu TEXT NOT NULL DEFAULT '',
|
|
cost_price_usd NUMERIC(18,6) NOT NULL DEFAULT 0,
|
|
base_price_usd NUMERIC(18,6) NOT NULL DEFAULT 0,
|
|
base_price_try NUMERIC(18,6) NOT NULL DEFAULT 0,
|
|
last_pricing_date DATE,
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
|
)`,
|
|
`ALTER TABLE mk_product_performance_price_dim ADD COLUMN IF NOT EXISTS item_description TEXT NOT NULL DEFAULT ''`,
|
|
`ALTER TABLE mk_product_performance_price_dim ADD COLUMN IF NOT EXISTS kategori TEXT NOT NULL DEFAULT ''`,
|
|
`ALTER TABLE mk_product_performance_price_dim ADD COLUMN IF NOT EXISTS askili_yan TEXT NOT NULL DEFAULT ''`,
|
|
`ALTER TABLE mk_product_performance_price_dim ADD COLUMN IF NOT EXISTS urun_ilk_grubu TEXT NOT NULL DEFAULT ''`,
|
|
`ALTER TABLE mk_product_performance_price_dim ADD COLUMN IF NOT EXISTS urun_ana_grubu TEXT NOT NULL DEFAULT ''`,
|
|
`ALTER TABLE mk_product_performance_price_dim ADD COLUMN IF NOT EXISTS urun_alt_grubu TEXT NOT NULL DEFAULT ''`,
|
|
`
|
|
UPDATE mk_product_performance_price_dim
|
|
SET
|
|
cost_price_usd = LEAST(cost_price_usd, base_price_usd),
|
|
base_price_usd = GREATEST(cost_price_usd, base_price_usd),
|
|
updated_at = now()
|
|
WHERE cost_price_usd > 0
|
|
AND base_price_usd > 0
|
|
AND cost_price_usd > base_price_usd`,
|
|
`
|
|
DELETE FROM mk_product_performance_price_dim
|
|
WHERE upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')`,
|
|
`
|
|
CREATE TABLE IF NOT EXISTS mk_product_performance_kpi_daily (
|
|
kpi_date DATE NOT NULL,
|
|
product_code TEXT NOT NULL,
|
|
color_code TEXT NOT NULL DEFAULT '',
|
|
yaka_kodu TEXT NOT NULL DEFAULT '',
|
|
item_description TEXT NOT NULL DEFAULT '',
|
|
kategori TEXT NOT NULL DEFAULT '',
|
|
seri TEXT NOT NULL DEFAULT '',
|
|
yas_grubu TEXT NOT NULL DEFAULT '',
|
|
askili_yan TEXT NOT NULL DEFAULT '',
|
|
urun_ilk_grubu TEXT NOT NULL DEFAULT '',
|
|
urun_ana_grubu TEXT NOT NULL DEFAULT '',
|
|
urun_alt_grubu TEXT NOT NULL DEFAULT '',
|
|
market_key TEXT NOT NULL DEFAULT '',
|
|
stock_qty NUMERIC(18,4) NOT NULL DEFAULT 0,
|
|
sales_qty_30d NUMERIC(18,4) NOT NULL DEFAULT 0,
|
|
sales_qty_90d NUMERIC(18,4) NOT NULL DEFAULT 0,
|
|
sales_qty_180d NUMERIC(18,4) NOT NULL DEFAULT 0,
|
|
sales_qty_365d NUMERIC(18,4) NOT NULL DEFAULT 0,
|
|
sales_qty_730d NUMERIC(18,4) NOT NULL DEFAULT 0,
|
|
sales_usd_30d NUMERIC(18,4) NOT NULL DEFAULT 0,
|
|
sales_usd_90d NUMERIC(18,4) NOT NULL DEFAULT 0,
|
|
sales_usd_180d NUMERIC(18,4) NOT NULL DEFAULT 0,
|
|
sales_usd_365d NUMERIC(18,4) NOT NULL DEFAULT 0,
|
|
avg_daily_sales_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
|
avg_daily_sales_180d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
|
avg_stock_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
|
avg_stock_180d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
|
avg_stock_365d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
|
avg_stock_total NUMERIC(18,6) NOT NULL DEFAULT 0,
|
|
stock_days_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
|
stock_days_180d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
|
avg_price_usd_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
|
avg_price_usd_180d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
|
cost_price_usd NUMERIC(18,6) NOT NULL DEFAULT 0,
|
|
base_price_usd NUMERIC(18,6) NOT NULL DEFAULT 0,
|
|
base_price_try NUMERIC(18,6) NOT NULL DEFAULT 0,
|
|
gross_profit_usd_90d NUMERIC(18,4) NOT NULL DEFAULT 0,
|
|
gross_profit_usd_180d NUMERIC(18,4) NOT NULL DEFAULT 0,
|
|
gross_margin_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
|
gross_margin_180d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
|
unit_profit_cost_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
|
unit_profit_cost_180d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
|
unit_profit_base_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
|
unit_profit_base_180d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
|
market_count_90d INTEGER NOT NULL DEFAULT 0,
|
|
market_count_180d INTEGER NOT NULL DEFAULT 0,
|
|
market_count_365d INTEGER NOT NULL DEFAULT 0,
|
|
market_count_total INTEGER NOT NULL DEFAULT 0,
|
|
customer_count_90d INTEGER NOT NULL DEFAULT 0,
|
|
customer_count_180d INTEGER NOT NULL DEFAULT 0,
|
|
customer_count_365d INTEGER NOT NULL DEFAULT 0,
|
|
customer_count_total INTEGER NOT NULL DEFAULT 0,
|
|
sales_index_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
|
sales_index_180d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
|
sales_index_365d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
|
sales_index_total NUMERIC(18,6) NOT NULL DEFAULT 0,
|
|
price_index_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
|
margin_index_90d NUMERIC(18,6) NOT NULL DEFAULT 0,
|
|
performance_score NUMERIC(18,6) NOT NULL DEFAULT 0,
|
|
performance_bucket TEXT NOT NULL DEFAULT '',
|
|
recommendation TEXT NOT NULL DEFAULT '',
|
|
last_sale_date DATE,
|
|
last_ref_number TEXT NOT NULL DEFAULT '',
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
CONSTRAINT pk_mk_product_performance_kpi_daily PRIMARY KEY
|
|
(kpi_date, product_code, color_code, yaka_kodu, market_key)
|
|
)`,
|
|
`CREATE INDEX IF NOT EXISTS ix_mk_product_perf_kpi_bucket ON mk_product_performance_kpi_daily (kpi_date DESC, performance_bucket)`,
|
|
`CREATE INDEX IF NOT EXISTS ix_mk_product_perf_kpi_market ON mk_product_performance_kpi_daily (kpi_date DESC, market_key, sales_index_90d DESC)`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS urun_ilk_grubu TEXT NOT NULL DEFAULT ''`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS urun_ana_grubu TEXT NOT NULL DEFAULT ''`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS urun_alt_grubu TEXT NOT NULL DEFAULT ''`,
|
|
`
|
|
UPDATE mk_product_performance_kpi_daily
|
|
SET urun_ilk_grubu = '', updated_at = now()
|
|
WHERE btrim(urun_ilk_grubu) = '-'
|
|
OR upper(translate(btrim(urun_ilk_grubu), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON')`,
|
|
`
|
|
DELETE FROM mk_product_performance_kpi_daily
|
|
WHERE upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')`,
|
|
`
|
|
UPDATE mk_product_performance_kpi_daily
|
|
SET
|
|
cost_price_usd = LEAST(cost_price_usd, base_price_usd),
|
|
base_price_usd = GREATEST(cost_price_usd, base_price_usd),
|
|
gross_profit_usd_90d = COALESCE(sales_usd_90d,0) - (COALESCE(sales_qty_90d,0) * LEAST(cost_price_usd, base_price_usd)),
|
|
gross_profit_usd_180d = COALESCE(sales_usd_180d,0) - (COALESCE(sales_qty_180d,0) * LEAST(cost_price_usd, base_price_usd)),
|
|
gross_margin_90d = CASE WHEN COALESCE(sales_usd_90d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_90d,0) - (COALESCE(sales_qty_90d,0) * LEAST(cost_price_usd, base_price_usd))) / NULLIF(sales_usd_90d,0) END,
|
|
gross_margin_180d = CASE WHEN COALESCE(sales_usd_180d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_180d,0) - (COALESCE(sales_qty_180d,0) * LEAST(cost_price_usd, base_price_usd))) / NULLIF(sales_usd_180d,0) END,
|
|
unit_profit_cost_90d = CASE WHEN COALESCE(sales_qty_90d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_90d,0) / NULLIF(sales_qty_90d,0)) - LEAST(cost_price_usd, base_price_usd) END,
|
|
unit_profit_cost_180d = CASE WHEN COALESCE(sales_qty_180d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_180d,0) / NULLIF(sales_qty_180d,0)) - LEAST(cost_price_usd, base_price_usd) END,
|
|
unit_profit_base_90d = CASE WHEN COALESCE(sales_qty_90d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_90d,0) / NULLIF(sales_qty_90d,0)) - GREATEST(cost_price_usd, base_price_usd) END,
|
|
unit_profit_base_180d = CASE WHEN COALESCE(sales_qty_180d,0) = 0 THEN 0 ELSE (COALESCE(sales_usd_180d,0) / NULLIF(sales_qty_180d,0)) - GREATEST(cost_price_usd, base_price_usd) END,
|
|
updated_at = now()
|
|
WHERE cost_price_usd > 0
|
|
AND base_price_usd > 0
|
|
AND cost_price_usd > base_price_usd`,
|
|
`
|
|
UPDATE mk_product_performance_kpi_daily
|
|
SET askili_yan = '', updated_at = now()
|
|
WHERE btrim(askili_yan) = '-'`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS sales_qty_180d NUMERIC(18,4) NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS sales_qty_total NUMERIC(18,4) NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS sales_usd_180d NUMERIC(18,4) NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS sales_usd_total NUMERIC(18,4) NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS avg_daily_sales_180d NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS avg_daily_sales_365d NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS avg_daily_sales_total NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS avg_stock_90d NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS avg_stock_180d NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS avg_stock_365d NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS avg_stock_total NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS stock_days_180d NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS stock_days_365d NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS stock_days_total NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS stock_turnover_90d NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS stock_turnover_180d NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS stock_turnover_365d NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS stock_turnover_total NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS avg_price_usd_180d NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS gross_profit_usd_180d NUMERIC(18,4) NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS gross_margin_180d NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS unit_profit_cost_90d NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS unit_profit_cost_180d NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS unit_profit_base_90d NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS unit_profit_base_180d NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS market_count_90d INTEGER NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS market_count_180d INTEGER NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS market_count_365d INTEGER NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS market_count_total INTEGER NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS customer_count_180d INTEGER NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS customer_count_365d INTEGER NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS customer_count_total INTEGER NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS sales_index_180d NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS sales_index_365d NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
|
`ALTER TABLE mk_product_performance_kpi_daily ADD COLUMN IF NOT EXISTS sales_index_total NUMERIC(18,6) NOT NULL DEFAULT 0`,
|
|
`
|
|
CREATE TABLE IF NOT EXISTS mk_product_performance_report_snapshot (
|
|
report_key TEXT NOT NULL,
|
|
row_order INTEGER NOT NULL,
|
|
payload JSONB NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
CONSTRAINT pk_mk_product_performance_report_snapshot PRIMARY KEY (report_key, row_order)
|
|
)`,
|
|
`CREATE INDEX IF NOT EXISTS ix_mk_product_perf_report_snapshot_key ON mk_product_performance_report_snapshot (report_key, row_order)`,
|
|
`
|
|
CREATE TABLE IF NOT EXISTS mk_product_performance_report_snapshot_meta (
|
|
report_key TEXT PRIMARY KEY,
|
|
row_count INTEGER NOT NULL DEFAULT 0,
|
|
refreshed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
duration_ms BIGINT NOT NULL DEFAULT 0
|
|
)`,
|
|
`
|
|
CREATE TABLE IF NOT EXISTS mk_product_performance_grouped_snapshot (
|
|
report_key TEXT NOT NULL,
|
|
row_order INTEGER NOT NULL,
|
|
mode TEXT NOT NULL,
|
|
group_levels_key TEXT NOT NULL,
|
|
main_group TEXT NOT NULL DEFAULT '',
|
|
group_level INTEGER NOT NULL DEFAULT 0,
|
|
group_key TEXT NOT NULL DEFAULT '',
|
|
parent_key TEXT NOT NULL DEFAULT '',
|
|
group_field TEXT NOT NULL DEFAULT '',
|
|
group_value TEXT NOT NULL DEFAULT '',
|
|
payload JSONB NOT NULL,
|
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
updated_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
CONSTRAINT pk_mk_product_performance_grouped_snapshot PRIMARY KEY (report_key, row_order)
|
|
)`,
|
|
`CREATE INDEX IF NOT EXISTS ix_mk_product_perf_grouped_snapshot_key ON mk_product_performance_grouped_snapshot (report_key, row_order)`,
|
|
`CREATE INDEX IF NOT EXISTS ix_mk_product_perf_grouped_snapshot_level ON mk_product_performance_grouped_snapshot (report_key, group_level, row_order)`,
|
|
`CREATE INDEX IF NOT EXISTS ix_mk_product_perf_grouped_snapshot_field ON mk_product_performance_grouped_snapshot (report_key, group_field, group_value)`,
|
|
`
|
|
CREATE TABLE IF NOT EXISTS mk_product_performance_grouped_snapshot_meta (
|
|
report_key TEXT PRIMARY KEY,
|
|
mode TEXT NOT NULL,
|
|
group_levels_key TEXT NOT NULL,
|
|
main_group TEXT NOT NULL DEFAULT '',
|
|
row_count INTEGER NOT NULL DEFAULT 0,
|
|
refreshed_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
|
duration_ms BIGINT NOT NULL DEFAULT 0
|
|
)`,
|
|
}
|
|
for _, stmt := range stmts {
|
|
if _, err := pg.Exec(stmt); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func productPerformanceSnapshotBypass(ctx context.Context) context.Context {
|
|
return context.WithValue(ctx, productPerformanceSnapshotBypassKey{}, true)
|
|
}
|
|
|
|
func productPerformanceUseSnapshot(ctx context.Context) bool {
|
|
v, _ := ctx.Value(productPerformanceSnapshotBypassKey{}).(bool)
|
|
return !v
|
|
}
|
|
|
|
func productPerformanceLiveFallbackEnabled() bool {
|
|
raw := strings.TrimSpace(strings.ToLower(os.Getenv("PRODUCT_PERFORMANCE_LIVE_FALLBACK")))
|
|
return raw == "1" || raw == "true" || raw == "on" || raw == "yes"
|
|
}
|
|
|
|
func productPerformanceSnapshotKey(parts ...string) string {
|
|
cleaned := make([]string, 0, len(parts))
|
|
for _, part := range parts {
|
|
part = strings.ToLower(strings.TrimSpace(part))
|
|
if part != "" {
|
|
cleaned = append(cleaned, part)
|
|
}
|
|
}
|
|
return strings.Join(cleaned, ":")
|
|
}
|
|
|
|
func loadProductPerformanceSnapshotRows[T any](ctx context.Context, pg *sql.DB, reportKey string, limit int) ([]T, bool, error) {
|
|
if !productPerformanceUseSnapshot(ctx) || pg == nil || strings.TrimSpace(reportKey) == "" {
|
|
return nil, false, nil
|
|
}
|
|
if limit <= 0 || limit > 50000 {
|
|
limit = 50000
|
|
}
|
|
rows, err := pg.QueryContext(ctx, `
|
|
SELECT payload
|
|
FROM mk_product_performance_report_snapshot
|
|
WHERE report_key = $1
|
|
ORDER BY row_order
|
|
LIMIT $2
|
|
`, reportKey, limit)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := make([]T, 0, limit)
|
|
for rows.Next() {
|
|
var raw []byte
|
|
if err := rows.Scan(&raw); err != nil {
|
|
return nil, false, err
|
|
}
|
|
var item T
|
|
if err := json.Unmarshal(raw, &item); err != nil {
|
|
return nil, false, err
|
|
}
|
|
out = append(out, item)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, false, err
|
|
}
|
|
if len(out) > 0 {
|
|
return out, true, nil
|
|
}
|
|
var exists bool
|
|
if err := pg.QueryRowContext(ctx, `
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM mk_product_performance_report_snapshot_meta
|
|
WHERE report_key = $1
|
|
)
|
|
`, reportKey).Scan(&exists); err != nil {
|
|
return nil, false, err
|
|
}
|
|
if !exists && !productPerformanceLiveFallbackEnabled() {
|
|
return out, true, nil
|
|
}
|
|
return out, exists, nil
|
|
}
|
|
|
|
func loadProductPerformanceSnapshotMapRows(ctx context.Context, pg *sql.DB, reportKey string, limit int) ([]map[string]any, bool, error) {
|
|
if !productPerformanceUseSnapshot(ctx) || pg == nil || strings.TrimSpace(reportKey) == "" {
|
|
return nil, false, nil
|
|
}
|
|
if limit <= 0 || limit > 50000 {
|
|
limit = 50000
|
|
}
|
|
rows, err := pg.QueryContext(ctx, `
|
|
SELECT payload
|
|
FROM mk_product_performance_report_snapshot
|
|
WHERE report_key = $1
|
|
ORDER BY row_order
|
|
LIMIT $2
|
|
`, reportKey, limit)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := make([]map[string]any, 0, limit)
|
|
for rows.Next() {
|
|
var raw []byte
|
|
if err := rows.Scan(&raw); err != nil {
|
|
return nil, false, err
|
|
}
|
|
var item map[string]any
|
|
if err := json.Unmarshal(raw, &item); err != nil {
|
|
return nil, false, err
|
|
}
|
|
if item == nil {
|
|
item = map[string]any{}
|
|
}
|
|
out = append(out, item)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, false, err
|
|
}
|
|
if len(out) > 0 {
|
|
return out, true, nil
|
|
}
|
|
var exists bool
|
|
if err := pg.QueryRowContext(ctx, `
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM mk_product_performance_report_snapshot_meta
|
|
WHERE report_key = $1
|
|
)
|
|
`, reportKey).Scan(&exists); err != nil {
|
|
return nil, false, err
|
|
}
|
|
if !exists && !productPerformanceLiveFallbackEnabled() {
|
|
return out, true, nil
|
|
}
|
|
return out, exists, nil
|
|
}
|
|
|
|
func loadProductPerformanceSnapshotItem[T any](ctx context.Context, pg *sql.DB, reportKey string) (T, bool, error) {
|
|
var zero T
|
|
rows, ok, err := loadProductPerformanceSnapshotRows[T](ctx, pg, reportKey, 1)
|
|
if err != nil || !ok || len(rows) == 0 {
|
|
return zero, ok, err
|
|
}
|
|
return rows[0], true, nil
|
|
}
|
|
|
|
func saveProductPerformanceSnapshotRows[T any](ctx context.Context, pg *sql.DB, reportKey string, rows []T, started time.Time) error {
|
|
if pg == nil || strings.TrimSpace(reportKey) == "" {
|
|
return nil
|
|
}
|
|
tx, err := pg.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM mk_product_performance_report_snapshot WHERE report_key = $1`, reportKey); err != nil {
|
|
return err
|
|
}
|
|
stmt, err := tx.PrepareContext(ctx, `
|
|
INSERT INTO mk_product_performance_report_snapshot (report_key, row_order, payload, updated_at)
|
|
VALUES ($1, $2, $3, now())
|
|
`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer stmt.Close()
|
|
for i, row := range rows {
|
|
raw, err := json.Marshal(row)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := stmt.ExecContext(ctx, reportKey, i, raw); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO mk_product_performance_report_snapshot_meta (report_key, row_count, refreshed_at, duration_ms)
|
|
VALUES ($1, $2, now(), $3)
|
|
ON CONFLICT (report_key) DO UPDATE SET
|
|
row_count = EXCLUDED.row_count,
|
|
refreshed_at = EXCLUDED.refreshed_at,
|
|
duration_ms = EXCLUDED.duration_ms
|
|
`, reportKey, len(rows), time.Since(started).Milliseconds()); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func saveProductPerformanceGroupedSnapshotRows(ctx context.Context, pg *sql.DB, reportKey string, def productPerformanceGroupedSnapshotDefinition, rows []map[string]any, started time.Time) error {
|
|
if pg == nil || strings.TrimSpace(reportKey) == "" {
|
|
return nil
|
|
}
|
|
tx, err := pg.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
if _, err := tx.ExecContext(ctx, `DELETE FROM mk_product_performance_grouped_snapshot WHERE report_key = $1`, reportKey); err != nil {
|
|
return err
|
|
}
|
|
stmt, err := tx.PrepareContext(ctx, `
|
|
INSERT INTO mk_product_performance_grouped_snapshot (
|
|
report_key, row_order, mode, group_levels_key, main_group,
|
|
group_level, group_key, parent_key, group_field, group_value, payload, updated_at
|
|
)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,now())
|
|
`)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer stmt.Close()
|
|
|
|
levelsKey := productPerformanceGroupedLevelsKey(def.Levels)
|
|
for i, row := range rows {
|
|
raw, err := json.Marshal(row)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
key := stringFromMap(row, "key")
|
|
if _, err := stmt.ExecContext(
|
|
ctx,
|
|
reportKey,
|
|
i,
|
|
strings.TrimSpace(def.Mode),
|
|
levelsKey,
|
|
strings.TrimSpace(def.MainGroup),
|
|
intFromMap(row, "level"),
|
|
key,
|
|
productPerformanceParentGroupKey(key),
|
|
stringFromMap(row, "group_field"),
|
|
stringFromMap(row, "group_value"),
|
|
raw,
|
|
); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO mk_product_performance_grouped_snapshot_meta (
|
|
report_key, mode, group_levels_key, main_group, row_count, refreshed_at, duration_ms
|
|
)
|
|
VALUES ($1,$2,$3,$4,$5,now(),$6)
|
|
ON CONFLICT (report_key) DO UPDATE SET
|
|
mode = EXCLUDED.mode,
|
|
group_levels_key = EXCLUDED.group_levels_key,
|
|
main_group = EXCLUDED.main_group,
|
|
row_count = EXCLUDED.row_count,
|
|
refreshed_at = EXCLUDED.refreshed_at,
|
|
duration_ms = EXCLUDED.duration_ms
|
|
`, reportKey, strings.TrimSpace(def.Mode), levelsKey, strings.TrimSpace(def.MainGroup), len(rows), time.Since(started).Milliseconds()); err != nil {
|
|
return err
|
|
}
|
|
return tx.Commit()
|
|
}
|
|
|
|
func RebuildProductPerformanceReportSnapshots(ctx context.Context, pg *sql.DB) (int, error) {
|
|
if err := EnsureProductPerformanceTables(pg); err != nil {
|
|
return 0, err
|
|
}
|
|
snapshotReadCtx := ctx
|
|
ctx = productPerformanceSnapshotBypass(ctx)
|
|
total := 0
|
|
|
|
save := func(reportKey string, rows any, started time.Time) error {
|
|
rowCount := productPerformanceSnapshotPayloadLen(rows)
|
|
log.Printf("[ProductPerformanceRefresh] snapshot save start key=%s rows=%d", reportKey, rowCount)
|
|
defer func() {
|
|
log.Printf("[ProductPerformanceRefresh] snapshot save done key=%s rows=%d elapsed=%s", reportKey, rowCount, time.Since(started).Round(time.Second))
|
|
}()
|
|
switch v := rows.(type) {
|
|
case []models.ProductPerformanceSummary:
|
|
total += len(v)
|
|
return saveProductPerformanceSnapshotRows(ctx, pg, reportKey, v, started)
|
|
case []models.ProductPerformanceRow:
|
|
total += len(v)
|
|
return saveProductPerformanceSnapshotRows(ctx, pg, reportKey, v, started)
|
|
case []models.ProductPerformanceGeneralRow:
|
|
total += len(v)
|
|
return saveProductPerformanceSnapshotRows(ctx, pg, reportKey, v, started)
|
|
case []models.ProductPerformanceOrderAnalysisRow:
|
|
total += len(v)
|
|
return saveProductPerformanceSnapshotRows(ctx, pg, reportKey, v, started)
|
|
case []models.ProductPerformanceOrderGroupRow:
|
|
total += len(v)
|
|
return saveProductPerformanceSnapshotRows(ctx, pg, reportKey, v, started)
|
|
case []models.ProductPerformanceOrderProductCustomerRow:
|
|
total += len(v)
|
|
return saveProductPerformanceSnapshotRows(ctx, pg, reportKey, v, started)
|
|
case []models.ProductPerformanceOrderMarketDetailRow:
|
|
total += len(v)
|
|
return saveProductPerformanceSnapshotRows(ctx, pg, reportKey, v, started)
|
|
case []models.ProductPerformanceMarketRow:
|
|
total += len(v)
|
|
return saveProductPerformanceSnapshotRows(ctx, pg, reportKey, v, started)
|
|
case []models.ProductPerformanceCountryRow:
|
|
total += len(v)
|
|
return saveProductPerformanceSnapshotRows(ctx, pg, reportKey, v, started)
|
|
case []models.ProductPerformanceCustomerRow:
|
|
total += len(v)
|
|
return saveProductPerformanceSnapshotRows(ctx, pg, reportKey, v, started)
|
|
case []models.ProductPerformanceSalesBreakdownRow:
|
|
total += len(v)
|
|
return saveProductPerformanceSnapshotRows(ctx, pg, reportKey, v, started)
|
|
default:
|
|
return fmt.Errorf("unsupported product performance snapshot payload %s", reportKey)
|
|
}
|
|
}
|
|
|
|
started := time.Now()
|
|
log.Printf("[ProductPerformanceRefresh] snapshot build start key=%s", productPerformanceSnapshotKey("summary"))
|
|
if summary, err := GetProductPerformanceSummary(ctx, pg); err != nil {
|
|
return total, err
|
|
} else if err := save(productPerformanceSnapshotKey("summary"), []models.ProductPerformanceSummary{summary}, started); err != nil {
|
|
return total, err
|
|
}
|
|
started = time.Now()
|
|
log.Printf("[ProductPerformanceRefresh] snapshot build start key=%s", productPerformanceSnapshotKey("products"))
|
|
if rows, _, err := ListProductPerformance(ctx, pg, ProductPerformanceFilters{Limit: 50000, Page: 1, SortBy: "performance_score", Descending: true}); err != nil {
|
|
return total, err
|
|
} else if err := save(productPerformanceSnapshotKey("products"), rows, started); err != nil {
|
|
return total, err
|
|
}
|
|
started = time.Now()
|
|
log.Printf("[ProductPerformanceRefresh] snapshot build start key=%s", productPerformanceSnapshotKey("general"))
|
|
if rows, err := ListProductPerformanceGeneral(ctx, pg, 50000); err != nil {
|
|
return total, err
|
|
} else if err := save(productPerformanceSnapshotKey("general"), rows, started); err != nil {
|
|
return total, err
|
|
}
|
|
started = time.Now()
|
|
log.Printf("[ProductPerformanceRefresh] snapshot build start key=%s", productPerformanceSnapshotKey("orders"))
|
|
if rows, err := ListProductPerformanceOrderAnalysis(ctx, pg, 50000); err != nil {
|
|
return total, err
|
|
} else if err := save(productPerformanceSnapshotKey("orders"), rows, started); err != nil {
|
|
return total, err
|
|
}
|
|
for _, mode := range []string{"market", "customer"} {
|
|
started = time.Now()
|
|
log.Printf("[ProductPerformanceRefresh] snapshot build start key=%s", productPerformanceSnapshotKey("order-groups", mode))
|
|
rows, err := ListProductPerformanceOrderGroups(ctx, pg, mode, 50000)
|
|
if err != nil {
|
|
return total, err
|
|
}
|
|
if err := save(productPerformanceSnapshotKey("order-groups", mode), rows, started); err != nil {
|
|
return total, err
|
|
}
|
|
}
|
|
started = time.Now()
|
|
log.Printf("[ProductPerformanceRefresh] snapshot build start key=%s", productPerformanceSnapshotKey("order-product-customers"))
|
|
if rows, err := ListProductPerformanceOrderProductCustomers(ctx, pg, 50000); err != nil {
|
|
return total, err
|
|
} else if err := save(productPerformanceSnapshotKey("order-product-customers"), rows, started); err != nil {
|
|
return total, err
|
|
}
|
|
started = time.Now()
|
|
log.Printf("[ProductPerformanceRefresh] snapshot build start key=%s", productPerformanceSnapshotKey("order-market-details"))
|
|
if rows, err := ListProductPerformanceOrderMarketDetails(ctx, pg, 50000); err != nil {
|
|
return total, err
|
|
} else if err := save(productPerformanceSnapshotKey("order-market-details"), rows, started); err != nil {
|
|
return total, err
|
|
}
|
|
started = time.Now()
|
|
log.Printf("[ProductPerformanceRefresh] snapshot build start key=%s", productPerformanceSnapshotKey("markets"))
|
|
if rows, err := ListProductPerformanceMarkets(ctx, pg, 50000); err != nil {
|
|
return total, err
|
|
} else if err := save(productPerformanceSnapshotKey("markets"), rows, started); err != nil {
|
|
return total, err
|
|
}
|
|
started = time.Now()
|
|
log.Printf("[ProductPerformanceRefresh] snapshot build start key=%s", productPerformanceSnapshotKey("countries"))
|
|
if rows, err := ListProductPerformanceCountries(ctx, pg, 50000); err != nil {
|
|
return total, err
|
|
} else if err := save(productPerformanceSnapshotKey("countries"), rows, started); err != nil {
|
|
return total, err
|
|
}
|
|
for _, mode := range []string{"market_customer", "country_customer", "market_country_customer"} {
|
|
started = time.Now()
|
|
log.Printf("[ProductPerformanceRefresh] snapshot build start key=%s", productPerformanceSnapshotKey("customers", mode))
|
|
rows, err := ListProductPerformanceCustomers(ctx, pg, mode, 50000)
|
|
if err != nil {
|
|
return total, err
|
|
}
|
|
if err := save(productPerformanceSnapshotKey("customers", mode), rows, started); err != nil {
|
|
return total, err
|
|
}
|
|
}
|
|
for _, mode := range []string{"color_yaka_market_customer", "product_country_segment_market_customer", "market_customer_product", "country_segment_market_customer_product"} {
|
|
started = time.Now()
|
|
log.Printf("[ProductPerformanceRefresh] snapshot build start key=%s", productPerformanceSnapshotKey("sales-breakdown", mode))
|
|
rows, err := ListProductPerformanceSalesBreakdown(ctx, pg, mode, 50000)
|
|
if err != nil {
|
|
return total, err
|
|
}
|
|
if err := save(productPerformanceSnapshotKey("sales-breakdown", mode), rows, started); err != nil {
|
|
return total, err
|
|
}
|
|
}
|
|
log.Printf("[ProductPerformanceRefresh] grouped snapshots rebuild start")
|
|
groupedRows, err := RebuildProductPerformanceGroupedSnapshots(snapshotReadCtx, pg)
|
|
if err != nil {
|
|
return total, err
|
|
}
|
|
log.Printf("[ProductPerformanceRefresh] grouped snapshots rebuild done rows=%d", groupedRows)
|
|
total += groupedRows
|
|
return total, nil
|
|
}
|
|
|
|
func productPerformanceSnapshotPayloadLen(rows any) int {
|
|
switch v := rows.(type) {
|
|
case []models.ProductPerformanceSummary:
|
|
return len(v)
|
|
case []models.ProductPerformanceRow:
|
|
return len(v)
|
|
case []models.ProductPerformanceGeneralRow:
|
|
return len(v)
|
|
case []models.ProductPerformanceOrderAnalysisRow:
|
|
return len(v)
|
|
case []models.ProductPerformanceOrderGroupRow:
|
|
return len(v)
|
|
case []models.ProductPerformanceOrderProductCustomerRow:
|
|
return len(v)
|
|
case []models.ProductPerformanceOrderMarketDetailRow:
|
|
return len(v)
|
|
case []models.ProductPerformanceMarketRow:
|
|
return len(v)
|
|
case []models.ProductPerformanceCountryRow:
|
|
return len(v)
|
|
case []models.ProductPerformanceCustomerRow:
|
|
return len(v)
|
|
case []models.ProductPerformanceSalesBreakdownRow:
|
|
return len(v)
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
type productPerformanceGroupedSnapshotDefinition struct {
|
|
Mode string
|
|
Levels []string
|
|
MainGroup string
|
|
}
|
|
|
|
func RebuildProductPerformanceGroupedSnapshots(ctx context.Context, pg *sql.DB) (int, error) {
|
|
if err := EnsureProductPerformanceTables(pg); err != nil {
|
|
return 0, err
|
|
}
|
|
defs, err := productPerformanceGroupedSnapshotDefinitions(ctx, pg)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
log.Printf("[ProductPerformanceRefresh] grouped snapshot definitions ready count=%d", len(defs))
|
|
if err := deleteStaleProductPerformanceGroupedSnapshots(ctx, pg, productPerformanceSnapshotKey("grouped", "product_detail")+":%"); err != nil {
|
|
return 0, err
|
|
}
|
|
total := 0
|
|
for i, def := range defs {
|
|
started := time.Now()
|
|
levels := sanitizeProductPerformanceGroupLevels(def.Levels)
|
|
if len(levels) == 0 {
|
|
levels = defaultProductPerformanceGroupLevels(def.Mode)
|
|
}
|
|
reportKey := productPerformanceGroupedSnapshotReportKey(def.Mode, levels, def.MainGroup)
|
|
log.Printf("[ProductPerformanceRefresh] grouped snapshot start %d/%d key=%s mode=%s main_group=%s levels=%s", i+1, len(defs), reportKey, def.Mode, def.MainGroup, productPerformanceGroupedLevelsKey(levels))
|
|
sourceRows, err := productPerformanceGroupedRawSnapshotSourceRows(ctx, pg, def.Mode, 50000)
|
|
if err != nil {
|
|
return total, err
|
|
}
|
|
log.Printf("[ProductPerformanceRefresh] grouped snapshot source loaded key=%s source_rows=%d elapsed=%s", reportKey, len(sourceRows), time.Since(started).Round(time.Second))
|
|
req := ProductPerformanceGroupedRequest{
|
|
Mode: def.Mode,
|
|
MainGroup: def.MainGroup,
|
|
}
|
|
sourceRows = filterProductPerformanceGroupedRows(sourceRows, productPerformanceGroupedEffectiveFilters(req))
|
|
groupStarted := time.Now()
|
|
rows := buildProductPerformanceGroupedSnapshotRows(sourceRows, levels, def.Mode)
|
|
log.Printf("[ProductPerformanceRefresh] grouped snapshot tree built key=%s rows=%d elapsed=%s total_elapsed=%s", reportKey, len(rows), time.Since(groupStarted).Round(time.Second), time.Since(started).Round(time.Second))
|
|
if err := saveProductPerformanceGroupedSnapshotRows(ctx, pg, reportKey, productPerformanceGroupedSnapshotDefinition{
|
|
Mode: def.Mode,
|
|
Levels: levels,
|
|
MainGroup: def.MainGroup,
|
|
}, rows, started); err != nil {
|
|
return total, err
|
|
}
|
|
total += len(rows)
|
|
log.Printf("[ProductPerformanceRefresh] grouped snapshot done key=%s rows=%d cumulative_rows=%d elapsed=%s", reportKey, len(rows), total, time.Since(started).Round(time.Second))
|
|
}
|
|
return total, nil
|
|
}
|
|
|
|
func deleteStaleProductPerformanceGroupedSnapshots(ctx context.Context, pg *sql.DB, reportKeyLike string) error {
|
|
reportKeyLike = strings.TrimSpace(reportKeyLike)
|
|
if reportKeyLike == "" {
|
|
return nil
|
|
}
|
|
if _, err := pg.ExecContext(ctx, `DELETE FROM mk_product_performance_grouped_snapshot WHERE report_key LIKE $1`, reportKeyLike); err != nil {
|
|
return err
|
|
}
|
|
if _, err := pg.ExecContext(ctx, `DELETE FROM mk_product_performance_grouped_snapshot_meta WHERE report_key LIKE $1`, reportKeyLike); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func productPerformanceGroupedSnapshotDefinitions(ctx context.Context, pg *sql.DB) ([]productPerformanceGroupedSnapshotDefinition, error) {
|
|
_ = ctx
|
|
_ = pg
|
|
defs := []productPerformanceGroupedSnapshotDefinition{
|
|
{Mode: "products", Levels: []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka", "market_key"}},
|
|
{Mode: "products", Levels: []string{"urun_ana_grubu"}},
|
|
{Mode: "idle", Levels: []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"}},
|
|
{Mode: "sales_color_yaka_market_customer", Levels: []string{"color_yaka", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "country", "market_key", "customer_segment", "customer_code", "customer_name"}},
|
|
{Mode: "sales_product_country_segment_market_customer", Levels: []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka", "country", "customer_segment", "market_key", "customer_code", "customer_name"}},
|
|
{Mode: "sales_country_segment_market_customer_product", Levels: []string{"country", "customer_segment", "market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"}},
|
|
{Mode: "order_product_customers", Levels: []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka", "market_key", "customer_code", "customer_name"}},
|
|
{Mode: "order_market_details", Levels: []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"}},
|
|
}
|
|
return defs, nil
|
|
}
|
|
|
|
func RefreshProductPerformance(ctx context.Context, pg *sql.DB, req ProductPerformanceRefreshRequest) (ProductPerformanceRefreshResult, error) {
|
|
started := time.Now()
|
|
stage := productPerformanceRefreshStage(req.Stage)
|
|
productPrefix := productPerformanceProductPrefix(req.ProductPrefix)
|
|
log.Printf("[ProductPerformanceRefresh] start mode=%s stage=%s prefix=%s start=%s end=%s", req.Mode, stage, productPrefix, req.StartDate.Format("2006-01-02"), req.EndDate.Format("2006-01-02"))
|
|
if pg == nil {
|
|
return ProductPerformanceRefreshResult{}, fmt.Errorf("postgres db nil")
|
|
}
|
|
if db.MssqlDB == nil {
|
|
return ProductPerformanceRefreshResult{}, fmt.Errorf("mssql db nil")
|
|
}
|
|
log.Printf("[ProductPerformanceRefresh] ensure tables start")
|
|
if err := EnsureProductPerformanceTables(pg); err != nil {
|
|
return ProductPerformanceRefreshResult{}, err
|
|
}
|
|
log.Printf("[ProductPerformanceRefresh] ensure tables done")
|
|
mode := strings.ToLower(strings.TrimSpace(req.Mode))
|
|
if mode == "" {
|
|
mode = "delta"
|
|
}
|
|
if req.EndDate.IsZero() {
|
|
req.EndDate = time.Now()
|
|
}
|
|
if req.StartDate.IsZero() {
|
|
if mode == "full" {
|
|
req.StartDate = time.Date(2022, 1, 1, 0, 0, 0, 0, req.EndDate.Location())
|
|
} else {
|
|
req.StartDate = req.EndDate.AddDate(0, 0, -45)
|
|
}
|
|
}
|
|
req.StartDate = dateOnly(req.StartDate)
|
|
req.EndDate = dateOnly(req.EndDate)
|
|
shouldRun := func(name string) bool {
|
|
if stage == "all" {
|
|
return true
|
|
}
|
|
order := map[string]int{
|
|
"sales": 1,
|
|
"stock": 2,
|
|
"price": 3,
|
|
"kpi": 4,
|
|
}
|
|
return order[name] >= order[stage]
|
|
}
|
|
|
|
runStage := func(stage string, fn func(*sql.Tx) error) error {
|
|
log.Printf("[ProductPerformanceRefresh] %s tx begin", stage)
|
|
tx, err := pg.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer tx.Rollback()
|
|
if err := fn(tx); err != nil {
|
|
return err
|
|
}
|
|
log.Printf("[ProductPerformanceRefresh] %s tx commit start", stage)
|
|
if err := tx.Commit(); err != nil {
|
|
return err
|
|
}
|
|
log.Printf("[ProductPerformanceRefresh] %s tx commit done elapsed=%s", stage, time.Since(started).Round(time.Second))
|
|
return nil
|
|
}
|
|
|
|
var salesRows int
|
|
if shouldRun("sales") {
|
|
if err := runStage("sales", func(tx *sql.Tx) error {
|
|
log.Printf("[ProductPerformanceRefresh] delete sales cache start")
|
|
deleteSQL := `DELETE FROM mk_product_performance_sales_daily WHERE sales_date BETWEEN $1 AND $2`
|
|
deleteArgs := []any{req.StartDate, req.EndDate}
|
|
if productPrefix != "" {
|
|
deleteSQL += ` AND product_code ILIKE $3 || '%'`
|
|
deleteArgs = append(deleteArgs, productPrefix)
|
|
}
|
|
if _, err := tx.ExecContext(ctx, deleteSQL, deleteArgs...); err != nil {
|
|
return err
|
|
}
|
|
log.Printf("[ProductPerformanceRefresh] delete sales cache done")
|
|
log.Printf("[ProductPerformanceRefresh] sales refresh start")
|
|
rows, err := refreshProductPerformanceSales(ctx, tx, req.StartDate, req.EndDate, productPrefix)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
salesRows = rows
|
|
log.Printf("[ProductPerformanceRefresh] sales refresh done rows=%d elapsed=%s", salesRows, time.Since(started).Round(time.Second))
|
|
return nil
|
|
}); err != nil {
|
|
return ProductPerformanceRefreshResult{}, err
|
|
}
|
|
} else {
|
|
log.Printf("[ProductPerformanceRefresh] sales skipped stage=%s", stage)
|
|
}
|
|
|
|
var stockRows int
|
|
if shouldRun("stock") {
|
|
rows, err := refreshProductPerformanceStockChunked(ctx, pg, req.StartDate, req.EndDate, started, req.SkipDelete || req.ResumeAfter > 0, req.ResumeAfter, productPrefix)
|
|
if err != nil {
|
|
return ProductPerformanceRefreshResult{}, err
|
|
}
|
|
stockRows = rows
|
|
} else {
|
|
log.Printf("[ProductPerformanceRefresh] stock skipped stage=%s", stage)
|
|
}
|
|
|
|
if shouldRun("price") {
|
|
if err := runStage("price", func(tx *sql.Tx) error {
|
|
log.Printf("[ProductPerformanceRefresh] price refresh start")
|
|
if err := refreshProductPerformancePrices(ctx, tx, productPrefix); err != nil {
|
|
return err
|
|
}
|
|
log.Printf("[ProductPerformanceRefresh] price refresh done elapsed=%s", time.Since(started).Round(time.Second))
|
|
return nil
|
|
}); err != nil {
|
|
return ProductPerformanceRefreshResult{}, err
|
|
}
|
|
} else {
|
|
log.Printf("[ProductPerformanceRefresh] price skipped stage=%s", stage)
|
|
}
|
|
|
|
var kpiRows int
|
|
var snapshotRows int
|
|
if shouldRun("kpi") {
|
|
if err := runStage("kpi", func(tx *sql.Tx) error {
|
|
log.Printf("[ProductPerformanceRefresh] kpi rebuild start")
|
|
rows, err := RebuildProductPerformanceKPI(ctx, tx, req.EndDate)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
kpiRows = rows
|
|
log.Printf("[ProductPerformanceRefresh] kpi rebuild done rows=%d elapsed=%s", kpiRows, time.Since(started).Round(time.Second))
|
|
return nil
|
|
}); err != nil {
|
|
return ProductPerformanceRefreshResult{}, err
|
|
}
|
|
log.Printf("[ProductPerformanceRefresh] report snapshots rebuild start")
|
|
rows, err := RebuildProductPerformanceReportSnapshots(ctx, pg)
|
|
if err != nil {
|
|
return ProductPerformanceRefreshResult{}, err
|
|
}
|
|
snapshotRows = rows
|
|
log.Printf("[ProductPerformanceRefresh] report snapshots rebuild done rows=%d elapsed=%s", snapshotRows, time.Since(started).Round(time.Second))
|
|
} else {
|
|
log.Printf("[ProductPerformanceRefresh] kpi skipped stage=%s", stage)
|
|
}
|
|
|
|
log.Printf("[ProductPerformanceRefresh] refresh done total_elapsed=%s", time.Since(started).Round(time.Second))
|
|
return ProductPerformanceRefreshResult{
|
|
Mode: mode,
|
|
Stage: stage,
|
|
StartDate: req.StartDate.Format("2006-01-02"),
|
|
EndDate: req.EndDate.Format("2006-01-02"),
|
|
SalesRows: salesRows,
|
|
StockRows: stockRows,
|
|
KpiRows: kpiRows,
|
|
SnapshotRows: snapshotRows,
|
|
DurationMS: time.Since(started).Milliseconds(),
|
|
}, nil
|
|
}
|
|
|
|
func productPerformanceRefreshStage(raw string) string {
|
|
switch strings.ToLower(strings.TrimSpace(raw)) {
|
|
case "", "all", "full":
|
|
return "all"
|
|
case "sales", "stock", "price", "kpi":
|
|
return strings.ToLower(strings.TrimSpace(raw))
|
|
default:
|
|
return "all"
|
|
}
|
|
}
|
|
|
|
func productPerformanceProductPrefix(raw string) string {
|
|
return strings.ToUpper(strings.TrimSpace(raw))
|
|
}
|
|
|
|
func normalizeProductPerformanceProductCode(raw string) string {
|
|
return strings.ToUpper(strings.TrimSpace(raw))
|
|
}
|
|
|
|
func normalizeProductPerformanceCode(raw string) string {
|
|
return strings.ToUpper(strings.TrimSpace(raw))
|
|
}
|
|
|
|
func normalizeProductPerformanceVariantCodes(productCode, colorCode, yakaKodu *string) {
|
|
if productCode != nil {
|
|
*productCode = normalizeProductPerformanceProductCode(*productCode)
|
|
}
|
|
if colorCode != nil {
|
|
*colorCode = normalizeProductPerformanceCode(*colorCode)
|
|
}
|
|
if yakaKodu != nil {
|
|
*yakaKodu = normalizeProductPerformanceCode(*yakaKodu)
|
|
}
|
|
}
|
|
|
|
func productPerformanceColorDescriptions(ctx context.Context, colorCodes []string) map[string]string {
|
|
out := map[string]string{}
|
|
if db.MssqlDB == nil || len(colorCodes) == 0 {
|
|
return out
|
|
}
|
|
seen := map[string]bool{}
|
|
codes := make([]string, 0, len(colorCodes))
|
|
for _, raw := range colorCodes {
|
|
code := normalizeProductPerformanceCode(raw)
|
|
if code == "" || seen[code] {
|
|
continue
|
|
}
|
|
seen[code] = true
|
|
codes = append(codes, code)
|
|
}
|
|
const batchSize = 500
|
|
for start := 0; start < len(codes); start += batchSize {
|
|
end := start + batchSize
|
|
if end > len(codes) {
|
|
end = len(codes)
|
|
}
|
|
batch := codes[start:end]
|
|
placeholders := make([]string, len(batch))
|
|
args := make([]any, len(batch))
|
|
for i, code := range batch {
|
|
placeholders[i] = fmt.Sprintf("@p%d", i+1)
|
|
args[i] = code
|
|
}
|
|
query := fmt.Sprintf(`
|
|
SELECT
|
|
UPPER(LTRIM(RTRIM(ColorCode))) AS color_code,
|
|
MAX(LTRIM(RTRIM(ISNULL(ColorDescription, '')))) AS color_description
|
|
FROM cdColorDesc WITH(NOLOCK)
|
|
WHERE LangCode = 'TR'
|
|
AND UPPER(LTRIM(RTRIM(ColorCode))) IN (%s)
|
|
GROUP BY UPPER(LTRIM(RTRIM(ColorCode)))
|
|
`, strings.Join(placeholders, ","))
|
|
rows, err := db.MssqlDB.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
log.Printf("[ProductPerformance] color descriptions failed: %v", err)
|
|
return out
|
|
}
|
|
for rows.Next() {
|
|
var code, desc string
|
|
if err := rows.Scan(&code, &desc); err != nil {
|
|
rows.Close()
|
|
log.Printf("[ProductPerformance] color description scan failed: %v", err)
|
|
return out
|
|
}
|
|
out[normalizeProductPerformanceCode(code)] = strings.TrimSpace(desc)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
rows.Close()
|
|
log.Printf("[ProductPerformance] color description rows failed: %v", err)
|
|
return out
|
|
}
|
|
rows.Close()
|
|
}
|
|
return out
|
|
}
|
|
|
|
func refreshProductPerformanceSales(ctx context.Context, tx *sql.Tx, startDate, endDate time.Time, productPrefix string) (int, error) {
|
|
log.Printf("[ProductPerformanceRefresh] sales mssql query start start=%s end=%s prefix=%s", startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), productPrefix)
|
|
rows, err := db.MssqlDB.QueryContext(ctx, productPerformanceSalesSQL(), startDate, endDate, productPrefix)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer rows.Close()
|
|
log.Printf("[ProductPerformanceRefresh] sales mssql query returned, postgres insert start")
|
|
count := 0
|
|
for rows.Next() {
|
|
var r productPerformanceSalesDaily
|
|
if err := rows.Scan(
|
|
&r.SalesDate, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.ItemDescription,
|
|
&r.Kategori, &r.Seri, &r.YasGrubu, &r.AskiliYan, &r.UrunIlkGrubu, &r.UrunAnaGrubu, &r.UrunAltGrubu, &r.MarketKey, &r.ChannelCode,
|
|
&r.CustomerCountry, &r.CustomerSegment, &r.CustomerCode, &r.CustomerName, &r.SalesQty, &r.SalesTL, &r.SalesUSD,
|
|
&r.AvgPriceUSD, &r.InvoiceLineCount, &r.InvoiceCount, &r.CustomerCount, &r.LastRefNumber,
|
|
); err != nil {
|
|
return count, err
|
|
}
|
|
normalizeProductPerformanceVariantCodes(&r.ProductCode, &r.ColorCode, &r.YakaKodu)
|
|
r.AskiliYan = cleanProductPerformanceOptionalAttr(r.AskiliYan)
|
|
r.UrunIlkGrubu = cleanProductPerformanceFirstGroup(r.UrunIlkGrubu)
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO mk_product_performance_sales_daily (
|
|
sales_date, product_code, color_code, yaka_kodu, item_description,
|
|
kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, market_key, channel_code,
|
|
customer_country, customer_segment, customer_code, customer_name, sales_qty, sales_tl, sales_usd,
|
|
avg_price_usd, invoice_line_count, invoice_count, customer_count, last_ref_number, updated_at
|
|
) VALUES (
|
|
$1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,$15,$16,$17,$18,$19,$20,$21,$22,$23,$24,$25,$26,now()
|
|
)
|
|
ON CONFLICT (sales_date, product_code, color_code, yaka_kodu, market_key, customer_country, customer_segment, customer_code)
|
|
DO UPDATE SET
|
|
item_description=EXCLUDED.item_description,
|
|
kategori=EXCLUDED.kategori,
|
|
seri=EXCLUDED.seri,
|
|
yas_grubu=EXCLUDED.yas_grubu,
|
|
askili_yan=EXCLUDED.askili_yan,
|
|
urun_ilk_grubu=EXCLUDED.urun_ilk_grubu,
|
|
urun_ana_grubu=EXCLUDED.urun_ana_grubu,
|
|
urun_alt_grubu=EXCLUDED.urun_alt_grubu,
|
|
channel_code=EXCLUDED.channel_code,
|
|
customer_name=EXCLUDED.customer_name,
|
|
sales_qty=EXCLUDED.sales_qty,
|
|
sales_tl=EXCLUDED.sales_tl,
|
|
sales_usd=EXCLUDED.sales_usd,
|
|
avg_price_usd=EXCLUDED.avg_price_usd,
|
|
invoice_line_count=EXCLUDED.invoice_line_count,
|
|
invoice_count=EXCLUDED.invoice_count,
|
|
customer_count=EXCLUDED.customer_count,
|
|
last_ref_number=EXCLUDED.last_ref_number,
|
|
updated_at=now()
|
|
`, r.SalesDate, r.ProductCode, r.ColorCode, r.YakaKodu, r.ItemDescription,
|
|
r.Kategori, r.Seri, r.YasGrubu, r.AskiliYan, r.UrunIlkGrubu, r.UrunAnaGrubu, r.UrunAltGrubu, r.MarketKey, r.ChannelCode,
|
|
r.CustomerCountry, r.CustomerSegment, r.CustomerCode, r.CustomerName, r.SalesQty, r.SalesTL, r.SalesUSD,
|
|
r.AvgPriceUSD, r.InvoiceLineCount, r.InvoiceCount, r.CustomerCount, r.LastRefNumber); err != nil {
|
|
return count, err
|
|
}
|
|
count++
|
|
if count%5000 == 0 {
|
|
log.Printf("[ProductPerformanceRefresh] sales inserted rows=%d", count)
|
|
}
|
|
}
|
|
return count, rows.Err()
|
|
}
|
|
|
|
func refreshProductPerformanceStock(ctx context.Context, tx *sql.Tx, startDate, endDate time.Time, productPrefix string) (int, error) {
|
|
log.Printf("[ProductPerformanceRefresh] stock mssql query start start=%s end=%s prefix=%s", startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), productPrefix)
|
|
rows, err := db.MssqlDB.QueryContext(ctx, productPerformanceStockQuery(), startDate, endDate, productPrefix)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer rows.Close()
|
|
log.Printf("[ProductPerformanceRefresh] stock mssql query returned, postgres insert start")
|
|
count := 0
|
|
for rows.Next() {
|
|
var r productPerformanceStockDaily
|
|
if err := rows.Scan(
|
|
&r.StockDate, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.StockQty,
|
|
&r.InQty, &r.OutQty, &r.KpiInQty, &r.KpiOutQty, &r.SalesMovementQty,
|
|
&r.ProductionInQty, &r.PurchaseInQty, &r.ConsumptionOutQty, &r.CountDiffQty,
|
|
); err != nil {
|
|
return count, err
|
|
}
|
|
normalizeProductPerformanceVariantCodes(&r.ProductCode, &r.ColorCode, &r.YakaKodu)
|
|
if err := insertProductPerformanceStock(ctx, tx, r); err != nil {
|
|
return count, err
|
|
}
|
|
count++
|
|
if count%5000 == 0 {
|
|
log.Printf("[ProductPerformanceRefresh] stock inserted rows=%d", count)
|
|
}
|
|
}
|
|
return count, rows.Err()
|
|
}
|
|
|
|
func refreshProductPerformanceStockChunked(ctx context.Context, pg *sql.DB, startDate, endDate, started time.Time, skipDelete bool, resumeAfter int, productPrefix string) (int, error) {
|
|
if skipDelete {
|
|
log.Printf("[ProductPerformanceRefresh] stock delete skipped resume_after=%d", resumeAfter)
|
|
} else {
|
|
log.Printf("[ProductPerformanceRefresh] stock delete tx begin")
|
|
deleteTx, err := pg.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer deleteTx.Rollback()
|
|
log.Printf("[ProductPerformanceRefresh] delete stock cache start")
|
|
deleteSQL := `DELETE FROM mk_product_performance_stock_daily WHERE stock_date BETWEEN $1 AND $2`
|
|
deleteArgs := []any{startDate, endDate}
|
|
if productPrefix != "" {
|
|
deleteSQL += ` AND product_code ILIKE $3 || '%'`
|
|
deleteArgs = append(deleteArgs, productPrefix)
|
|
}
|
|
if _, err := deleteTx.ExecContext(ctx, deleteSQL, deleteArgs...); err != nil {
|
|
return 0, err
|
|
}
|
|
log.Printf("[ProductPerformanceRefresh] delete stock cache done")
|
|
log.Printf("[ProductPerformanceRefresh] stock delete tx commit start")
|
|
if err := deleteTx.Commit(); err != nil {
|
|
return 0, err
|
|
}
|
|
log.Printf("[ProductPerformanceRefresh] stock delete tx commit done elapsed=%s", time.Since(started).Round(time.Second))
|
|
}
|
|
|
|
log.Printf("[ProductPerformanceRefresh] stock refresh start")
|
|
log.Printf("[ProductPerformanceRefresh] stock mssql query start start=%s end=%s prefix=%s", startDate.Format("2006-01-02"), endDate.Format("2006-01-02"), productPrefix)
|
|
rows, err := db.MssqlDB.QueryContext(ctx, productPerformanceStockQuery(), startDate, endDate, productPrefix)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer rows.Close()
|
|
log.Printf("[ProductPerformanceRefresh] stock mssql query returned, postgres chunk upsert start")
|
|
|
|
const chunkSize = 5000
|
|
count := 0
|
|
seen := 0
|
|
chunkRows := 0
|
|
tx, err := pg.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
defer tx.Rollback()
|
|
|
|
for rows.Next() {
|
|
var r productPerformanceStockDaily
|
|
if err := rows.Scan(
|
|
&r.StockDate, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.StockQty,
|
|
&r.InQty, &r.OutQty, &r.KpiInQty, &r.KpiOutQty, &r.SalesMovementQty,
|
|
&r.ProductionInQty, &r.PurchaseInQty, &r.ConsumptionOutQty, &r.CountDiffQty,
|
|
); err != nil {
|
|
return count, err
|
|
}
|
|
normalizeProductPerformanceVariantCodes(&r.ProductCode, &r.ColorCode, &r.YakaKodu)
|
|
seen++
|
|
if resumeAfter > 0 && seen <= resumeAfter {
|
|
if seen%50000 == 0 || seen == resumeAfter {
|
|
log.Printf("[ProductPerformanceRefresh] stock resume skipped rows=%d", seen)
|
|
}
|
|
continue
|
|
}
|
|
if err := insertProductPerformanceStock(ctx, tx, r); err != nil {
|
|
return count, err
|
|
}
|
|
count++
|
|
chunkRows++
|
|
if chunkRows >= chunkSize {
|
|
log.Printf("[ProductPerformanceRefresh] stock chunk commit start upserted_rows=%d scanned_rows=%d", count, seen)
|
|
if err := tx.Commit(); err != nil {
|
|
return count, err
|
|
}
|
|
log.Printf("[ProductPerformanceRefresh] stock chunk commit done upserted_rows=%d scanned_rows=%d elapsed=%s", count, seen, time.Since(started).Round(time.Second))
|
|
tx, err = pg.BeginTx(ctx, nil)
|
|
if err != nil {
|
|
return count, err
|
|
}
|
|
chunkRows = 0
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return count, err
|
|
}
|
|
if chunkRows > 0 {
|
|
log.Printf("[ProductPerformanceRefresh] stock final chunk commit start upserted_rows=%d scanned_rows=%d", count, seen)
|
|
if err := tx.Commit(); err != nil {
|
|
return count, err
|
|
}
|
|
log.Printf("[ProductPerformanceRefresh] stock final chunk commit done upserted_rows=%d scanned_rows=%d elapsed=%s", count, seen, time.Since(started).Round(time.Second))
|
|
} else {
|
|
_ = tx.Rollback()
|
|
}
|
|
log.Printf("[ProductPerformanceRefresh] stock refresh done upserted_rows=%d scanned_rows=%d elapsed=%s", count, seen, time.Since(started).Round(time.Second))
|
|
return count, nil
|
|
}
|
|
|
|
type productPerformanceStockExec interface {
|
|
ExecContext(context.Context, string, ...any) (sql.Result, error)
|
|
}
|
|
|
|
func prepareProductPerformanceStockCopy(ctx context.Context, tx *sql.Tx) (*sql.Stmt, error) {
|
|
return tx.PrepareContext(ctx, pq.CopyIn(
|
|
"mk_product_performance_stock_daily",
|
|
"stock_date",
|
|
"product_code",
|
|
"color_code",
|
|
"yaka_kodu",
|
|
"stock_qty",
|
|
"in_qty",
|
|
"out_qty",
|
|
"kpi_in_qty",
|
|
"kpi_out_qty",
|
|
"sales_movement_qty",
|
|
"production_in_qty",
|
|
"purchase_in_qty",
|
|
"consumption_out_qty",
|
|
"count_diff_qty",
|
|
))
|
|
}
|
|
|
|
func insertProductPerformanceStock(ctx context.Context, exec productPerformanceStockExec, r productPerformanceStockDaily) error {
|
|
normalizeProductPerformanceVariantCodes(&r.ProductCode, &r.ColorCode, &r.YakaKodu)
|
|
_, err := exec.ExecContext(ctx, `
|
|
INSERT INTO mk_product_performance_stock_daily (
|
|
stock_date, product_code, color_code, yaka_kodu, stock_qty, in_qty, out_qty,
|
|
kpi_in_qty, kpi_out_qty, sales_movement_qty, production_in_qty, purchase_in_qty,
|
|
consumption_out_qty, count_diff_qty, updated_at
|
|
) VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,$13,$14,now())
|
|
ON CONFLICT (stock_date, product_code, color_code, yaka_kodu)
|
|
DO UPDATE SET
|
|
stock_qty=EXCLUDED.stock_qty,
|
|
in_qty=EXCLUDED.in_qty,
|
|
out_qty=EXCLUDED.out_qty,
|
|
kpi_in_qty=EXCLUDED.kpi_in_qty,
|
|
kpi_out_qty=EXCLUDED.kpi_out_qty,
|
|
sales_movement_qty=EXCLUDED.sales_movement_qty,
|
|
production_in_qty=EXCLUDED.production_in_qty,
|
|
purchase_in_qty=EXCLUDED.purchase_in_qty,
|
|
consumption_out_qty=EXCLUDED.consumption_out_qty,
|
|
count_diff_qty=EXCLUDED.count_diff_qty,
|
|
updated_at=now()
|
|
`, r.StockDate, r.ProductCode, r.ColorCode, r.YakaKodu, r.StockQty,
|
|
r.InQty, r.OutQty, r.KpiInQty, r.KpiOutQty, r.SalesMovementQty,
|
|
r.ProductionInQty, r.PurchaseInQty, r.ConsumptionOutQty, r.CountDiffQty)
|
|
return err
|
|
}
|
|
|
|
func refreshProductPerformancePrices(ctx context.Context, tx *sql.Tx, productPrefix string) error {
|
|
log.Printf("[ProductPerformanceRefresh] price mssql query start prefix=%s", productPrefix)
|
|
rows, err := db.MssqlDB.QueryContext(ctx, productPerformancePriceSQL(), productPrefix)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
defer rows.Close()
|
|
log.Printf("[ProductPerformanceRefresh] price mssql query returned, postgres upsert start")
|
|
count := 0
|
|
for rows.Next() {
|
|
var code string
|
|
var itemDescription, kategori, askiliYan, urunIlkGrubu, urunAnaGrubu, urunAltGrubu string
|
|
var cost, usd, tryPrice float64
|
|
var lastPricing sql.NullTime
|
|
if err := rows.Scan(&code, &itemDescription, &kategori, &askiliYan, &urunIlkGrubu, &urunAnaGrubu, &urunAltGrubu, &cost, &usd, &tryPrice, &lastPricing); err != nil {
|
|
return err
|
|
}
|
|
code = normalizeProductPerformanceProductCode(code)
|
|
askiliYan = cleanProductPerformanceOptionalAttr(askiliYan)
|
|
urunIlkGrubu = cleanProductPerformanceFirstGroup(urunIlkGrubu)
|
|
cost, usd = normalizeProductPerformanceCostPair(cost, usd)
|
|
var lp any
|
|
if lastPricing.Valid {
|
|
lp = lastPricing.Time
|
|
}
|
|
if _, err := tx.ExecContext(ctx, `
|
|
INSERT INTO mk_product_performance_price_dim (
|
|
product_code, item_description, kategori, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu,
|
|
cost_price_usd, base_price_usd, base_price_try, last_pricing_date, updated_at
|
|
)
|
|
VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,now())
|
|
ON CONFLICT (product_code) DO UPDATE SET
|
|
item_description=EXCLUDED.item_description,
|
|
kategori=EXCLUDED.kategori,
|
|
askili_yan=EXCLUDED.askili_yan,
|
|
urun_ilk_grubu=EXCLUDED.urun_ilk_grubu,
|
|
urun_ana_grubu=EXCLUDED.urun_ana_grubu,
|
|
urun_alt_grubu=EXCLUDED.urun_alt_grubu,
|
|
cost_price_usd=EXCLUDED.cost_price_usd,
|
|
base_price_usd=EXCLUDED.base_price_usd,
|
|
base_price_try=EXCLUDED.base_price_try,
|
|
last_pricing_date=EXCLUDED.last_pricing_date,
|
|
updated_at=now()
|
|
`, code, strings.TrimSpace(itemDescription), strings.TrimSpace(kategori), askiliYan, urunIlkGrubu, strings.TrimSpace(urunAnaGrubu), strings.TrimSpace(urunAltGrubu), cost, usd, tryPrice, lp); err != nil {
|
|
return err
|
|
}
|
|
count++
|
|
if count%5000 == 0 {
|
|
log.Printf("[ProductPerformanceRefresh] price upserted rows=%d", count)
|
|
}
|
|
}
|
|
log.Printf("[ProductPerformanceRefresh] price upsert done rows=%d", count)
|
|
return rows.Err()
|
|
}
|
|
|
|
func RebuildProductPerformanceKPI(ctx context.Context, exec interface {
|
|
ExecContext(context.Context, string, ...any) (sql.Result, error)
|
|
}, kpiDate time.Time) (int, error) {
|
|
kpiDate = dateOnly(kpiDate)
|
|
if _, err := exec.ExecContext(ctx, `DELETE FROM mk_product_performance_kpi_daily WHERE kpi_date=$1`, kpiDate); err != nil {
|
|
return 0, err
|
|
}
|
|
res, err := exec.ExecContext(ctx, productPerformanceKPISQL(), kpiDate)
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
if _, err := exec.ExecContext(ctx, `
|
|
UPDATE mk_product_performance_kpi_daily k
|
|
SET
|
|
item_description = CASE WHEN btrim(COALESCE(k.item_description,'')) = '' THEN COALESCE(NULLIF(p.item_description,''), k.item_description) ELSE k.item_description END,
|
|
kategori = CASE WHEN btrim(COALESCE(k.kategori,'')) = '' OR btrim(COALESCE(k.kategori,'')) = '-' THEN COALESCE(NULLIF(p.kategori,''), '') ELSE k.kategori END,
|
|
askili_yan = CASE WHEN btrim(COALESCE(k.askili_yan,'')) = '' OR btrim(COALESCE(k.askili_yan,'')) = '-' THEN COALESCE(NULLIF(NULLIF(p.askili_yan,''), '-'), '') ELSE k.askili_yan END,
|
|
urun_ilk_grubu = CASE WHEN btrim(COALESCE(k.urun_ilk_grubu,'')) = '' OR btrim(COALESCE(k.urun_ilk_grubu,'')) = '-' THEN COALESCE(NULLIF(p.urun_ilk_grubu,''), '') ELSE k.urun_ilk_grubu END,
|
|
urun_ana_grubu = CASE WHEN btrim(COALESCE(k.urun_ana_grubu,'')) = '' THEN COALESCE(NULLIF(p.urun_ana_grubu,''), k.urun_ana_grubu) ELSE k.urun_ana_grubu END,
|
|
urun_alt_grubu = CASE WHEN btrim(COALESCE(k.urun_alt_grubu,'')) = '' THEN COALESCE(NULLIF(p.urun_alt_grubu,''), k.urun_alt_grubu) ELSE k.urun_alt_grubu END,
|
|
updated_at = now()
|
|
FROM mk_product_performance_price_dim p
|
|
WHERE k.kpi_date = $1
|
|
AND p.product_code = k.product_code
|
|
AND (
|
|
btrim(COALESCE(k.item_description,'')) = ''
|
|
OR btrim(COALESCE(k.kategori,'')) IN ('', '-')
|
|
OR btrim(COALESCE(k.askili_yan,'')) IN ('', '-')
|
|
OR btrim(COALESCE(k.urun_ilk_grubu,'')) IN ('', '-')
|
|
OR btrim(COALESCE(k.urun_ana_grubu,'')) = ''
|
|
OR btrim(COALESCE(k.urun_alt_grubu,'')) = ''
|
|
)
|
|
`, kpiDate); err != nil {
|
|
return 0, err
|
|
}
|
|
n, _ := res.RowsAffected()
|
|
return int(n), nil
|
|
}
|
|
|
|
func ListProductPerformance(ctx context.Context, pg *sql.DB, f ProductPerformanceFilters) ([]models.ProductPerformanceRow, int, error) {
|
|
if err := EnsureProductPerformanceTables(pg); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
limit := f.Limit
|
|
if limit <= 0 {
|
|
limit = 50000
|
|
} else if limit > 50000 {
|
|
limit = 50000
|
|
}
|
|
page := f.Page
|
|
if page <= 0 {
|
|
page = 1
|
|
}
|
|
if !productPerformanceHasServerFilters(f) {
|
|
if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceRow](ctx, pg, productPerformanceSnapshotKey("products"), limit); err != nil {
|
|
return nil, 0, err
|
|
} else if ok {
|
|
applyProductPerformanceProductRowScores(rows)
|
|
return rows, len(rows), nil
|
|
}
|
|
}
|
|
where, args := productPerformanceWhere(f)
|
|
countQuery := `SELECT COUNT(*) FROM mk_product_performance_kpi_daily WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)` + where
|
|
var total int
|
|
if err := pg.QueryRowContext(ctx, countQuery, args...).Scan(&total); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
args = append(args, limit, (page-1)*limit)
|
|
orderBy := productPerformanceOrderBy(f.SortBy, f.Descending)
|
|
rows, err := pg.QueryContext(ctx, `
|
|
SELECT
|
|
to_char(kpi_date,'YYYY-MM-DD'), product_code, color_code, yaka_kodu, item_description,
|
|
kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, market_key, stock_qty,
|
|
sales_qty_30d, sales_qty_90d, sales_qty_180d, sales_qty_365d, sales_qty_730d, sales_qty_total,
|
|
sales_usd_30d, sales_usd_90d, sales_usd_180d, sales_usd_365d, sales_usd_total,
|
|
avg_daily_sales_90d, avg_daily_sales_180d, avg_daily_sales_365d, avg_daily_sales_total,
|
|
avg_stock_90d, avg_stock_180d, avg_stock_365d, avg_stock_total,
|
|
stock_days_90d, stock_days_180d, stock_days_365d, stock_days_total,
|
|
stock_turnover_90d, stock_turnover_180d, stock_turnover_365d, stock_turnover_total,
|
|
avg_price_usd_90d, avg_price_usd_180d, cost_price_usd, base_price_usd, base_price_try,
|
|
gross_profit_usd_90d, gross_profit_usd_180d, gross_margin_90d, gross_margin_180d,
|
|
unit_profit_cost_90d, unit_profit_cost_180d, unit_profit_base_90d, unit_profit_base_180d,
|
|
market_count_90d, market_count_180d, market_count_365d, market_count_total,
|
|
customer_count_90d, customer_count_180d, customer_count_365d, customer_count_total,
|
|
sales_index_90d, sales_index_180d, sales_index_365d, sales_index_total,
|
|
price_index_90d, margin_index_90d, performance_score, performance_bucket,
|
|
recommendation, COALESCE(to_char(last_sale_date,'YYYY-MM-DD'),''), last_ref_number,
|
|
to_char(updated_at,'YYYY-MM-DD HH24:MI:SS')
|
|
FROM mk_product_performance_kpi_daily
|
|
WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)`+where+`
|
|
ORDER BY `+orderBy+`
|
|
LIMIT $`+fmt.Sprint(len(args)-1)+` OFFSET $`+fmt.Sprint(len(args)), args...)
|
|
if err != nil {
|
|
return nil, 0, err
|
|
}
|
|
defer rows.Close()
|
|
out := make([]models.ProductPerformanceRow, 0, limit)
|
|
colorCodes := make([]string, 0, limit)
|
|
for rows.Next() {
|
|
var r models.ProductPerformanceRow
|
|
if err := rows.Scan(
|
|
&r.KpiDate, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.ItemDescription,
|
|
&r.Kategori, &r.Seri, &r.YasGrubu, &r.AskiliYan, &r.UrunIlkGrubu, &r.UrunAnaGrubu, &r.UrunAltGrubu, &r.MarketKey, &r.StockQty,
|
|
&r.SalesQty30, &r.SalesQty90, &r.SalesQty180, &r.SalesQty365, &r.SalesQty730, &r.SalesQtyTotal,
|
|
&r.SalesUSD30, &r.SalesUSD90, &r.SalesUSD180, &r.SalesUSD365, &r.SalesUSDTotal,
|
|
&r.AvgDailySales90, &r.AvgDailySales180, &r.AvgDailySales365, &r.AvgDailySalesTotal,
|
|
&r.AvgStock90, &r.AvgStock180, &r.AvgStock365, &r.AvgStockTotal,
|
|
&r.StockDays90, &r.StockDays180, &r.StockDays365, &r.StockDaysTotal,
|
|
&r.StockTurnover90, &r.StockTurnover180, &r.StockTurnover365, &r.StockTurnoverTotal,
|
|
&r.AvgPriceUSD90, &r.AvgPriceUSD180, &r.CostPriceUSD, &r.BasePriceUSD, &r.BasePriceTRY,
|
|
&r.GrossProfitUSD90, &r.GrossProfitUSD180, &r.GrossMargin90, &r.GrossMargin180,
|
|
&r.UnitProfitCost90, &r.UnitProfitCost180, &r.UnitProfitBase90, &r.UnitProfitBase180,
|
|
&r.MarketCount90, &r.MarketCount180, &r.MarketCount365, &r.MarketCountTotal,
|
|
&r.CustomerCount90, &r.CustomerCount180, &r.CustomerCount365, &r.CustomerCountTotal,
|
|
&r.SalesIndex90, &r.SalesIndex180, &r.SalesIndex365, &r.SalesIndexTotal,
|
|
&r.PriceIndex90, &r.MarginIndex90, &r.PerformanceScore, &r.PerformanceBucket,
|
|
&r.Recommendation, &r.LastSaleDate, &r.LastRefNumber, &r.UpdatedAt,
|
|
); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
r.UrunIlkGrubu = cleanProductPerformanceFirstGroup(r.UrunIlkGrubu)
|
|
colorCodes = append(colorCodes, r.ColorCode)
|
|
out = append(out, r)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, 0, err
|
|
}
|
|
colorDescriptions := productPerformanceColorDescriptions(ctx, colorCodes)
|
|
for i := range out {
|
|
out[i].ColorDescription = colorDescriptions[normalizeProductPerformanceCode(out[i].ColorCode)]
|
|
}
|
|
applyProductPerformanceProductRowScores(out)
|
|
return out, total, nil
|
|
}
|
|
|
|
func applyProductPerformanceProductRowScores(rows []models.ProductPerformanceRow) {
|
|
avg := productPerformanceProductRowAverages(rows)
|
|
for i := range rows {
|
|
rows[i].SalesIndex90 = productPerformanceRelativeIndex(rows[i].SalesUSD90, avg.salesUSD90)
|
|
rows[i].SalesIndex180 = productPerformanceRelativeIndex(rows[i].SalesUSD180, avg.salesUSD180)
|
|
rows[i].SalesIndex365 = productPerformanceRelativeIndex(rows[i].SalesUSD365, avg.salesUSD365)
|
|
rows[i].SalesIndexTotal = productPerformanceRelativeIndex(rows[i].SalesUSDTotal, avg.salesUSDTotal)
|
|
|
|
margin365 := productPerformanceMarginFromSales(rows[i].SalesUSD365, rows[i].SalesQty365, rows[i].CostPriceUSD)
|
|
marginTotal := productPerformanceMarginFromSales(rows[i].SalesUSDTotal, rows[i].SalesQtyTotal, rows[i].CostPriceUSD)
|
|
|
|
rows[i].PerformanceScore90 = productPerformanceProductScore(
|
|
"90d",
|
|
rows[i].SalesUSD90,
|
|
rows[i].SalesIndex90,
|
|
rows[i].GrossMargin90,
|
|
rows[i].StockTurnover90,
|
|
float64(rows[i].MarketCount90),
|
|
float64(rows[i].CustomerCount90),
|
|
)
|
|
rows[i].PerformanceScore180 = productPerformanceProductScore(
|
|
"180d",
|
|
rows[i].SalesUSD180,
|
|
rows[i].SalesIndex180,
|
|
rows[i].GrossMargin180,
|
|
rows[i].StockTurnover180,
|
|
float64(rows[i].MarketCount180),
|
|
float64(rows[i].CustomerCount180),
|
|
)
|
|
rows[i].PerformanceScore365 = productPerformanceProductScore(
|
|
"365d",
|
|
rows[i].SalesUSD365,
|
|
rows[i].SalesIndex365,
|
|
margin365,
|
|
rows[i].StockTurnover365,
|
|
float64(rows[i].MarketCount365),
|
|
float64(rows[i].CustomerCount365),
|
|
)
|
|
rows[i].PerformanceScoreTotal = productPerformanceProductScore(
|
|
"total",
|
|
rows[i].SalesUSDTotal,
|
|
rows[i].SalesIndexTotal,
|
|
marginTotal,
|
|
rows[i].StockTurnoverTotal,
|
|
float64(rows[i].MarketCountTotal),
|
|
float64(rows[i].CustomerCountTotal),
|
|
productPerformanceProductRowTotalPeriodDays(rows[i]),
|
|
)
|
|
rows[i].PerformanceScore = rows[i].PerformanceScore90
|
|
}
|
|
}
|
|
|
|
type productPerformanceProductRowAverage struct {
|
|
salesUSD90 float64
|
|
salesUSD180 float64
|
|
salesUSD365 float64
|
|
salesUSDTotal float64
|
|
}
|
|
|
|
func productPerformanceProductRowAverages(rows []models.ProductPerformanceRow) productPerformanceProductRowAverage {
|
|
var sum90, count90, sum180, count180, sum365, count365, sumTotal, countTotal float64
|
|
for _, row := range rows {
|
|
if row.SalesUSD90 > 0 {
|
|
sum90 += row.SalesUSD90
|
|
count90++
|
|
}
|
|
if row.SalesUSD180 > 0 {
|
|
sum180 += row.SalesUSD180
|
|
count180++
|
|
}
|
|
if row.SalesUSD365 > 0 {
|
|
sum365 += row.SalesUSD365
|
|
count365++
|
|
}
|
|
if row.SalesUSDTotal > 0 {
|
|
sumTotal += row.SalesUSDTotal
|
|
countTotal++
|
|
}
|
|
}
|
|
out := productPerformanceProductRowAverage{}
|
|
if count90 > 0 {
|
|
out.salesUSD90 = sum90 / count90
|
|
}
|
|
if count180 > 0 {
|
|
out.salesUSD180 = sum180 / count180
|
|
}
|
|
if count365 > 0 {
|
|
out.salesUSD365 = sum365 / count365
|
|
}
|
|
if countTotal > 0 {
|
|
out.salesUSDTotal = sumTotal / countTotal
|
|
}
|
|
return out
|
|
}
|
|
|
|
func productPerformanceProductRowTotalPeriodDays(row models.ProductPerformanceRow) float64 {
|
|
return productPerformancePeriodDays(map[string]any{"kpi_date": row.KpiDate}, "total")
|
|
}
|
|
|
|
func productPerformanceMarginFromSales(salesUSD, salesQty, unitCost float64) float64 {
|
|
if salesUSD <= 0 {
|
|
return 0
|
|
}
|
|
return (salesUSD - (salesQty * unitCost)) / salesUSD
|
|
}
|
|
|
|
func GetProductPerformanceSummary(ctx context.Context, pg *sql.DB) (models.ProductPerformanceSummary, error) {
|
|
if err := EnsureProductPerformanceTables(pg); err != nil {
|
|
return models.ProductPerformanceSummary{}, err
|
|
}
|
|
if summary, ok, err := loadProductPerformanceSnapshotItem[models.ProductPerformanceSummary](ctx, pg, productPerformanceSnapshotKey("summary")); err != nil {
|
|
return models.ProductPerformanceSummary{}, err
|
|
} else if ok {
|
|
return summary, nil
|
|
}
|
|
var s models.ProductPerformanceSummary
|
|
err := pg.QueryRowContext(ctx, `
|
|
WITH Latest AS (
|
|
SELECT MAX(kpi_date) AS kpi_date
|
|
FROM mk_product_performance_kpi_daily
|
|
),
|
|
KPI AS (
|
|
SELECT *
|
|
FROM mk_product_performance_kpi_daily
|
|
WHERE kpi_date = (SELECT kpi_date FROM Latest)
|
|
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
|
|
),
|
|
VariantStock AS (
|
|
SELECT
|
|
product_code,
|
|
color_code,
|
|
yaka_kodu,
|
|
MAX(stock_qty) AS stock_qty,
|
|
MAX(cost_price_usd) AS cost_price_usd,
|
|
BOOL_OR(performance_bucket IN ('STOK_RISKI','TAKIP')) AS has_risk_bucket,
|
|
SUM(COALESCE(sales_qty_90d,0)) AS sales_qty_90d
|
|
FROM KPI
|
|
GROUP BY product_code, color_code, yaka_kodu
|
|
),
|
|
StockTotals AS (
|
|
SELECT
|
|
COALESCE(SUM(stock_qty),0) AS total_stock,
|
|
COALESCE(SUM(stock_qty * cost_price_usd),0) AS stock_cost_usd,
|
|
COALESCE(SUM(CASE WHEN has_risk_bucket AND COALESCE(sales_qty_90d,0)=0 THEN stock_qty * cost_price_usd ELSE 0 END),0) AS risk_cost_usd
|
|
FROM VariantStock
|
|
),
|
|
Sales90 AS (
|
|
SELECT
|
|
COUNT(DISTINCT market_key) AS market_count_90d,
|
|
COUNT(DISTINCT NULLIF(customer_code, '-')) AS customer_count_90d
|
|
FROM mk_product_performance_sales_daily
|
|
WHERE sales_date BETWEEN (SELECT kpi_date FROM Latest) - INTERVAL '89 days' AND (SELECT kpi_date FROM Latest)
|
|
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
|
|
)
|
|
SELECT
|
|
COALESCE(to_char(MAX(kpi_date),'YYYY-MM-DD'),''),
|
|
COUNT(*),
|
|
COUNT(*) FILTER (WHERE performance_bucket='YILDIZ_URUN'),
|
|
COUNT(*) FILTER (WHERE performance_bucket='STOK_RISKI'),
|
|
COUNT(*) FILTER (WHERE performance_bucket='STOKSUZ_TALEP'),
|
|
COALESCE(MAX(StockTotals.total_stock),0),
|
|
COALESCE(MAX(StockTotals.stock_cost_usd),0),
|
|
COALESCE(MAX(StockTotals.risk_cost_usd),0),
|
|
COALESCE(SUM(sales_qty_90d),0),
|
|
COALESCE(SUM(sales_usd_90d),0),
|
|
COALESCE(SUM(gross_profit_usd_90d),0),
|
|
COALESCE(MAX(Sales90.market_count_90d),0)::integer,
|
|
COALESCE(MAX(Sales90.customer_count_90d),0)::integer,
|
|
COALESCE(to_char(MAX(updated_at),'YYYY-MM-DD HH24:MI:SS'),'')
|
|
FROM KPI
|
|
CROSS JOIN Sales90
|
|
CROSS JOIN StockTotals
|
|
`).Scan(&s.KpiDate, &s.TotalRows, &s.StarCount, &s.StockRisk, &s.NoStockDemand, &s.TotalStock, &s.StockCostUSD, &s.RiskCostUSD, &s.SalesQty90, &s.SalesUSD90, &s.GrossProfit90, &s.MarketCount90, &s.CustomerCount90, &s.UpdatedAt)
|
|
return s, err
|
|
}
|
|
|
|
func ListProductPerformanceGeneral(ctx context.Context, pg *sql.DB, limit int) ([]models.ProductPerformanceGeneralRow, error) {
|
|
if err := EnsureProductPerformanceTables(pg); err != nil {
|
|
return nil, err
|
|
}
|
|
if limit <= 0 {
|
|
limit = 50000
|
|
} else if limit > 50000 {
|
|
limit = 50000
|
|
}
|
|
if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceGeneralRow](ctx, pg, productPerformanceSnapshotKey("general"), limit); err != nil {
|
|
return nil, err
|
|
} else if ok {
|
|
applyProductPerformanceGeneralRowScores(rows)
|
|
return rows, nil
|
|
}
|
|
rows, err := pg.QueryContext(ctx, `
|
|
WITH Bounds AS (
|
|
SELECT
|
|
DATE '2022-01-01' AS period_start,
|
|
COALESCE(
|
|
(SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily),
|
|
(SELECT MAX(sales_date) FROM mk_product_performance_sales_daily),
|
|
current_date
|
|
) AS period_end
|
|
),
|
|
LatestStockDate AS (
|
|
SELECT MAX(stock_date) AS stock_date
|
|
FROM mk_product_performance_stock_daily
|
|
),
|
|
SalesAgg AS (
|
|
SELECT
|
|
product_code,
|
|
color_code,
|
|
yaka_kodu,
|
|
market_key,
|
|
MAX(item_description) AS item_description,
|
|
MAX(kategori) AS kategori,
|
|
MAX(seri) AS seri,
|
|
MAX(yas_grubu) AS yas_grubu,
|
|
COALESCE(MAX(NULLIF(askili_yan, '-')), '') AS askili_yan,
|
|
MAX(urun_ilk_grubu) AS urun_ilk_grubu,
|
|
MAX(urun_ana_grubu) AS urun_ana_grubu,
|
|
MAX(urun_alt_grubu) AS urun_alt_grubu,
|
|
MIN(sales_date) AS first_sale_date,
|
|
MAX(sales_date) AS last_sale_date,
|
|
MAX(last_ref_number) AS last_ref_number,
|
|
COALESCE(SUM(sales_qty),0) AS sales_qty_total,
|
|
COALESCE(SUM(sales_usd),0) AS sales_usd_total,
|
|
COALESCE(COUNT(DISTINCT NULLIF(customer_code, '-')) FILTER (WHERE COALESCE(sales_usd,0) > 0),0)::integer AS customer_count_total,
|
|
COALESCE(SUM(invoice_count),0)::integer AS invoice_count_total
|
|
FROM mk_product_performance_sales_daily, Bounds
|
|
WHERE sales_date BETWEEN Bounds.period_start AND Bounds.period_end
|
|
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
|
|
GROUP BY product_code, color_code, yaka_kodu, market_key
|
|
),
|
|
StockAgg AS (
|
|
SELECT
|
|
s.product_code,
|
|
s.color_code,
|
|
s.yaka_kodu,
|
|
COALESCE(SUM(s.stock_qty),0) AS stock_qty
|
|
FROM mk_product_performance_stock_daily s
|
|
WHERE s.stock_date = (SELECT stock_date FROM LatestStockDate)
|
|
GROUP BY s.product_code, s.color_code, s.yaka_kodu
|
|
),
|
|
StockTotalStart AS (
|
|
SELECT DISTINCT ON (s.product_code, s.color_code, s.yaka_kodu)
|
|
s.product_code,
|
|
s.color_code,
|
|
s.yaka_kodu,
|
|
s.stock_qty
|
|
FROM mk_product_performance_stock_daily s
|
|
WHERE s.stock_date <= DATE '2022-01-01'
|
|
ORDER BY s.product_code, s.color_code, s.yaka_kodu, s.stock_date DESC
|
|
),
|
|
Dim AS (
|
|
SELECT DISTINCT ON (product_code, color_code, yaka_kodu)
|
|
product_code,
|
|
color_code,
|
|
yaka_kodu,
|
|
item_description,
|
|
COALESCE(kategori,'') AS kategori,
|
|
seri,
|
|
yas_grubu,
|
|
CASE WHEN btrim(COALESCE(askili_yan,'')) = '-' THEN '' ELSE COALESCE(askili_yan,'') END AS askili_yan,
|
|
urun_ilk_grubu,
|
|
urun_ana_grubu,
|
|
urun_alt_grubu
|
|
FROM (
|
|
SELECT
|
|
product_code,
|
|
color_code,
|
|
yaka_kodu,
|
|
item_description,
|
|
kategori,
|
|
seri,
|
|
yas_grubu,
|
|
askili_yan,
|
|
urun_ilk_grubu,
|
|
urun_ana_grubu,
|
|
urun_alt_grubu,
|
|
1 AS src_rank
|
|
FROM mk_product_performance_kpi_daily
|
|
WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)
|
|
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
|
|
UNION ALL
|
|
SELECT
|
|
product_code,
|
|
color_code,
|
|
yaka_kodu,
|
|
MAX(item_description) AS item_description,
|
|
MAX(kategori) AS kategori,
|
|
MAX(seri) AS seri,
|
|
MAX(yas_grubu) AS yas_grubu,
|
|
COALESCE(MAX(NULLIF(askili_yan, '-')), '') AS askili_yan,
|
|
MAX(urun_ilk_grubu) AS urun_ilk_grubu,
|
|
MAX(urun_ana_grubu) AS urun_ana_grubu,
|
|
MAX(urun_alt_grubu) AS urun_alt_grubu,
|
|
2 AS src_rank
|
|
FROM mk_product_performance_sales_daily
|
|
WHERE upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
|
|
GROUP BY product_code, color_code, yaka_kodu
|
|
) x
|
|
ORDER BY product_code, color_code, yaka_kodu, src_rank
|
|
),
|
|
StockOnly AS (
|
|
SELECT
|
|
s.product_code,
|
|
s.color_code,
|
|
s.yaka_kodu,
|
|
'STOK'::text AS market_key,
|
|
COALESCE(MAX(d.item_description),'') AS item_description,
|
|
COALESCE(MAX(d.kategori),'') AS kategori,
|
|
COALESCE(MAX(d.seri),'') AS seri,
|
|
COALESCE(MAX(d.yas_grubu),'') AS yas_grubu,
|
|
COALESCE(MAX(NULLIF(d.askili_yan, '-')),'') AS askili_yan,
|
|
COALESCE(MAX(d.urun_ilk_grubu),'') AS urun_ilk_grubu,
|
|
COALESCE(MAX(d.urun_ana_grubu),'') AS urun_ana_grubu,
|
|
COALESCE(MAX(d.urun_alt_grubu),'') AS urun_alt_grubu,
|
|
NULL::date AS first_sale_date,
|
|
NULL::date AS last_sale_date,
|
|
''::text AS last_ref_number,
|
|
0::numeric AS sales_qty_total,
|
|
0::numeric AS sales_usd_total,
|
|
0::integer AS customer_count_total,
|
|
0::integer AS invoice_count_total
|
|
FROM mk_product_performance_stock_daily s
|
|
LEFT JOIN Dim d
|
|
ON d.product_code = s.product_code
|
|
AND d.color_code = s.color_code
|
|
AND d.yaka_kodu = s.yaka_kodu
|
|
WHERE s.stock_date = (SELECT stock_date FROM LatestStockDate)
|
|
AND upper(translate(btrim(COALESCE(d.urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
|
|
AND NOT EXISTS (
|
|
SELECT 1
|
|
FROM SalesAgg a
|
|
WHERE a.product_code = s.product_code
|
|
AND a.color_code = s.color_code
|
|
AND a.yaka_kodu = s.yaka_kodu
|
|
)
|
|
GROUP BY s.product_code, s.color_code, s.yaka_kodu
|
|
),
|
|
BaseRows AS (
|
|
SELECT * FROM SalesAgg
|
|
UNION ALL
|
|
SELECT * FROM StockOnly
|
|
),
|
|
Spread AS (
|
|
SELECT
|
|
product_code,
|
|
color_code,
|
|
yaka_kodu,
|
|
COUNT(DISTINCT NULLIF(market_key, 'STOK')) FILTER (WHERE COALESCE(sales_usd,0) > 0)::integer AS market_count_total,
|
|
COUNT(DISTINCT NULLIF(customer_code, '-')) FILTER (WHERE COALESCE(sales_usd,0) > 0)::integer AS customer_count_total_all
|
|
FROM mk_product_performance_sales_daily, Bounds
|
|
WHERE sales_date BETWEEN Bounds.period_start AND Bounds.period_end
|
|
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
|
|
GROUP BY product_code, color_code, yaka_kodu
|
|
),
|
|
Scored AS (
|
|
SELECT
|
|
Bounds.period_start,
|
|
Bounds.period_end,
|
|
b.product_code,
|
|
b.color_code,
|
|
b.yaka_kodu,
|
|
b.item_description,
|
|
b.kategori,
|
|
b.seri,
|
|
b.yas_grubu,
|
|
b.askili_yan,
|
|
b.urun_ilk_grubu,
|
|
b.urun_ana_grubu,
|
|
b.urun_alt_grubu,
|
|
b.market_key,
|
|
COALESCE(st.stock_qty, 0) AS stock_qty,
|
|
b.sales_qty_total,
|
|
b.sales_usd_total,
|
|
b.sales_qty_total / GREATEST(1, (Bounds.period_end - Bounds.period_start + 1)) AS avg_daily_sales_total,
|
|
CASE
|
|
WHEN b.sales_qty_total <= 0 THEN 9999
|
|
ELSE ((COALESCE(st_start.stock_qty, 0) + COALESCE(st.stock_qty, 0)) / 2.0) / NULLIF(b.sales_qty_total / GREATEST(1, (Bounds.period_end - Bounds.period_start + 1)), 0)
|
|
END AS stock_days_total,
|
|
CASE WHEN b.sales_qty_total <= 0 THEN 0 ELSE b.sales_usd_total / NULLIF(b.sales_qty_total,0) END AS avg_price_usd_total,
|
|
COALESCE(pd.cost_price_usd,0) AS cost_price_usd,
|
|
COALESCE(pd.base_price_usd,0) AS base_price_usd,
|
|
b.sales_usd_total - (b.sales_qty_total * COALESCE(pd.cost_price_usd,0)) AS gross_profit_usd_total,
|
|
CASE WHEN b.sales_usd_total <= 0 THEN 0 ELSE (b.sales_usd_total - (b.sales_qty_total * COALESCE(pd.cost_price_usd,0))) / NULLIF(b.sales_usd_total,0) END AS gross_margin_total,
|
|
CASE WHEN b.sales_qty_total <= 0 THEN 0 ELSE (b.sales_usd_total / NULLIF(b.sales_qty_total,0)) - COALESCE(pd.cost_price_usd,0) END AS unit_profit_cost_total,
|
|
CASE WHEN b.sales_qty_total <= 0 THEN 0 ELSE (b.sales_usd_total / NULLIF(b.sales_qty_total,0)) - COALESCE(pd.base_price_usd,0) END AS unit_profit_base_total,
|
|
CASE WHEN b.market_key <> 'STOK' AND b.sales_usd_total > 0 THEN 1 ELSE 0 END AS market_count_total,
|
|
b.customer_count_total AS customer_count_total,
|
|
b.invoice_count_total,
|
|
CASE
|
|
WHEN AVG(b.sales_qty_total) OVER (PARTITION BY b.market_key, b.kategori, b.urun_ana_grubu) <= 0 THEN 0
|
|
ELSE b.sales_qty_total / NULLIF(AVG(b.sales_qty_total) OVER (PARTITION BY b.market_key, b.kategori, b.urun_ana_grubu),0)
|
|
END AS sales_index_total,
|
|
b.first_sale_date,
|
|
b.last_sale_date,
|
|
b.last_ref_number
|
|
FROM BaseRows b
|
|
CROSS JOIN Bounds
|
|
LEFT JOIN StockAgg st
|
|
ON st.product_code = b.product_code
|
|
AND st.color_code = b.color_code
|
|
AND st.yaka_kodu = b.yaka_kodu
|
|
LEFT JOIN StockTotalStart st_start
|
|
ON st_start.product_code = b.product_code
|
|
AND st_start.color_code = b.color_code
|
|
AND st_start.yaka_kodu = b.yaka_kodu
|
|
LEFT JOIN mk_product_performance_price_dim pd ON pd.product_code = b.product_code
|
|
LEFT JOIN Spread sp
|
|
ON sp.product_code = b.product_code
|
|
AND sp.color_code = b.color_code
|
|
AND sp.yaka_kodu = b.yaka_kodu
|
|
)
|
|
SELECT
|
|
to_char(period_start,'YYYY-MM-DD'),
|
|
to_char(period_end,'YYYY-MM-DD'),
|
|
product_code, color_code, yaka_kodu, item_description,
|
|
kategori, seri, yas_grubu, askili_yan, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, market_key,
|
|
stock_qty, sales_qty_total, sales_usd_total, avg_daily_sales_total, stock_days_total, avg_price_usd_total,
|
|
cost_price_usd, base_price_usd, gross_profit_usd_total, gross_margin_total, unit_profit_cost_total, unit_profit_base_total,
|
|
market_count_total, customer_count_total, invoice_count_total, sales_index_total,
|
|
ROUND((
|
|
LEAST(35, GREATEST(0, sales_index_total) * 18)
|
|
+ LEAST(25, GREATEST(0, gross_margin_total) * 50)
|
|
+ LEAST(20, customer_count_total * 1.5)
|
|
+ LEAST(10, market_count_total * 2.5)
|
|
+ CASE
|
|
WHEN sales_qty_total > 0 AND stock_days_total BETWEEN 20 AND 180 THEN 10
|
|
WHEN sales_qty_total > 0 AND stock_days_total > 365 THEN -10
|
|
WHEN sales_qty_total = 0 AND stock_qty > 0 THEN -15
|
|
ELSE 0
|
|
END
|
|
)::numeric, 4) AS performance_score,
|
|
CASE
|
|
WHEN sales_qty_total > 0 AND stock_qty <= 0 AND sales_index_total >= 1 THEN 'STOKSUZ_TALEP'
|
|
WHEN sales_qty_total = 0 AND stock_qty > 0 THEN 'STOK_RISKI'
|
|
WHEN gross_margin_total < 0 THEN 'FIYAT_BASKISI'
|
|
WHEN sales_index_total >= 1.4 AND gross_margin_total >= 0.45 THEN 'YILDIZ_URUN'
|
|
WHEN gross_margin_total >= 0.35 AND sales_index_total < 0.8 THEN 'FIYAT_FIRSATI'
|
|
ELSE 'TAKIP'
|
|
END AS performance_bucket,
|
|
CASE
|
|
WHEN sales_qty_total > 0 AND stock_qty <= 0 THEN 'Talep var, stok yok. Uretim/satin alma onceligi ver.'
|
|
WHEN sales_qty_total = 0 AND stock_qty > 0 THEN 'Satis yok, stok maliyeti tasiyor. Piyasa/fiyat aksiyonu gerekli.'
|
|
WHEN gross_margin_total < 0 THEN 'Ciplak maliyet altinda satis var. Fiyat veya maliyet kontrol edilmeli.'
|
|
WHEN sales_index_total >= 1.4 AND gross_margin_total >= 0.45 THEN 'Genel donemde guclu urun. Stok ve fiyat korunmali.'
|
|
WHEN gross_margin_total >= 0.35 AND sales_index_total < 0.8 THEN 'Karli ama yavas. Dogru piyasada satis firsati var.'
|
|
ELSE 'Izleme ve piyasa bazli aksiyon.'
|
|
END AS recommendation,
|
|
COALESCE(to_char(first_sale_date,'YYYY-MM-DD'),''),
|
|
COALESCE(to_char(last_sale_date,'YYYY-MM-DD'),''),
|
|
COALESCE(last_ref_number,'')
|
|
FROM Scored
|
|
ORDER BY performance_score DESC, sales_usd_total DESC, product_code ASC, color_code ASC, yaka_kodu ASC
|
|
LIMIT $1
|
|
`, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := make([]models.ProductPerformanceGeneralRow, 0, limit)
|
|
colorCodes := make([]string, 0, limit)
|
|
for rows.Next() {
|
|
var r models.ProductPerformanceGeneralRow
|
|
if err := rows.Scan(
|
|
&r.PeriodStart, &r.PeriodEnd, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.ItemDescription,
|
|
&r.Kategori, &r.Seri, &r.YasGrubu, &r.AskiliYan, &r.UrunIlkGrubu, &r.UrunAnaGrubu, &r.UrunAltGrubu, &r.MarketKey,
|
|
&r.StockQty, &r.SalesQtyTotal, &r.SalesUSDTotal, &r.AvgDailySalesTotal, &r.StockDaysTotal, &r.AvgPriceUSDTotal,
|
|
&r.CostPriceUSD, &r.BasePriceUSD, &r.GrossProfitUSDTotal, &r.GrossMarginTotal, &r.UnitProfitCostTotal, &r.UnitProfitBaseTotal,
|
|
&r.MarketCountTotal, &r.CustomerCountTotal, &r.InvoiceCountTotal, &r.SalesIndexTotal, &r.PerformanceScore,
|
|
&r.PerformanceBucket, &r.Recommendation, &r.FirstSaleDate, &r.LastSaleDate, &r.LastRefNumber,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
r.UrunIlkGrubu = cleanProductPerformanceFirstGroup(r.UrunIlkGrubu)
|
|
colorCodes = append(colorCodes, r.ColorCode)
|
|
out = append(out, r)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
colorDescriptions := productPerformanceColorDescriptions(ctx, colorCodes)
|
|
for i := range out {
|
|
out[i].ColorDescription = colorDescriptions[normalizeProductPerformanceCode(out[i].ColorCode)]
|
|
}
|
|
applyProductPerformanceGeneralRowScores(out)
|
|
return out, nil
|
|
}
|
|
|
|
func applyProductPerformanceGeneralRowScores(rows []models.ProductPerformanceGeneralRow) {
|
|
for i := range rows {
|
|
turnover := 0.0
|
|
if rows[i].StockQty > 0 {
|
|
turnover = rows[i].SalesQtyTotal / rows[i].StockQty
|
|
}
|
|
rows[i].PerformanceScore = productPerformanceProductScore(
|
|
"total",
|
|
rows[i].SalesUSDTotal,
|
|
rows[i].SalesIndexTotal,
|
|
rows[i].GrossMarginTotal,
|
|
turnover,
|
|
float64(rows[i].MarketCountTotal),
|
|
float64(rows[i].CustomerCountTotal),
|
|
)
|
|
}
|
|
}
|
|
|
|
func ListProductPerformanceOrderAnalysis(ctx context.Context, pg *sql.DB, limit int) ([]models.ProductPerformanceOrderAnalysisRow, error) {
|
|
if db.MssqlDB == nil {
|
|
return nil, fmt.Errorf("mssql db nil")
|
|
}
|
|
if err := EnsureProductPerformanceTables(pg); err != nil {
|
|
return nil, err
|
|
}
|
|
if limit <= 0 {
|
|
limit = 50000
|
|
} else if limit > 50000 {
|
|
limit = 50000
|
|
}
|
|
if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceOrderAnalysisRow](ctx, pg, productPerformanceSnapshotKey("orders"), limit); err != nil {
|
|
return nil, err
|
|
} else if ok {
|
|
return rows, nil
|
|
}
|
|
|
|
rows, err := db.MssqlDB.QueryContext(ctx, `
|
|
WITH OpenOrderLines AS (
|
|
SELECT
|
|
OrderDate = CAST(h.OrderDate AS date),
|
|
DueDate = CAST(ISNULL(l.DeliveryDate, h.AverageDueDate) AS date),
|
|
h.OrderHeaderID,
|
|
h.OrderNumber,
|
|
h.CurrAccCode,
|
|
ProductCode = UPPER(LTRIM(RTRIM(l.ItemCode))),
|
|
ColorCode = UPPER(LTRIM(RTRIM(ISNULL(l.ColorCode, '')))),
|
|
YakaKodu = UPPER(LTRIM(RTRIM(ISNULL(l.ItemDim2Code, '')))),
|
|
ItemDescription = dbo.HG_Temizlik(ISNULL((
|
|
SELECT ItemDescription
|
|
FROM cdItemDesc WITH(NOLOCK)
|
|
WHERE cdItemDesc.ItemTypeCode = l.ItemTypeCode
|
|
AND cdItemDesc.ItemCode = l.ItemCode
|
|
AND cdItemDesc.LangCode = 'TR'
|
|
), SPACE(0))),
|
|
Kategori = dbo.HG_Temizlik(ISNULL((
|
|
SELECT AttributeDescription
|
|
FROM cdItemAttributeDesc WITH(NOLOCK)
|
|
WHERE cdItemAttributeDesc.ItemTypeCode = l.ItemTypeCode
|
|
AND cdItemAttributeDesc.AttributeTypeCode = 44
|
|
AND cdItemAttributeDesc.AttributeCode = (
|
|
SELECT TOP 1 AttributeCode
|
|
FROM prItemAttribute WITH(NOLOCK)
|
|
WHERE AttributeTypeCode = 44
|
|
AND prItemAttribute.ItemTypeCode = l.ItemTypeCode
|
|
AND prItemAttribute.ItemCode = l.ItemCode
|
|
)
|
|
AND cdItemAttributeDesc.LangCode = 'TR'
|
|
), SPACE(0))),
|
|
Seri = dbo.HG_Temizlik(ISNULL((
|
|
SELECT AttributeDescription
|
|
FROM cdItemAttributeDesc WITH(NOLOCK)
|
|
WHERE cdItemAttributeDesc.ItemTypeCode = l.ItemTypeCode
|
|
AND cdItemAttributeDesc.AttributeTypeCode = 2
|
|
AND cdItemAttributeDesc.AttributeCode = (
|
|
SELECT TOP 1 AttributeCode
|
|
FROM prItemAttribute WITH(NOLOCK)
|
|
WHERE AttributeTypeCode = 2
|
|
AND prItemAttribute.ItemTypeCode = l.ItemTypeCode
|
|
AND prItemAttribute.ItemCode = l.ItemCode
|
|
)
|
|
AND cdItemAttributeDesc.LangCode = 'TR'
|
|
), SPACE(0))),
|
|
YasGrubu = '',
|
|
UrunIlkGrubu = dbo.HG_Temizlik(ISNULL((
|
|
SELECT AttributeDescription
|
|
FROM cdItemAttributeDesc WITH(NOLOCK)
|
|
WHERE cdItemAttributeDesc.ItemTypeCode = l.ItemTypeCode
|
|
AND cdItemAttributeDesc.AttributeTypeCode = 42
|
|
AND cdItemAttributeDesc.AttributeCode = (
|
|
SELECT TOP 1 AttributeCode
|
|
FROM prItemAttribute WITH(NOLOCK)
|
|
WHERE AttributeTypeCode = 42
|
|
AND prItemAttribute.ItemTypeCode = l.ItemTypeCode
|
|
AND prItemAttribute.ItemCode = l.ItemCode
|
|
)
|
|
AND cdItemAttributeDesc.LangCode = 'TR'
|
|
), SPACE(0))),
|
|
AskiliYan = dbo.HG_Temizlik(ISNULL((
|
|
SELECT TOP 1 ProductAtt45
|
|
FROM ProductAttributesFilter WITH(NOLOCK)
|
|
WHERE ProductAttributesFilter.ItemCode = l.ItemCode
|
|
), SPACE(0))),
|
|
UrunAnaGrubu = dbo.HG_Temizlik(ISNULL((
|
|
SELECT AttributeDescription
|
|
FROM cdItemAttributeDesc WITH(NOLOCK)
|
|
WHERE cdItemAttributeDesc.ItemTypeCode = l.ItemTypeCode
|
|
AND cdItemAttributeDesc.AttributeTypeCode = 1
|
|
AND cdItemAttributeDesc.AttributeCode = (
|
|
SELECT TOP 1 AttributeCode
|
|
FROM prItemAttribute WITH(NOLOCK)
|
|
WHERE AttributeTypeCode = 1
|
|
AND prItemAttribute.ItemTypeCode = l.ItemTypeCode
|
|
AND prItemAttribute.ItemCode = l.ItemCode
|
|
)
|
|
AND cdItemAttributeDesc.LangCode = 'TR'
|
|
), SPACE(0))),
|
|
UrunAltGrubu = dbo.HG_Temizlik(ISNULL((
|
|
SELECT AttributeDescription
|
|
FROM cdItemAttributeDesc WITH(NOLOCK)
|
|
WHERE cdItemAttributeDesc.ItemTypeCode = l.ItemTypeCode
|
|
AND cdItemAttributeDesc.AttributeTypeCode = 2
|
|
AND cdItemAttributeDesc.AttributeCode = (
|
|
SELECT TOP 1 AttributeCode
|
|
FROM prItemAttribute WITH(NOLOCK)
|
|
WHERE AttributeTypeCode = 2
|
|
AND prItemAttribute.ItemTypeCode = l.ItemTypeCode
|
|
AND prItemAttribute.ItemCode = l.ItemCode
|
|
)
|
|
AND cdItemAttributeDesc.LangCode = 'TR'
|
|
), SPACE(0))),
|
|
MarketKey = dbo.HG_Temizlik(ISNULL((
|
|
SELECT AttributeDescription
|
|
FROM cdCurrAccAttributeDesc WITH(NOLOCK)
|
|
WHERE cdCurrAccAttributeDesc.CurrAccTypeCode = 3
|
|
AND cdCurrAccAttributeDesc.AttributeTypeCode = 1
|
|
AND cdCurrAccAttributeDesc.AttributeCode = caf.CustomerAtt01
|
|
AND cdCurrAccAttributeDesc.LangCode = 'TR'
|
|
), SPACE(0))),
|
|
Qty = ISNULL(l.Qty1, 0),
|
|
AmountUSD = CASE
|
|
WHEN h.DocCurrencyCode = 'USD' THEN ISNULL(c.NetAmount, 0)
|
|
WHEN h.DocCurrencyCode = 'TRY' AND usd.Rate > 0 THEN ISNULL(c.NetAmount, 0) / usd.Rate
|
|
WHEN h.DocCurrencyCode IN ('EUR', 'GBP') AND cur.Rate > 0 AND usd.Rate > 0 THEN (ISNULL(c.NetAmount, 0) * cur.Rate) / usd.Rate
|
|
ELSE 0
|
|
END
|
|
FROM dbo.trOrderHeader h WITH(NOLOCK)
|
|
INNER JOIN dbo.trOrderLine l WITH(NOLOCK)
|
|
ON l.OrderHeaderID = h.OrderHeaderID
|
|
LEFT JOIN dbo.trOrderLineCurrency c WITH(NOLOCK)
|
|
ON c.OrderLineID = l.OrderLineID
|
|
AND c.CurrencyCode = ISNULL(h.DocCurrencyCode, 'TRY')
|
|
LEFT JOIN dbo.CustomerAttributesFilter caf WITH(NOLOCK)
|
|
ON caf.CurrAccTypeCode = h.CurrAccTypeCode
|
|
AND caf.CurrAccCode = h.CurrAccCode
|
|
OUTER APPLY (
|
|
SELECT TOP 1 Rate
|
|
FROM dbo.AllExchangeRates WITH(NOLOCK)
|
|
WHERE CurrencyCode = 'USD'
|
|
AND RelationCurrencyCode = 'TRY'
|
|
AND ExchangeTypeCode = 6
|
|
AND Rate > 0
|
|
AND Date <= CAST(ISNULL(h.OrderDate, GETDATE()) AS date)
|
|
ORDER BY Date DESC
|
|
) usd
|
|
OUTER APPLY (
|
|
SELECT TOP 1 Rate
|
|
FROM dbo.AllExchangeRates WITH(NOLOCK)
|
|
WHERE CurrencyCode = h.DocCurrencyCode
|
|
AND RelationCurrencyCode = 'TRY'
|
|
AND ExchangeTypeCode = 6
|
|
AND Rate > 0
|
|
AND Date <= CAST(ISNULL(h.OrderDate, GETDATE()) AS date)
|
|
ORDER BY Date DESC
|
|
) cur
|
|
WHERE ISNULL(h.IsCancelOrder, 0) = 0
|
|
AND ISNULL(h.IsClosed, 0) = 0
|
|
AND h.OrderTypeCode = 1
|
|
AND h.ProcessCode = 'WS'
|
|
AND ISNULL(l.IsClosed, 0) = 0
|
|
AND l.ItemTypeCode = 1
|
|
AND ISNULL(l.Qty1, 0) > 0
|
|
AND LEN(LTRIM(RTRIM(l.ItemCode))) = 13
|
|
),
|
|
Spread AS (
|
|
SELECT
|
|
ProductCode,
|
|
ColorCode,
|
|
YakaKodu,
|
|
MarketCount = COUNT(DISTINCT NULLIF(MarketKey, '')),
|
|
CustomerCount = COUNT(DISTINCT NULLIF(CurrAccCode, ''))
|
|
FROM OpenOrderLines
|
|
GROUP BY ProductCode, ColorCode, YakaKodu
|
|
)
|
|
SELECT TOP (@p1)
|
|
o.ProductCode,
|
|
o.ColorCode,
|
|
o.YakaKodu,
|
|
ItemDescription = MAX(o.ItemDescription),
|
|
Kategori = MAX(o.Kategori),
|
|
Seri = MAX(o.Seri),
|
|
YasGrubu = MAX(o.YasGrubu),
|
|
AskiliYan = ISNULL(NULLIF(MAX(o.AskiliYan), '-'), ''),
|
|
UrunIlkGrubu = MAX(o.UrunIlkGrubu),
|
|
UrunAnaGrubu = MAX(o.UrunAnaGrubu),
|
|
UrunAltGrubu = MAX(o.UrunAltGrubu),
|
|
o.MarketKey,
|
|
OrderQty = SUM(o.Qty),
|
|
OrderUSD = SUM(o.AmountUSD),
|
|
AvgOrderPriceUSD = CASE WHEN SUM(o.Qty) = 0 THEN 0 ELSE SUM(o.AmountUSD) / NULLIF(SUM(o.Qty), 0) END,
|
|
MarketCount = MAX(s.MarketCount),
|
|
CustomerCount = COUNT(DISTINCT NULLIF(o.CurrAccCode, '')),
|
|
OrderCount = COUNT(DISTINCT o.OrderHeaderID),
|
|
LineCount = COUNT(*),
|
|
FirstOrderDate = CONVERT(varchar, MIN(o.OrderDate), 23),
|
|
LastOrderDate = CONVERT(varchar, MAX(o.OrderDate), 23),
|
|
EarliestDueDate = CONVERT(varchar, MIN(o.DueDate), 23),
|
|
OverdueQty = SUM(CASE WHEN o.DueDate < CAST(GETDATE() AS date) THEN o.Qty ELSE 0 END)
|
|
FROM OpenOrderLines o
|
|
LEFT JOIN Spread s
|
|
ON s.ProductCode = o.ProductCode
|
|
AND s.ColorCode = o.ColorCode
|
|
AND s.YakaKodu = o.YakaKodu
|
|
GROUP BY o.ProductCode, o.ColorCode, o.YakaKodu, o.MarketKey
|
|
ORDER BY SUM(o.AmountUSD) DESC, SUM(o.Qty) DESC, o.ProductCode, o.ColorCode, o.YakaKodu
|
|
`, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := make([]models.ProductPerformanceOrderAnalysisRow, 0, limit)
|
|
productCodes := make([]string, 0, limit)
|
|
stockKeys := make([]string, 0, limit)
|
|
colorCodes := make([]string, 0, limit)
|
|
seenProducts := map[string]bool{}
|
|
seenStockKeys := map[string]bool{}
|
|
for rows.Next() {
|
|
var r models.ProductPerformanceOrderAnalysisRow
|
|
if err := rows.Scan(
|
|
&r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.ItemDescription, &r.Kategori, &r.Seri, &r.YasGrubu, &r.AskiliYan,
|
|
&r.UrunIlkGrubu, &r.UrunAnaGrubu, &r.UrunAltGrubu, &r.MarketKey, &r.OrderQty, &r.OrderUSD, &r.AvgOrderPriceUSD,
|
|
&r.MarketCount, &r.CustomerCount, &r.OrderCount, &r.LineCount, &r.FirstOrderDate, &r.LastOrderDate, &r.EarliestDueDate, &r.OverdueQty,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
normalizeProductPerformanceVariantCodes(&r.ProductCode, &r.ColorCode, &r.YakaKodu)
|
|
r.AskiliYan = cleanProductPerformanceOptionalAttr(r.AskiliYan)
|
|
colorCodes = append(colorCodes, r.ColorCode)
|
|
out = append(out, r)
|
|
if !seenProducts[r.ProductCode] {
|
|
seenProducts[r.ProductCode] = true
|
|
productCodes = append(productCodes, r.ProductCode)
|
|
}
|
|
key := productPerformanceVariantKey(r.ProductCode, r.ColorCode, r.YakaKodu)
|
|
if !seenStockKeys[key] {
|
|
seenStockKeys[key] = true
|
|
stockKeys = append(stockKeys, key)
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
priceByProduct, err := productPerformancePriceLookup(ctx, pg, productCodes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stockByKey, err := productPerformanceStockLookup(ctx, pg, stockKeys)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
attrByKey, err := productPerformanceAttrLookup(ctx, pg, stockKeys)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
colorDescriptions := productPerformanceColorDescriptions(ctx, colorCodes)
|
|
filtered := out[:0]
|
|
for i := range out {
|
|
row := &out[i]
|
|
price := priceByProduct[normalizeProductPerformanceProductCode(row.ProductCode)]
|
|
attr := attrByKey[productPerformanceVariantKey(row.ProductCode, row.ColorCode, row.YakaKodu)]
|
|
if isExcludedProductPerformanceFirstGroup(attr.urunIlkGrubu) {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(row.ItemDescription) == "" {
|
|
row.ItemDescription = attr.itemDescription
|
|
}
|
|
if strings.TrimSpace(row.Kategori) == "" {
|
|
row.Kategori = attr.kategori
|
|
}
|
|
row.AskiliYan = cleanProductPerformanceOptionalAttr(row.AskiliYan)
|
|
if strings.TrimSpace(row.AskiliYan) == "" {
|
|
row.AskiliYan = cleanProductPerformanceOptionalAttr(attr.askiliYan)
|
|
}
|
|
if strings.TrimSpace(row.UrunIlkGrubu) == "" {
|
|
row.UrunIlkGrubu = attr.urunIlkGrubu
|
|
}
|
|
if strings.TrimSpace(row.UrunAnaGrubu) == "" {
|
|
row.UrunAnaGrubu = attr.urunAnaGrubu
|
|
}
|
|
if strings.TrimSpace(row.UrunAltGrubu) == "" {
|
|
row.UrunAltGrubu = attr.urunAltGrubu
|
|
}
|
|
row.CostPriceUSD = price.cost
|
|
row.BasePriceUSD = price.base
|
|
row.StockQty = stockByKey[productPerformanceVariantKey(row.ProductCode, row.ColorCode, row.YakaKodu)]
|
|
row.NetStockAfterOrder = row.StockQty - row.OrderQty
|
|
row.UnitProfitCostUSD = row.AvgOrderPriceUSD - row.CostPriceUSD
|
|
row.UnitProfitBaseUSD = row.AvgOrderPriceUSD - row.BasePriceUSD
|
|
row.ExpectedProfitCostUSD = row.UnitProfitCostUSD * row.OrderQty
|
|
row.ExpectedProfitBaseUSD = row.UnitProfitBaseUSD * row.OrderQty
|
|
if row.OrderUSD != 0 {
|
|
row.ExpectedMarginCost = row.ExpectedProfitCostUSD / row.OrderUSD
|
|
}
|
|
row.ColorDescription = colorDescriptions[normalizeProductPerformanceCode(row.ColorCode)]
|
|
row.PerformanceBucket, row.Recommendation = productPerformanceOrderRecommendation(*row)
|
|
filtered = append(filtered, *row)
|
|
}
|
|
return filtered, nil
|
|
}
|
|
|
|
func ListProductPerformanceOrderGroups(ctx context.Context, pg *sql.DB, breakdown string, limit int) ([]models.ProductPerformanceOrderGroupRow, error) {
|
|
if db.MssqlDB == nil {
|
|
return nil, fmt.Errorf("mssql db nil")
|
|
}
|
|
if err := EnsureProductPerformanceTables(pg); err != nil {
|
|
return nil, err
|
|
}
|
|
mode := strings.ToLower(strings.TrimSpace(breakdown))
|
|
if mode != "customer" {
|
|
mode = "market"
|
|
}
|
|
if limit <= 0 {
|
|
limit = 50000
|
|
} else if limit > 50000 {
|
|
limit = 50000
|
|
}
|
|
if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceOrderGroupRow](ctx, pg, productPerformanceSnapshotKey("order-groups", mode), limit); err != nil {
|
|
return nil, err
|
|
} else if ok {
|
|
return rows, nil
|
|
}
|
|
|
|
rows, err := db.MssqlDB.QueryContext(ctx, `
|
|
WITH OpenOrderLines AS (
|
|
SELECT
|
|
h.OrderHeaderID,
|
|
h.CurrAccCode,
|
|
CustomerName = ISNULL(cad.CurrAccDescription, ''),
|
|
ProductCode = UPPER(LTRIM(RTRIM(l.ItemCode))),
|
|
ColorCode = UPPER(LTRIM(RTRIM(ISNULL(l.ColorCode, '')))),
|
|
YakaKodu = UPPER(LTRIM(RTRIM(ISNULL(l.ItemDim2Code, '')))),
|
|
MarketKey = dbo.HG_Temizlik(ISNULL((
|
|
SELECT AttributeDescription
|
|
FROM cdCurrAccAttributeDesc WITH(NOLOCK)
|
|
WHERE cdCurrAccAttributeDesc.CurrAccTypeCode = 3
|
|
AND cdCurrAccAttributeDesc.AttributeTypeCode = 1
|
|
AND cdCurrAccAttributeDesc.AttributeCode = caf.CustomerAtt01
|
|
AND cdCurrAccAttributeDesc.LangCode = 'TR'
|
|
), SPACE(0))),
|
|
Qty = ISNULL(l.Qty1, 0),
|
|
AmountUSD = CASE
|
|
WHEN h.DocCurrencyCode = 'USD' THEN ISNULL(c.NetAmount, 0)
|
|
WHEN h.DocCurrencyCode = 'TRY' AND usd.Rate > 0 THEN ISNULL(c.NetAmount, 0) / usd.Rate
|
|
WHEN h.DocCurrencyCode IN ('EUR', 'GBP') AND cur.Rate > 0 AND usd.Rate > 0 THEN (ISNULL(c.NetAmount, 0) * cur.Rate) / usd.Rate
|
|
ELSE 0
|
|
END,
|
|
DueDate = CAST(ISNULL(l.DeliveryDate, h.AverageDueDate) AS date)
|
|
FROM dbo.trOrderHeader h WITH(NOLOCK)
|
|
INNER JOIN dbo.trOrderLine l WITH(NOLOCK)
|
|
ON l.OrderHeaderID = h.OrderHeaderID
|
|
LEFT JOIN dbo.trOrderLineCurrency c WITH(NOLOCK)
|
|
ON c.OrderLineID = l.OrderLineID
|
|
AND c.CurrencyCode = ISNULL(h.DocCurrencyCode, 'TRY')
|
|
LEFT JOIN dbo.CustomerAttributesFilter caf WITH(NOLOCK)
|
|
ON caf.CurrAccTypeCode = h.CurrAccTypeCode
|
|
AND caf.CurrAccCode = h.CurrAccCode
|
|
LEFT JOIN dbo.cdCurrAccDesc cad WITH(NOLOCK)
|
|
ON cad.CurrAccTypeCode = h.CurrAccTypeCode
|
|
AND cad.CurrAccCode = h.CurrAccCode
|
|
AND cad.LangCode = 'TR'
|
|
OUTER APPLY (
|
|
SELECT TOP 1 Rate
|
|
FROM dbo.AllExchangeRates WITH(NOLOCK)
|
|
WHERE CurrencyCode = 'USD'
|
|
AND RelationCurrencyCode = 'TRY'
|
|
AND ExchangeTypeCode = 6
|
|
AND Rate > 0
|
|
AND Date <= CAST(ISNULL(h.OrderDate, GETDATE()) AS date)
|
|
ORDER BY Date DESC
|
|
) usd
|
|
OUTER APPLY (
|
|
SELECT TOP 1 Rate
|
|
FROM dbo.AllExchangeRates WITH(NOLOCK)
|
|
WHERE CurrencyCode = h.DocCurrencyCode
|
|
AND RelationCurrencyCode = 'TRY'
|
|
AND ExchangeTypeCode = 6
|
|
AND Rate > 0
|
|
AND Date <= CAST(ISNULL(h.OrderDate, GETDATE()) AS date)
|
|
ORDER BY Date DESC
|
|
) cur
|
|
WHERE ISNULL(h.IsCancelOrder, 0) = 0
|
|
AND ISNULL(h.IsClosed, 0) = 0
|
|
AND h.OrderTypeCode = 1
|
|
AND h.ProcessCode = 'WS'
|
|
AND ISNULL(l.IsClosed, 0) = 0
|
|
AND l.ItemTypeCode = 1
|
|
AND ISNULL(l.Qty1, 0) > 0
|
|
AND LEN(LTRIM(RTRIM(l.ItemCode))) = 13
|
|
)
|
|
SELECT TOP (@p1)
|
|
ProductCode,
|
|
ColorCode,
|
|
YakaKodu,
|
|
MarketKey,
|
|
CurrAccCode,
|
|
CustomerName,
|
|
OrderQty = SUM(Qty),
|
|
OrderUSD = SUM(AmountUSD),
|
|
OrderCount = COUNT(DISTINCT OrderHeaderID),
|
|
LineCount = COUNT(*),
|
|
OverdueQty = SUM(CASE WHEN DueDate < CAST(GETDATE() AS date) THEN Qty ELSE 0 END)
|
|
FROM OpenOrderLines
|
|
GROUP BY ProductCode, ColorCode, YakaKodu, MarketKey, CurrAccCode, CustomerName
|
|
ORDER BY SUM(AmountUSD) DESC, SUM(Qty) DESC
|
|
`, limit*4)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
type detail struct {
|
|
productCode string
|
|
colorCode string
|
|
yakaKodu string
|
|
marketKey string
|
|
customerCode string
|
|
customerName string
|
|
orderQty float64
|
|
orderUSD float64
|
|
orderCount int
|
|
lineCount int
|
|
overdueQty float64
|
|
}
|
|
details := make([]detail, 0, limit*2)
|
|
productCodes := make([]string, 0, limit)
|
|
stockKeys := make([]string, 0, limit)
|
|
seenProducts := map[string]bool{}
|
|
seenStockKeys := map[string]bool{}
|
|
for rows.Next() {
|
|
var d detail
|
|
if err := rows.Scan(&d.productCode, &d.colorCode, &d.yakaKodu, &d.marketKey, &d.customerCode, &d.customerName, &d.orderQty, &d.orderUSD, &d.orderCount, &d.lineCount, &d.overdueQty); err != nil {
|
|
return nil, err
|
|
}
|
|
normalizeProductPerformanceVariantCodes(&d.productCode, &d.colorCode, &d.yakaKodu)
|
|
details = append(details, d)
|
|
if !seenProducts[d.productCode] {
|
|
seenProducts[d.productCode] = true
|
|
productCodes = append(productCodes, d.productCode)
|
|
}
|
|
key := productPerformanceVariantKey(d.productCode, d.colorCode, d.yakaKodu)
|
|
if !seenStockKeys[key] {
|
|
seenStockKeys[key] = true
|
|
stockKeys = append(stockKeys, key)
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
priceByProduct, err := productPerformancePriceLookup(ctx, pg, productCodes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stockByKey, err := productPerformanceStockLookup(ctx, pg, stockKeys)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
grouped := map[string]*models.ProductPerformanceOrderGroupRow{}
|
|
productSets := map[string]map[string]bool{}
|
|
marketSets := map[string]map[string]bool{}
|
|
customerSets := map[string]map[string]bool{}
|
|
stockVariantSets := map[string]map[string]bool{}
|
|
for _, d := range details {
|
|
groupKey := strings.TrimSpace(d.marketKey)
|
|
customerCode := ""
|
|
customerName := ""
|
|
if mode == "customer" {
|
|
groupKey = strings.TrimSpace(d.customerCode)
|
|
customerCode = strings.TrimSpace(d.customerCode)
|
|
customerName = strings.TrimSpace(d.customerName)
|
|
}
|
|
if groupKey == "" {
|
|
groupKey = "-"
|
|
}
|
|
row := grouped[groupKey]
|
|
if row == nil {
|
|
row = &models.ProductPerformanceOrderGroupRow{
|
|
Breakdown: mode,
|
|
GroupKey: groupKey,
|
|
MarketKey: strings.TrimSpace(d.marketKey),
|
|
CustomerCode: customerCode,
|
|
CustomerName: customerName,
|
|
}
|
|
grouped[groupKey] = row
|
|
productSets[groupKey] = map[string]bool{}
|
|
marketSets[groupKey] = map[string]bool{}
|
|
customerSets[groupKey] = map[string]bool{}
|
|
stockVariantSets[groupKey] = map[string]bool{}
|
|
}
|
|
price := priceByProduct[normalizeProductPerformanceProductCode(d.productCode)]
|
|
variantKey := productPerformanceVariantKey(d.productCode, d.colorCode, d.yakaKodu)
|
|
row.OrderQty += d.orderQty
|
|
row.OrderUSD += d.orderUSD
|
|
row.BaseCostUSD += d.orderQty * price.base
|
|
row.CostAmountUSD += d.orderQty * price.cost
|
|
if !stockVariantSets[groupKey][variantKey] {
|
|
row.StockQty += stockByKey[variantKey]
|
|
stockVariantSets[groupKey][variantKey] = true
|
|
}
|
|
row.OrderCount += d.orderCount
|
|
row.LineCount += d.lineCount
|
|
row.OverdueQty += d.overdueQty
|
|
productSets[groupKey][d.productCode] = true
|
|
if strings.TrimSpace(d.marketKey) != "" {
|
|
marketSets[groupKey][strings.TrimSpace(d.marketKey)] = true
|
|
}
|
|
if strings.TrimSpace(d.customerCode) != "" {
|
|
customerSets[groupKey][strings.TrimSpace(d.customerCode)] = true
|
|
}
|
|
}
|
|
|
|
out := make([]models.ProductPerformanceOrderGroupRow, 0, len(grouped))
|
|
for key, row := range grouped {
|
|
row.ProductCount = len(productSets[key])
|
|
row.MarketCount = len(marketSets[key])
|
|
row.CustomerCount = len(customerSets[key])
|
|
row.NetStockAfterOrder = row.StockQty - row.OrderQty
|
|
if row.OrderQty != 0 {
|
|
row.AvgOrderPriceUSD = row.OrderUSD / row.OrderQty
|
|
}
|
|
row.ExpectedProfitBaseUSD = row.OrderUSD - row.BaseCostUSD
|
|
row.ExpectedProfitCostUSD = row.OrderUSD - row.CostAmountUSD
|
|
if row.OrderUSD != 0 {
|
|
row.ExpectedMarginBase = row.ExpectedProfitBaseUSD / row.OrderUSD
|
|
row.ExpectedMarginCost = row.ExpectedProfitCostUSD / row.OrderUSD
|
|
}
|
|
row.PerformanceBucket, row.Recommendation = productPerformanceOrderGroupRecommendation(*row)
|
|
out = append(out, *row)
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
if out[i].ExpectedProfitCostUSD < 0 && out[j].ExpectedProfitCostUSD >= 0 {
|
|
return true
|
|
}
|
|
if out[i].ExpectedProfitCostUSD >= 0 && out[j].ExpectedProfitCostUSD < 0 {
|
|
return false
|
|
}
|
|
return out[i].OrderUSD > out[j].OrderUSD
|
|
})
|
|
if len(out) > limit {
|
|
out = out[:limit]
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func ListProductPerformanceOrderProductCustomers(ctx context.Context, pg *sql.DB, limit int) ([]models.ProductPerformanceOrderProductCustomerRow, error) {
|
|
if db.MssqlDB == nil {
|
|
return nil, fmt.Errorf("mssql db nil")
|
|
}
|
|
if err := EnsureProductPerformanceTables(pg); err != nil {
|
|
return nil, err
|
|
}
|
|
if limit <= 0 {
|
|
limit = 50000
|
|
} else if limit > 50000 {
|
|
limit = 50000
|
|
}
|
|
if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceOrderProductCustomerRow](ctx, pg, productPerformanceSnapshotKey("order-product-customers"), limit); err != nil {
|
|
return nil, err
|
|
} else if ok {
|
|
return rows, nil
|
|
}
|
|
rows, err := db.MssqlDB.QueryContext(ctx, `
|
|
WITH OpenOrderLines AS (
|
|
SELECT
|
|
h.OrderHeaderID,
|
|
h.CurrAccCode,
|
|
CustomerName = ISNULL(cad.CurrAccDescription, ''),
|
|
ProductCode = UPPER(LTRIM(RTRIM(l.ItemCode))),
|
|
ColorCode = UPPER(LTRIM(RTRIM(ISNULL(l.ColorCode, '')))),
|
|
YakaKodu = UPPER(LTRIM(RTRIM(ISNULL(l.ItemDim2Code, '')))),
|
|
ItemDescription = dbo.HG_Temizlik(ISNULL((SELECT ItemDescription FROM cdItemDesc WITH(NOLOCK) WHERE cdItemDesc.ItemTypeCode = l.ItemTypeCode AND cdItemDesc.ItemCode = l.ItemCode AND cdItemDesc.LangCode = 'TR'), SPACE(0))),
|
|
MarketKey = dbo.HG_Temizlik(ISNULL((
|
|
SELECT AttributeDescription FROM cdCurrAccAttributeDesc WITH(NOLOCK)
|
|
WHERE cdCurrAccAttributeDesc.CurrAccTypeCode = 3
|
|
AND cdCurrAccAttributeDesc.AttributeTypeCode = 1
|
|
AND cdCurrAccAttributeDesc.AttributeCode = caf.CustomerAtt01
|
|
AND cdCurrAccAttributeDesc.LangCode = 'TR'
|
|
), SPACE(0))),
|
|
Qty = ISNULL(l.Qty1, 0),
|
|
AmountUSD = CASE
|
|
WHEN h.DocCurrencyCode = 'USD' THEN ISNULL(c.NetAmount, 0)
|
|
WHEN h.DocCurrencyCode = 'TRY' AND usd.Rate > 0 THEN ISNULL(c.NetAmount, 0) / usd.Rate
|
|
WHEN h.DocCurrencyCode IN ('EUR', 'GBP') AND cur.Rate > 0 AND usd.Rate > 0 THEN (ISNULL(c.NetAmount, 0) * cur.Rate) / usd.Rate
|
|
ELSE 0
|
|
END,
|
|
DueDate = CAST(ISNULL(l.DeliveryDate, h.AverageDueDate) AS date)
|
|
FROM dbo.trOrderHeader h WITH(NOLOCK)
|
|
INNER JOIN dbo.trOrderLine l WITH(NOLOCK) ON l.OrderHeaderID = h.OrderHeaderID
|
|
LEFT JOIN dbo.trOrderLineCurrency c WITH(NOLOCK) ON c.OrderLineID = l.OrderLineID AND c.CurrencyCode = ISNULL(h.DocCurrencyCode, 'TRY')
|
|
LEFT JOIN dbo.CustomerAttributesFilter caf WITH(NOLOCK) ON caf.CurrAccTypeCode = h.CurrAccTypeCode AND caf.CurrAccCode = h.CurrAccCode
|
|
LEFT JOIN dbo.cdCurrAccDesc cad WITH(NOLOCK) ON cad.CurrAccTypeCode = h.CurrAccTypeCode AND cad.CurrAccCode = h.CurrAccCode AND cad.LangCode = 'TR'
|
|
OUTER APPLY (SELECT TOP 1 Rate FROM dbo.AllExchangeRates WITH(NOLOCK) WHERE CurrencyCode = 'USD' AND RelationCurrencyCode = 'TRY' AND ExchangeTypeCode = 6 AND Rate > 0 AND Date <= CAST(ISNULL(h.OrderDate, GETDATE()) AS date) ORDER BY Date DESC) usd
|
|
OUTER APPLY (SELECT TOP 1 Rate FROM dbo.AllExchangeRates WITH(NOLOCK) WHERE CurrencyCode = h.DocCurrencyCode AND RelationCurrencyCode = 'TRY' AND ExchangeTypeCode = 6 AND Rate > 0 AND Date <= CAST(ISNULL(h.OrderDate, GETDATE()) AS date) ORDER BY Date DESC) cur
|
|
WHERE ISNULL(h.IsCancelOrder, 0) = 0
|
|
AND ISNULL(h.IsClosed, 0) = 0
|
|
AND h.OrderTypeCode = 1
|
|
AND h.ProcessCode = 'WS'
|
|
AND ISNULL(l.IsClosed, 0) = 0
|
|
AND l.ItemTypeCode = 1
|
|
AND ISNULL(l.Qty1, 0) > 0
|
|
AND LEN(LTRIM(RTRIM(l.ItemCode))) = 13
|
|
)
|
|
SELECT TOP (@p1)
|
|
ProductCode, ColorCode, YakaKodu, MAX(ItemDescription), MarketKey, CurrAccCode, CustomerName,
|
|
SUM(Qty), SUM(AmountUSD), CASE WHEN SUM(Qty)=0 THEN 0 ELSE SUM(AmountUSD)/NULLIF(SUM(Qty),0) END,
|
|
COUNT(DISTINCT OrderHeaderID), COUNT(*), SUM(CASE WHEN DueDate < CAST(GETDATE() AS date) THEN Qty ELSE 0 END)
|
|
FROM OpenOrderLines
|
|
GROUP BY ProductCode, ColorCode, YakaKodu, MarketKey, CurrAccCode, CustomerName
|
|
ORDER BY SUM(AmountUSD) DESC, SUM(Qty) DESC
|
|
`, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := make([]models.ProductPerformanceOrderProductCustomerRow, 0, limit)
|
|
productCodes := make([]string, 0, limit)
|
|
stockKeys := make([]string, 0, limit)
|
|
colorCodes := make([]string, 0, limit)
|
|
seenProducts := map[string]bool{}
|
|
seenStockKeys := map[string]bool{}
|
|
for rows.Next() {
|
|
var r models.ProductPerformanceOrderProductCustomerRow
|
|
if err := rows.Scan(&r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.ItemDescription, &r.MarketKey, &r.CustomerCode, &r.CustomerName, &r.OrderQty, &r.OrderUSD, &r.AvgOrderPriceUSD, &r.OrderCount, &r.LineCount, &r.OverdueQty); err != nil {
|
|
return nil, err
|
|
}
|
|
normalizeProductPerformanceVariantCodes(&r.ProductCode, &r.ColorCode, &r.YakaKodu)
|
|
colorCodes = append(colorCodes, r.ColorCode)
|
|
out = append(out, r)
|
|
if !seenProducts[r.ProductCode] {
|
|
seenProducts[r.ProductCode] = true
|
|
productCodes = append(productCodes, r.ProductCode)
|
|
}
|
|
key := productPerformanceVariantKey(r.ProductCode, r.ColorCode, r.YakaKodu)
|
|
if !seenStockKeys[key] {
|
|
seenStockKeys[key] = true
|
|
stockKeys = append(stockKeys, key)
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
priceByProduct, err := productPerformancePriceLookup(ctx, pg, productCodes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stockByKey, err := productPerformanceStockLookup(ctx, pg, stockKeys)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
attrByKey, err := productPerformanceAttrLookup(ctx, pg, stockKeys)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
colorDescriptions := productPerformanceColorDescriptions(ctx, colorCodes)
|
|
filtered := out[:0]
|
|
for i := range out {
|
|
row := &out[i]
|
|
price := priceByProduct[normalizeProductPerformanceProductCode(row.ProductCode)]
|
|
attr := attrByKey[productPerformanceVariantKey(row.ProductCode, row.ColorCode, row.YakaKodu)]
|
|
if isExcludedProductPerformanceFirstGroup(attr.urunIlkGrubu) {
|
|
continue
|
|
}
|
|
if strings.TrimSpace(row.ItemDescription) == "" {
|
|
row.ItemDescription = attr.itemDescription
|
|
}
|
|
row.Kategori = attr.kategori
|
|
row.AskiliYan = cleanProductPerformanceOptionalAttr(attr.askiliYan)
|
|
row.UrunIlkGrubu = attr.urunIlkGrubu
|
|
row.UrunAnaGrubu = attr.urunAnaGrubu
|
|
row.UrunAltGrubu = attr.urunAltGrubu
|
|
row.CostPriceUSD = price.cost
|
|
row.BasePriceUSD = price.base
|
|
row.StockQty = stockByKey[productPerformanceVariantKey(row.ProductCode, row.ColorCode, row.YakaKodu)]
|
|
row.NetStockAfterOrder = row.StockQty - row.OrderQty
|
|
row.ExpectedProfitBaseUSD = row.OrderUSD - row.OrderQty*row.BasePriceUSD
|
|
row.ExpectedProfitCostUSD = row.OrderUSD - row.OrderQty*row.CostPriceUSD
|
|
if row.OrderUSD != 0 {
|
|
row.ExpectedMarginBase = row.ExpectedProfitBaseUSD / row.OrderUSD
|
|
row.ExpectedMarginCost = row.ExpectedProfitCostUSD / row.OrderUSD
|
|
}
|
|
row.ColorDescription = colorDescriptions[normalizeProductPerformanceCode(row.ColorCode)]
|
|
row.PerformanceBucket, row.Recommendation = productPerformanceOrderProductCustomerRecommendation(*row)
|
|
filtered = append(filtered, *row)
|
|
}
|
|
return filtered, nil
|
|
}
|
|
|
|
func ListProductPerformanceOrderMarketDetails(ctx context.Context, pg *sql.DB, limit int) ([]models.ProductPerformanceOrderMarketDetailRow, error) {
|
|
if db.MssqlDB == nil {
|
|
return nil, fmt.Errorf("mssql db nil")
|
|
}
|
|
if err := EnsureProductPerformanceTables(pg); err != nil {
|
|
return nil, err
|
|
}
|
|
if limit <= 0 {
|
|
limit = 800
|
|
} else if limit > 50000 {
|
|
limit = 50000
|
|
}
|
|
if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceOrderMarketDetailRow](ctx, pg, productPerformanceSnapshotKey("order-market-details"), limit); err != nil {
|
|
return nil, err
|
|
} else if ok {
|
|
return rows, nil
|
|
}
|
|
rows, err := db.MssqlDB.QueryContext(ctx, `
|
|
WITH OpenOrderLines AS (
|
|
SELECT
|
|
MarketKey = dbo.HG_Temizlik(ISNULL((SELECT AttributeDescription FROM cdCurrAccAttributeDesc WITH(NOLOCK) WHERE cdCurrAccAttributeDesc.CurrAccTypeCode = 3 AND cdCurrAccAttributeDesc.AttributeTypeCode = 1 AND cdCurrAccAttributeDesc.AttributeCode = caf.CustomerAtt01 AND cdCurrAccAttributeDesc.LangCode = 'TR'), SPACE(0))),
|
|
h.CurrAccCode,
|
|
CustomerName = ISNULL(cad.CurrAccDescription, ''),
|
|
h.OrderNumber,
|
|
OrderDate = CAST(h.OrderDate AS date),
|
|
DueDate = CAST(ISNULL(l.DeliveryDate, h.AverageDueDate) AS date),
|
|
ProductCode = UPPER(LTRIM(RTRIM(l.ItemCode))),
|
|
ColorCode = UPPER(LTRIM(RTRIM(ISNULL(l.ColorCode, '')))),
|
|
YakaKodu = UPPER(LTRIM(RTRIM(ISNULL(l.ItemDim2Code, '')))),
|
|
ItemDescription = dbo.HG_Temizlik(ISNULL((SELECT ItemDescription FROM cdItemDesc WITH(NOLOCK) WHERE cdItemDesc.ItemTypeCode = l.ItemTypeCode AND cdItemDesc.ItemCode = l.ItemCode AND cdItemDesc.LangCode = 'TR'), SPACE(0))),
|
|
Qty = ISNULL(l.Qty1, 0),
|
|
AmountUSD = CASE
|
|
WHEN h.DocCurrencyCode = 'USD' THEN ISNULL(c.NetAmount, 0)
|
|
WHEN h.DocCurrencyCode = 'TRY' AND usd.Rate > 0 THEN ISNULL(c.NetAmount, 0) / usd.Rate
|
|
WHEN h.DocCurrencyCode IN ('EUR', 'GBP') AND cur.Rate > 0 AND usd.Rate > 0 THEN (ISNULL(c.NetAmount, 0) * cur.Rate) / usd.Rate
|
|
ELSE 0
|
|
END
|
|
FROM dbo.trOrderHeader h WITH(NOLOCK)
|
|
INNER JOIN dbo.trOrderLine l WITH(NOLOCK) ON l.OrderHeaderID = h.OrderHeaderID
|
|
LEFT JOIN dbo.trOrderLineCurrency c WITH(NOLOCK) ON c.OrderLineID = l.OrderLineID AND c.CurrencyCode = ISNULL(h.DocCurrencyCode, 'TRY')
|
|
LEFT JOIN dbo.CustomerAttributesFilter caf WITH(NOLOCK) ON caf.CurrAccTypeCode = h.CurrAccTypeCode AND caf.CurrAccCode = h.CurrAccCode
|
|
LEFT JOIN dbo.cdCurrAccDesc cad WITH(NOLOCK) ON cad.CurrAccTypeCode = h.CurrAccTypeCode AND cad.CurrAccCode = h.CurrAccCode AND cad.LangCode = 'TR'
|
|
OUTER APPLY (SELECT TOP 1 Rate FROM dbo.AllExchangeRates WITH(NOLOCK) WHERE CurrencyCode = 'USD' AND RelationCurrencyCode = 'TRY' AND ExchangeTypeCode = 6 AND Rate > 0 AND Date <= CAST(ISNULL(h.OrderDate, GETDATE()) AS date) ORDER BY Date DESC) usd
|
|
OUTER APPLY (SELECT TOP 1 Rate FROM dbo.AllExchangeRates WITH(NOLOCK) WHERE CurrencyCode = h.DocCurrencyCode AND RelationCurrencyCode = 'TRY' AND ExchangeTypeCode = 6 AND Rate > 0 AND Date <= CAST(ISNULL(h.OrderDate, GETDATE()) AS date) ORDER BY Date DESC) cur
|
|
WHERE ISNULL(h.IsCancelOrder, 0) = 0
|
|
AND ISNULL(h.IsClosed, 0) = 0
|
|
AND h.OrderTypeCode = 1
|
|
AND h.ProcessCode = 'WS'
|
|
AND ISNULL(l.IsClosed, 0) = 0
|
|
AND l.ItemTypeCode = 1
|
|
AND ISNULL(l.Qty1, 0) > 0
|
|
AND LEN(LTRIM(RTRIM(l.ItemCode))) = 13
|
|
)
|
|
SELECT TOP (@p1)
|
|
MarketKey, CurrAccCode, CustomerName, OrderNumber, CONVERT(varchar, OrderDate, 23), CONVERT(varchar, DueDate, 23),
|
|
ProductCode, ColorCode, YakaKodu, MAX(ItemDescription),
|
|
SUM(Qty), SUM(AmountUSD), CASE WHEN SUM(Qty)=0 THEN 0 ELSE SUM(AmountUSD)/NULLIF(SUM(Qty),0) END,
|
|
CASE WHEN MIN(DueDate) < CAST(GETDATE() AS date) THEN CAST(1 AS bit) ELSE CAST(0 AS bit) END
|
|
FROM OpenOrderLines
|
|
GROUP BY MarketKey, CurrAccCode, CustomerName, OrderNumber, OrderDate, DueDate, ProductCode, ColorCode, YakaKodu
|
|
ORDER BY MarketKey, CustomerName, OrderDate DESC, OrderNumber, SUM(AmountUSD) DESC
|
|
`, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := make([]models.ProductPerformanceOrderMarketDetailRow, 0, limit)
|
|
productCodes := make([]string, 0, limit)
|
|
stockKeys := make([]string, 0, limit)
|
|
colorCodes := make([]string, 0, limit)
|
|
seenProducts := map[string]bool{}
|
|
seenStockKeys := map[string]bool{}
|
|
for rows.Next() {
|
|
var r models.ProductPerformanceOrderMarketDetailRow
|
|
if err := rows.Scan(&r.MarketKey, &r.CustomerCode, &r.CustomerName, &r.OrderNumber, &r.OrderDate, &r.DueDate, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.ItemDescription, &r.OrderQty, &r.OrderUSD, &r.AvgOrderPriceUSD, &r.IsOverdue); err != nil {
|
|
return nil, err
|
|
}
|
|
normalizeProductPerformanceVariantCodes(&r.ProductCode, &r.ColorCode, &r.YakaKodu)
|
|
colorCodes = append(colorCodes, r.ColorCode)
|
|
out = append(out, r)
|
|
if !seenProducts[r.ProductCode] {
|
|
seenProducts[r.ProductCode] = true
|
|
productCodes = append(productCodes, r.ProductCode)
|
|
}
|
|
key := productPerformanceVariantKey(r.ProductCode, r.ColorCode, r.YakaKodu)
|
|
if !seenStockKeys[key] {
|
|
seenStockKeys[key] = true
|
|
stockKeys = append(stockKeys, key)
|
|
}
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
priceByProduct, err := productPerformancePriceLookup(ctx, pg, productCodes)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
stockByKey, err := productPerformanceStockLookup(ctx, pg, stockKeys)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
attrByKey, err := productPerformanceAttrLookup(ctx, pg, stockKeys)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
colorDescriptions := productPerformanceColorDescriptions(ctx, colorCodes)
|
|
filtered := out[:0]
|
|
for i := range out {
|
|
row := &out[i]
|
|
price := priceByProduct[normalizeProductPerformanceProductCode(row.ProductCode)]
|
|
attr := attrByKey[productPerformanceVariantKey(row.ProductCode, row.ColorCode, row.YakaKodu)]
|
|
if isExcludedProductPerformanceFirstGroup(attr.urunIlkGrubu) {
|
|
continue
|
|
}
|
|
row.CostPriceUSD = price.cost
|
|
row.BasePriceUSD = price.base
|
|
row.StockQty = stockByKey[productPerformanceVariantKey(row.ProductCode, row.ColorCode, row.YakaKodu)]
|
|
row.NetStockAfterOrder = row.StockQty - row.OrderQty
|
|
row.ExpectedProfitBaseUSD = row.OrderUSD - row.OrderQty*row.BasePriceUSD
|
|
row.ExpectedProfitCostUSD = row.OrderUSD - row.OrderQty*row.CostPriceUSD
|
|
if row.OrderUSD != 0 {
|
|
row.ExpectedMarginBase = row.ExpectedProfitBaseUSD / row.OrderUSD
|
|
row.ExpectedMarginCost = row.ExpectedProfitCostUSD / row.OrderUSD
|
|
}
|
|
row.ColorDescription = colorDescriptions[normalizeProductPerformanceCode(row.ColorCode)]
|
|
row.PerformanceBucket, row.Recommendation = productPerformanceOrderMarketDetailRecommendation(*row)
|
|
filtered = append(filtered, *row)
|
|
}
|
|
return filtered, nil
|
|
}
|
|
|
|
func ListProductPerformanceMarkets(ctx context.Context, pg *sql.DB, limit int) ([]models.ProductPerformanceMarketRow, error) {
|
|
if err := EnsureProductPerformanceTables(pg); err != nil {
|
|
return nil, err
|
|
}
|
|
if limit <= 0 {
|
|
limit = 50000
|
|
} else if limit > 50000 {
|
|
limit = 50000
|
|
}
|
|
if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceMarketRow](ctx, pg, productPerformanceSnapshotKey("markets"), limit); err != nil {
|
|
return nil, err
|
|
} else if ok {
|
|
return rows, nil
|
|
}
|
|
rows, err := pg.QueryContext(ctx, `
|
|
SELECT
|
|
market_key,
|
|
MAX(kategori) AS kategori,
|
|
MAX(seri) AS seri,
|
|
MAX(yas_grubu) AS yas_grubu,
|
|
COALESCE(MAX(NULLIF(askili_yan, '-')), '') AS askili_yan,
|
|
MAX(urun_ilk_grubu) AS urun_ilk_grubu,
|
|
MAX(urun_ana_grubu) AS urun_ana_grubu,
|
|
MAX(urun_alt_grubu) AS urun_alt_grubu,
|
|
COUNT(DISTINCT product_code) AS product_count,
|
|
COUNT(*) FILTER (WHERE performance_bucket='YILDIZ_URUN') AS star_count,
|
|
COUNT(*) FILTER (WHERE performance_bucket='STOK_RISKI') AS stock_risk_count,
|
|
COALESCE(SUM(stock_qty),0) AS stock_qty,
|
|
COALESCE(SUM(stock_qty * cost_price_usd),0) AS stock_cost_value_usd,
|
|
COALESCE(SUM(CASE WHEN performance_bucket IN ('STOK_RISKI','TAKIP') AND COALESCE(sales_qty_90d,0)=0 THEN stock_qty * cost_price_usd ELSE 0 END),0) AS risk_stock_cost_value_usd,
|
|
COALESCE(SUM(sales_qty_90d),0) AS sales_qty_90d,
|
|
COALESCE(SUM(sales_usd_90d),0) AS sales_usd_90d,
|
|
COALESCE(SUM(gross_profit_usd_90d),0) AS gross_profit_usd_90d,
|
|
COALESCE(AVG(NULLIF(gross_margin_90d,0)),0) AS avg_gross_margin_90d,
|
|
COALESCE(AVG(NULLIF(stock_days_90d,0)),0) AS avg_stock_days_90d
|
|
FROM mk_product_performance_kpi_daily
|
|
WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)
|
|
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
|
|
GROUP BY market_key
|
|
ORDER BY risk_stock_cost_value_usd DESC, stock_cost_value_usd DESC, sales_usd_90d DESC
|
|
LIMIT $1
|
|
`, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := make([]models.ProductPerformanceMarketRow, 0, limit)
|
|
for rows.Next() {
|
|
var r models.ProductPerformanceMarketRow
|
|
if err := rows.Scan(
|
|
&r.MarketKey, &r.Kategori, &r.Seri, &r.YasGrubu, &r.AskiliYan, &r.UrunIlkGrubu, &r.UrunAnaGrubu, &r.UrunAltGrubu,
|
|
&r.ProductCount, &r.StarCount, &r.StockRiskCount, &r.StockQty,
|
|
&r.StockCostValueUSD, &r.RiskStockCostValueUSD, &r.SalesQty90,
|
|
&r.SalesUSD90, &r.GrossProfitUSD90, &r.AvgGrossMargin90, &r.AvgStockDays90,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
r.UrunIlkGrubu = cleanProductPerformanceFirstGroup(r.UrunIlkGrubu)
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func ListProductPerformanceCountries(ctx context.Context, pg *sql.DB, limit int) ([]models.ProductPerformanceCountryRow, error) {
|
|
if err := EnsureProductPerformanceTables(pg); err != nil {
|
|
return nil, err
|
|
}
|
|
if limit <= 0 {
|
|
limit = 50000
|
|
} else if limit > 50000 {
|
|
limit = 50000
|
|
}
|
|
if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceCountryRow](ctx, pg, productPerformanceSnapshotKey("countries"), limit); err != nil {
|
|
return nil, err
|
|
} else if ok {
|
|
return rows, nil
|
|
}
|
|
rows, err := pg.QueryContext(ctx, `
|
|
SELECT
|
|
customer_country,
|
|
customer_segment,
|
|
market_key,
|
|
MAX(kategori) AS kategori,
|
|
MAX(seri) AS seri,
|
|
COUNT(DISTINCT product_code) AS product_count,
|
|
COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) AS sales_qty_90d,
|
|
COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) AS sales_usd_90d,
|
|
CASE
|
|
WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)=0 THEN 0
|
|
ELSE COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)
|
|
/ NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)
|
|
END AS avg_price_usd_90d,
|
|
COUNT(DISTINCT NULLIF(customer_code, '-')) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days' AND COALESCE(sales_usd,0) > 0)::integer AS customer_count_90d,
|
|
COALESCE(SUM(invoice_count) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)::integer AS invoice_count_90d,
|
|
COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '364 days'),0) AS sales_qty_365d,
|
|
COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '364 days'),0) AS sales_usd_365d
|
|
FROM mk_product_performance_sales_daily
|
|
WHERE sales_date >= current_date - INTERVAL '364 days'
|
|
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
|
|
GROUP BY customer_country, customer_segment, market_key
|
|
ORDER BY sales_usd_90d DESC, sales_qty_90d DESC
|
|
LIMIT $1
|
|
`, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := make([]models.ProductPerformanceCountryRow, 0, limit)
|
|
for rows.Next() {
|
|
var r models.ProductPerformanceCountryRow
|
|
if err := rows.Scan(
|
|
&r.Country, &r.CustomerSegment, &r.MarketKey, &r.Kategori, &r.Seri,
|
|
&r.ProductCount, &r.SalesQty90, &r.SalesUSD90, &r.AvgPriceUSD90,
|
|
&r.CustomerCount90, &r.InvoiceCount90, &r.SalesQty365, &r.SalesUSD365,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func ListProductPerformanceCustomers(ctx context.Context, pg *sql.DB, breakdown string, limit int) ([]models.ProductPerformanceCustomerRow, error) {
|
|
if err := EnsureProductPerformanceTables(pg); err != nil {
|
|
return nil, err
|
|
}
|
|
if limit <= 0 {
|
|
limit = 50000
|
|
} else if limit > 50000 {
|
|
limit = 50000
|
|
}
|
|
mode := strings.ToLower(strings.TrimSpace(breakdown))
|
|
if mode == "" {
|
|
mode = "market_customer"
|
|
}
|
|
selectMarket := "''"
|
|
selectCountry := "''"
|
|
selectSegment := "''"
|
|
groupCols := []string{"customer_code", "customer_name"}
|
|
switch mode {
|
|
case "country_customer":
|
|
selectCountry = "customer_country"
|
|
selectSegment = "customer_segment"
|
|
groupCols = append(groupCols, "customer_country", "customer_segment")
|
|
case "market_country_customer":
|
|
selectMarket = "market_key"
|
|
selectCountry = "customer_country"
|
|
selectSegment = "customer_segment"
|
|
groupCols = append(groupCols, "market_key", "customer_country", "customer_segment")
|
|
default:
|
|
mode = "market_customer"
|
|
selectMarket = "market_key"
|
|
groupCols = append(groupCols, "market_key")
|
|
}
|
|
if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceCustomerRow](ctx, pg, productPerformanceSnapshotKey("customers", mode), limit); err != nil {
|
|
return nil, err
|
|
} else if ok {
|
|
return rows, nil
|
|
}
|
|
|
|
query := fmt.Sprintf(`
|
|
SELECT
|
|
$2::text AS breakdown,
|
|
%s AS market_key,
|
|
%s AS country,
|
|
%s AS customer_segment,
|
|
customer_code,
|
|
customer_name,
|
|
COUNT(DISTINCT product_code) AS product_count,
|
|
COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) AS sales_qty_90d,
|
|
COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) AS sales_usd_90d,
|
|
CASE
|
|
WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)=0 THEN 0
|
|
ELSE COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)
|
|
/ NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)
|
|
END AS avg_price_usd_90d,
|
|
COALESCE(SUM(invoice_count) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)::integer AS invoice_count_90d,
|
|
COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '364 days'),0) AS sales_qty_365d,
|
|
COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '364 days'),0) AS sales_usd_365d,
|
|
COALESCE(to_char(MAX(sales_date),'YYYY-MM-DD'),'') AS last_sale_date
|
|
FROM mk_product_performance_sales_daily
|
|
WHERE sales_date >= current_date - INTERVAL '364 days'
|
|
AND COALESCE(customer_code,'') <> ''
|
|
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
|
|
GROUP BY %s
|
|
ORDER BY sales_usd_90d DESC, sales_qty_90d DESC
|
|
LIMIT $1
|
|
`, selectMarket, selectCountry, selectSegment, strings.Join(groupCols, ", "))
|
|
|
|
rows, err := pg.QueryContext(ctx, query, limit, mode)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := make([]models.ProductPerformanceCustomerRow, 0, limit)
|
|
for rows.Next() {
|
|
var r models.ProductPerformanceCustomerRow
|
|
if err := rows.Scan(
|
|
&r.Breakdown, &r.MarketKey, &r.Country, &r.CustomerSegment, &r.CustomerCode, &r.CustomerName,
|
|
&r.ProductCount, &r.SalesQty90, &r.SalesUSD90, &r.AvgPriceUSD90, &r.InvoiceCount90,
|
|
&r.SalesQty365, &r.SalesUSD365, &r.LastSaleDate,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func ListProductPerformanceSalesBreakdown(ctx context.Context, pg *sql.DB, breakdown string, limit int) ([]models.ProductPerformanceSalesBreakdownRow, error) {
|
|
if err := EnsureProductPerformanceTables(pg); err != nil {
|
|
return nil, err
|
|
}
|
|
if limit <= 0 {
|
|
limit = 50000
|
|
} else if limit > 50000 {
|
|
limit = 50000
|
|
}
|
|
mode := strings.ToLower(strings.TrimSpace(breakdown))
|
|
fields := map[string]string{
|
|
"product_code": "s.product_code",
|
|
"color_code": "s.color_code",
|
|
"yaka_kodu": "s.yaka_kodu",
|
|
"item_description": "MAX(s.item_description)",
|
|
"kategori": "COALESCE(MAX(s.kategori),'')",
|
|
"askili_yan": "COALESCE(MAX(CASE WHEN btrim(COALESCE(s.askili_yan,'')) = '-' THEN '' ELSE s.askili_yan END),'')",
|
|
"urun_ilk_grubu": "COALESCE(MAX(" + productPerformanceCleanFirstGroupSQL("s.urun_ilk_grubu") + "),'')",
|
|
"urun_ana_grubu": "COALESCE(MAX(s.urun_ana_grubu),'')",
|
|
"urun_alt_grubu": "MAX(s.urun_alt_grubu)",
|
|
"market_key": "s.market_key",
|
|
"country": "s.customer_country",
|
|
"customer_segment": "s.customer_segment",
|
|
"customer_code": "s.customer_code",
|
|
"customer_name": "MAX(s.customer_name)",
|
|
}
|
|
selected := map[string]bool{}
|
|
groupCols := []string{}
|
|
switch mode {
|
|
case "color_yaka_market_customer":
|
|
selected["product_code"] = true
|
|
selected["color_code"] = true
|
|
selected["yaka_kodu"] = true
|
|
selected["item_description"] = true
|
|
selected["kategori"] = true
|
|
selected["askili_yan"] = true
|
|
selected["urun_ilk_grubu"] = true
|
|
selected["urun_ana_grubu"] = true
|
|
selected["urun_alt_grubu"] = true
|
|
selected["country"] = true
|
|
selected["customer_segment"] = true
|
|
selected["market_key"] = true
|
|
selected["customer_code"] = true
|
|
selected["customer_name"] = true
|
|
groupCols = []string{productPerformanceCleanFirstGroupSQL("s.urun_ilk_grubu"), "s.color_code", "s.yaka_kodu", "CASE WHEN btrim(COALESCE(s.askili_yan,'')) = '-' THEN '' ELSE s.askili_yan END", "s.kategori", "s.urun_ana_grubu", "s.urun_alt_grubu", "s.product_code", "s.customer_country", "s.market_key", "s.customer_segment", "s.customer_code"}
|
|
case "product_country_segment_market_customer":
|
|
selected["product_code"] = true
|
|
selected["item_description"] = true
|
|
selected["kategori"] = true
|
|
selected["askili_yan"] = true
|
|
selected["urun_ilk_grubu"] = true
|
|
selected["urun_ana_grubu"] = true
|
|
selected["urun_alt_grubu"] = true
|
|
selected["color_code"] = true
|
|
selected["yaka_kodu"] = true
|
|
selected["country"] = true
|
|
selected["customer_segment"] = true
|
|
selected["market_key"] = true
|
|
selected["customer_code"] = true
|
|
selected["customer_name"] = true
|
|
groupCols = []string{"s.market_key", "s.customer_code", "s.product_code", "s.color_code", "s.yaka_kodu", "s.customer_country", "s.customer_segment"}
|
|
case "market_customer_product":
|
|
selected["market_key"] = true
|
|
selected["customer_code"] = true
|
|
selected["customer_name"] = true
|
|
selected["product_code"] = true
|
|
selected["color_code"] = true
|
|
selected["yaka_kodu"] = true
|
|
selected["item_description"] = true
|
|
selected["kategori"] = true
|
|
selected["askili_yan"] = true
|
|
selected["urun_ilk_grubu"] = true
|
|
selected["urun_ana_grubu"] = true
|
|
selected["urun_alt_grubu"] = true
|
|
groupCols = []string{"s.market_key", "s.customer_code", "s.product_code", "s.color_code", "s.yaka_kodu"}
|
|
case "country_segment_market_customer_product":
|
|
selected["country"] = true
|
|
selected["customer_segment"] = true
|
|
selected["market_key"] = true
|
|
selected["customer_code"] = true
|
|
selected["customer_name"] = true
|
|
selected["product_code"] = true
|
|
selected["color_code"] = true
|
|
selected["yaka_kodu"] = true
|
|
selected["item_description"] = true
|
|
selected["kategori"] = true
|
|
selected["askili_yan"] = true
|
|
selected["urun_ilk_grubu"] = true
|
|
selected["urun_ana_grubu"] = true
|
|
selected["urun_alt_grubu"] = true
|
|
groupCols = []string{"s.customer_country", "s.customer_segment", "s.market_key", "s.customer_code", "s.product_code", "s.color_code", "s.yaka_kodu"}
|
|
default:
|
|
mode = "color_yaka_market_customer"
|
|
selected["product_code"] = true
|
|
selected["color_code"] = true
|
|
selected["yaka_kodu"] = true
|
|
selected["item_description"] = true
|
|
selected["kategori"] = true
|
|
selected["askili_yan"] = true
|
|
selected["urun_ilk_grubu"] = true
|
|
selected["urun_ana_grubu"] = true
|
|
selected["urun_alt_grubu"] = true
|
|
selected["country"] = true
|
|
selected["customer_segment"] = true
|
|
selected["market_key"] = true
|
|
selected["customer_code"] = true
|
|
selected["customer_name"] = true
|
|
groupCols = []string{productPerformanceCleanFirstGroupSQL("s.urun_ilk_grubu"), "s.color_code", "s.yaka_kodu", "CASE WHEN btrim(COALESCE(s.askili_yan,'')) = '-' THEN '' ELSE s.askili_yan END", "s.kategori", "s.urun_ana_grubu", "s.urun_alt_grubu", "s.product_code", "s.customer_country", "s.market_key", "s.customer_segment", "s.customer_code"}
|
|
}
|
|
|
|
if rows, ok, err := loadProductPerformanceSnapshotRows[models.ProductPerformanceSalesBreakdownRow](ctx, pg, productPerformanceSnapshotKey("sales-breakdown", mode), limit); err != nil {
|
|
return nil, err
|
|
} else if ok {
|
|
applyProductPerformanceSalesBreakdownScores(rows)
|
|
return rows, nil
|
|
}
|
|
|
|
selectExpr := func(name string) string {
|
|
if selected[name] {
|
|
return fields[name] + " AS " + name
|
|
}
|
|
return "''::text AS " + name
|
|
}
|
|
|
|
query := fmt.Sprintf(`
|
|
WITH LatestKPI AS (
|
|
SELECT DISTINCT ON (product_code, color_code, yaka_kodu)
|
|
product_code,
|
|
color_code,
|
|
yaka_kodu,
|
|
COALESCE(kategori,'') AS kategori,
|
|
CASE WHEN btrim(COALESCE(askili_yan,'')) = '-' THEN '' ELSE COALESCE(askili_yan,'') END AS askili_yan,
|
|
CASE
|
|
WHEN btrim(COALESCE(urun_ilk_grubu,'')) = '-' THEN ''
|
|
WHEN upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON') THEN ''
|
|
ELSE COALESCE(urun_ilk_grubu,'')
|
|
END AS urun_ilk_grubu,
|
|
COALESCE(urun_ana_grubu,'') AS urun_ana_grubu,
|
|
urun_alt_grubu
|
|
FROM mk_product_performance_kpi_daily
|
|
WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)
|
|
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
|
|
ORDER BY product_code, color_code, yaka_kodu, performance_score DESC
|
|
),
|
|
StockAgg AS (
|
|
SELECT
|
|
product_code,
|
|
color_code,
|
|
yaka_kodu,
|
|
COALESCE(SUM(stock_qty),0) AS stock_qty
|
|
FROM mk_product_performance_stock_daily
|
|
WHERE stock_date = (SELECT MAX(stock_date) FROM mk_product_performance_stock_daily)
|
|
GROUP BY product_code, color_code, yaka_kodu
|
|
),
|
|
Stock90Start AS (
|
|
SELECT DISTINCT ON (product_code, color_code, yaka_kodu)
|
|
product_code,
|
|
color_code,
|
|
yaka_kodu,
|
|
stock_qty
|
|
FROM mk_product_performance_stock_daily
|
|
WHERE stock_date <= current_date - INTERVAL '89 days'
|
|
ORDER BY product_code, color_code, yaka_kodu, stock_date DESC
|
|
),
|
|
Stock180Start AS (
|
|
SELECT DISTINCT ON (product_code, color_code, yaka_kodu)
|
|
product_code,
|
|
color_code,
|
|
yaka_kodu,
|
|
stock_qty
|
|
FROM mk_product_performance_stock_daily
|
|
WHERE stock_date <= current_date - INTERVAL '179 days'
|
|
ORDER BY product_code, color_code, yaka_kodu, stock_date DESC
|
|
),
|
|
Stock365Start AS (
|
|
SELECT DISTINCT ON (product_code, color_code, yaka_kodu)
|
|
product_code,
|
|
color_code,
|
|
yaka_kodu,
|
|
stock_qty
|
|
FROM mk_product_performance_stock_daily
|
|
WHERE stock_date <= current_date - INTERVAL '359 days'
|
|
ORDER BY product_code, color_code, yaka_kodu, stock_date DESC
|
|
),
|
|
StockTotalStart AS (
|
|
SELECT DISTINCT ON (product_code, color_code, yaka_kodu)
|
|
product_code,
|
|
color_code,
|
|
yaka_kodu,
|
|
stock_qty
|
|
FROM mk_product_performance_stock_daily
|
|
WHERE stock_date <= DATE '2022-01-01'
|
|
ORDER BY product_code, color_code, yaka_kodu, stock_date DESC
|
|
),
|
|
FirstSaleByProduct AS (
|
|
SELECT
|
|
product_code,
|
|
color_code,
|
|
yaka_kodu,
|
|
MIN(sales_date) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days') AS first_sale_date_90d,
|
|
MIN(sales_date) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days') AS first_sale_date_180d,
|
|
MIN(sales_date) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days') AS first_sale_date_365d,
|
|
MIN(sales_date) FILTER (WHERE sales_date >= DATE '2022-01-01') AS first_sale_date_total
|
|
FROM mk_product_performance_sales_daily
|
|
WHERE sales_date >= DATE '2022-01-01'
|
|
GROUP BY product_code, color_code, yaka_kodu
|
|
),
|
|
Agg AS (
|
|
SELECT
|
|
$2::text AS breakdown,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
%s,
|
|
COUNT(DISTINCT product_code) AS product_count,
|
|
COUNT(DISTINCT COALESCE(NULLIF(urun_ana_grubu,''), NULLIF(urun_alt_grubu,''), NULLIF(kategori,''), product_code)) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days')::integer AS product_group_count_90d,
|
|
COUNT(DISTINCT COALESCE(NULLIF(urun_ana_grubu,''), NULLIF(urun_alt_grubu,''), NULLIF(kategori,''), product_code)) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days')::integer AS product_group_count_180d,
|
|
COUNT(DISTINCT COALESCE(NULLIF(urun_ana_grubu,''), NULLIF(urun_alt_grubu,''), NULLIF(kategori,''), product_code)) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days')::integer AS product_group_count_365d,
|
|
COUNT(DISTINCT COALESCE(NULLIF(urun_ana_grubu,''), NULLIF(urun_alt_grubu,''), NULLIF(kategori,''), product_code)) FILTER (WHERE sales_date >= DATE '2022-01-01')::integer AS product_group_count_total,
|
|
COUNT(DISTINCT NULLIF(NULLIF(market_key,'STOK'),'')) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days' AND COALESCE(sales_usd,0) > 0)::integer AS market_count_90d,
|
|
COUNT(DISTINCT NULLIF(NULLIF(market_key,'STOK'),'')) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days' AND COALESCE(sales_usd,0) > 0)::integer AS market_count_180d,
|
|
COUNT(DISTINCT NULLIF(NULLIF(market_key,'STOK'),'')) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days' AND COALESCE(sales_usd,0) > 0)::integer AS market_count_365d,
|
|
COUNT(DISTINCT NULLIF(NULLIF(market_key,'STOK'),'')) FILTER (WHERE sales_date >= DATE '2022-01-01' AND COALESCE(sales_usd,0) > 0)::integer AS market_count_total,
|
|
COALESCE(MAX(stock_qty),0) AS stock_qty,
|
|
COALESCE(MAX(avg_stock_90d),0) AS avg_stock_90d,
|
|
COALESCE(MAX(avg_stock_180d),0) AS avg_stock_180d,
|
|
COALESCE(MAX(avg_stock_365d),0) AS avg_stock_365d,
|
|
COALESCE(MAX(avg_stock_total),0) AS avg_stock_total,
|
|
COUNT(DISTINCT customer_code) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days' AND COALESCE(customer_code,'') <> '' AND COALESCE(sales_usd,0) > 0)::integer AS customer_count_90d,
|
|
COALESCE(SUM(invoice_count) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)::integer AS invoice_count_90d,
|
|
COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) AS sales_qty_90d,
|
|
COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0) AS sales_usd_90d,
|
|
CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)=0 THEN 0
|
|
ELSE COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)
|
|
/ NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)
|
|
END AS avg_price_usd_90d,
|
|
CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)=0 THEN 0
|
|
ELSE COALESCE(SUM(sales_qty * base_price_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)
|
|
/ NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)
|
|
END AS base_price_usd_90d,
|
|
CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)=0 THEN 0
|
|
ELSE COALESCE(SUM(sales_qty * cost_price_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)
|
|
/ NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '89 days'),0)
|
|
END AS cost_price_usd_90d,
|
|
COUNT(DISTINCT customer_code) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days' AND COALESCE(customer_code,'') <> '' AND COALESCE(sales_usd,0) > 0)::integer AS customer_count_180d,
|
|
COALESCE(SUM(invoice_count) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0)::integer AS invoice_count_180d,
|
|
COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0) AS sales_qty_180d,
|
|
COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0) AS sales_usd_180d,
|
|
CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0)=0 THEN 0
|
|
ELSE COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0)
|
|
/ NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0)
|
|
END AS avg_price_usd_180d,
|
|
CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0)=0 THEN 0
|
|
ELSE COALESCE(SUM(sales_qty * base_price_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0)
|
|
/ NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0)
|
|
END AS base_price_usd_180d,
|
|
CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0)=0 THEN 0
|
|
ELSE COALESCE(SUM(sales_qty * cost_price_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0)
|
|
/ NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '179 days'),0)
|
|
END AS cost_price_usd_180d,
|
|
COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0) AS sales_qty_365d,
|
|
COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0) AS sales_usd_365d,
|
|
COUNT(DISTINCT customer_code) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days' AND COALESCE(customer_code,'') <> '' AND COALESCE(sales_usd,0) > 0)::integer AS customer_count_365d,
|
|
COALESCE(SUM(invoice_count) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)::integer AS invoice_count_365d,
|
|
CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)=0 THEN 0
|
|
ELSE COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)
|
|
/ NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)
|
|
END AS avg_price_usd_365d,
|
|
CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)=0 THEN 0
|
|
ELSE COALESCE(SUM(sales_qty * base_price_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)
|
|
/ NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)
|
|
END AS base_price_usd_365d,
|
|
CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)=0 THEN 0
|
|
ELSE COALESCE(SUM(sales_qty * cost_price_usd) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)
|
|
/ NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= current_date - INTERVAL '359 days'),0)
|
|
END AS cost_price_usd_365d,
|
|
COUNT(DISTINCT customer_code) FILTER (WHERE sales_date >= DATE '2022-01-01' AND COALESCE(customer_code,'') <> '' AND COALESCE(sales_usd,0) > 0)::integer AS customer_count_total,
|
|
COALESCE(SUM(invoice_count) FILTER (WHERE sales_date >= DATE '2022-01-01'),0)::integer AS invoice_count_total,
|
|
COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= DATE '2022-01-01'),0) AS sales_qty_total,
|
|
COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= DATE '2022-01-01'),0) AS sales_usd_total,
|
|
CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= DATE '2022-01-01'),0)=0 THEN 0
|
|
ELSE COALESCE(SUM(sales_usd) FILTER (WHERE sales_date >= DATE '2022-01-01'),0)
|
|
/ NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= DATE '2022-01-01'),0)
|
|
END AS avg_price_usd_total,
|
|
CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= DATE '2022-01-01'),0)=0 THEN 0
|
|
ELSE COALESCE(SUM(sales_qty * base_price_usd) FILTER (WHERE sales_date >= DATE '2022-01-01'),0)
|
|
/ NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= DATE '2022-01-01'),0)
|
|
END AS base_price_usd_total,
|
|
CASE WHEN COALESCE(SUM(sales_qty) FILTER (WHERE sales_date >= DATE '2022-01-01'),0)=0 THEN 0
|
|
ELSE COALESCE(SUM(sales_qty * cost_price_usd) FILTER (WHERE sales_date >= DATE '2022-01-01'),0)
|
|
/ NULLIF(SUM(sales_qty) FILTER (WHERE sales_date >= DATE '2022-01-01'),0)
|
|
END AS cost_price_usd_total,
|
|
COALESCE(to_char(MAX(sales_date),'YYYY-MM-DD'),'') AS last_sale_date
|
|
FROM (
|
|
SELECT
|
|
s.*,
|
|
COALESCE(pd.base_price_usd,0) AS base_price_usd,
|
|
COALESCE(pd.cost_price_usd,0) AS cost_price_usd,
|
|
COALESCE(st.stock_qty,0) AS stock_qty,
|
|
(COALESCE(s90.stock_qty, st.stock_qty, 0) + COALESCE(st.stock_qty,0)) / 2.0 AS avg_stock_90d,
|
|
(COALESCE(s180.stock_qty, st.stock_qty, 0) + COALESCE(st.stock_qty,0)) / 2.0 AS avg_stock_180d,
|
|
(COALESCE(s365.stock_qty, st.stock_qty, 0) + COALESCE(st.stock_qty,0)) / 2.0 AS avg_stock_365d,
|
|
(COALESCE(stotal.stock_qty, st.stock_qty, 0) + COALESCE(st.stock_qty,0)) / 2.0 AS avg_stock_total
|
|
FROM mk_product_performance_sales_daily s
|
|
LEFT JOIN mk_product_performance_price_dim pd ON pd.product_code = s.product_code
|
|
LEFT JOIN LatestKPI k ON k.product_code = s.product_code AND k.color_code = s.color_code AND k.yaka_kodu = s.yaka_kodu
|
|
LEFT JOIN FirstSaleByProduct fs ON fs.product_code = s.product_code AND fs.color_code = s.color_code AND fs.yaka_kodu = s.yaka_kodu
|
|
LEFT JOIN StockAgg st ON st.product_code = s.product_code AND st.color_code = s.color_code AND st.yaka_kodu = s.yaka_kodu
|
|
LEFT JOIN LATERAL (
|
|
SELECT x.stock_qty
|
|
FROM mk_product_performance_stock_daily x
|
|
WHERE x.product_code = s.product_code
|
|
AND x.color_code = s.color_code
|
|
AND x.yaka_kodu = s.yaka_kodu
|
|
AND x.stock_date <= COALESCE(fs.first_sale_date_90d, current_date - INTERVAL '89 days')
|
|
ORDER BY x.stock_date DESC
|
|
LIMIT 1
|
|
) s90 ON TRUE
|
|
LEFT JOIN LATERAL (
|
|
SELECT x.stock_qty
|
|
FROM mk_product_performance_stock_daily x
|
|
WHERE x.product_code = s.product_code
|
|
AND x.color_code = s.color_code
|
|
AND x.yaka_kodu = s.yaka_kodu
|
|
AND x.stock_date <= COALESCE(fs.first_sale_date_180d, current_date - INTERVAL '179 days')
|
|
ORDER BY x.stock_date DESC
|
|
LIMIT 1
|
|
) s180 ON TRUE
|
|
LEFT JOIN LATERAL (
|
|
SELECT x.stock_qty
|
|
FROM mk_product_performance_stock_daily x
|
|
WHERE x.product_code = s.product_code
|
|
AND x.color_code = s.color_code
|
|
AND x.yaka_kodu = s.yaka_kodu
|
|
AND x.stock_date <= COALESCE(fs.first_sale_date_365d, current_date - INTERVAL '359 days')
|
|
ORDER BY x.stock_date DESC
|
|
LIMIT 1
|
|
) s365 ON TRUE
|
|
LEFT JOIN LATERAL (
|
|
SELECT x.stock_qty
|
|
FROM mk_product_performance_stock_daily x
|
|
WHERE x.product_code = s.product_code
|
|
AND x.color_code = s.color_code
|
|
AND x.yaka_kodu = s.yaka_kodu
|
|
AND x.stock_date <= COALESCE(fs.first_sale_date_total, DATE '2022-01-01')
|
|
ORDER BY x.stock_date DESC
|
|
LIMIT 1
|
|
) stotal ON TRUE
|
|
) s
|
|
WHERE sales_date >= DATE '2022-01-01'
|
|
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
|
|
GROUP BY %s
|
|
),
|
|
Enriched AS (
|
|
SELECT
|
|
Agg.*,
|
|
sales_usd_90d - (sales_qty_90d * base_price_usd_90d) AS gross_profit_base_usd_90d,
|
|
sales_usd_90d - (sales_qty_90d * cost_price_usd_90d) AS gross_profit_cost_usd_90d,
|
|
CASE WHEN sales_usd_90d <= 0 THEN 0 ELSE (sales_usd_90d - (sales_qty_90d * base_price_usd_90d)) / NULLIF(sales_usd_90d,0) END AS gross_margin_base_90d,
|
|
CASE WHEN sales_usd_90d <= 0 THEN 0 ELSE (sales_usd_90d - (sales_qty_90d * cost_price_usd_90d)) / NULLIF(sales_usd_90d,0) END AS gross_margin_cost_90d,
|
|
sales_usd_180d - (sales_qty_180d * base_price_usd_180d) AS gross_profit_base_usd_180d,
|
|
sales_usd_180d - (sales_qty_180d * cost_price_usd_180d) AS gross_profit_cost_usd_180d,
|
|
CASE WHEN sales_usd_180d <= 0 THEN 0 ELSE (sales_usd_180d - (sales_qty_180d * base_price_usd_180d)) / NULLIF(sales_usd_180d,0) END AS gross_margin_base_180d,
|
|
CASE WHEN sales_usd_180d <= 0 THEN 0 ELSE (sales_usd_180d - (sales_qty_180d * cost_price_usd_180d)) / NULLIF(sales_usd_180d,0) END AS gross_margin_cost_180d,
|
|
sales_usd_365d - (sales_qty_365d * base_price_usd_365d) AS gross_profit_base_usd_365d,
|
|
sales_usd_365d - (sales_qty_365d * cost_price_usd_365d) AS gross_profit_cost_usd_365d,
|
|
CASE WHEN sales_usd_365d <= 0 THEN 0 ELSE (sales_usd_365d - (sales_qty_365d * base_price_usd_365d)) / NULLIF(sales_usd_365d,0) END AS gross_margin_base_365d,
|
|
CASE WHEN sales_usd_365d <= 0 THEN 0 ELSE (sales_usd_365d - (sales_qty_365d * cost_price_usd_365d)) / NULLIF(sales_usd_365d,0) END AS gross_margin_cost_365d,
|
|
sales_usd_total - (sales_qty_total * base_price_usd_total) AS gross_profit_base_usd_total,
|
|
sales_usd_total - (sales_qty_total * cost_price_usd_total) AS gross_profit_cost_usd_total,
|
|
CASE WHEN sales_usd_total <= 0 THEN 0 ELSE (sales_usd_total - (sales_qty_total * base_price_usd_total)) / NULLIF(sales_usd_total,0) END AS gross_margin_base_total,
|
|
CASE WHEN sales_usd_total <= 0 THEN 0 ELSE (sales_usd_total - (sales_qty_total * cost_price_usd_total)) / NULLIF(sales_usd_total,0) END AS gross_margin_cost_total,
|
|
(base_price_usd_total > 0 AND cost_price_usd_total > 0) AS has_cost
|
|
FROM Agg
|
|
),
|
|
Scored AS (
|
|
SELECT
|
|
Enriched.*,
|
|
CASE WHEN AVG(NULLIF(sales_usd_90d,0)) OVER () IS NULL THEN 0
|
|
ELSE sales_usd_90d / NULLIF(AVG(NULLIF(sales_usd_90d,0)) OVER (),0)
|
|
END AS sales_index_90d
|
|
FROM Enriched
|
|
)
|
|
SELECT
|
|
breakdown,
|
|
product_code,
|
|
color_code,
|
|
yaka_kodu,
|
|
item_description,
|
|
kategori,
|
|
askili_yan,
|
|
urun_ilk_grubu,
|
|
urun_ana_grubu,
|
|
urun_alt_grubu,
|
|
market_key,
|
|
country,
|
|
customer_segment,
|
|
customer_code,
|
|
customer_name,
|
|
product_count,
|
|
product_group_count_90d,
|
|
product_group_count_180d,
|
|
product_group_count_365d,
|
|
product_group_count_total,
|
|
market_count_90d,
|
|
market_count_180d,
|
|
market_count_365d,
|
|
market_count_total,
|
|
stock_qty,
|
|
customer_count_90d,
|
|
invoice_count_90d,
|
|
sales_qty_90d,
|
|
sales_usd_90d,
|
|
avg_price_usd_90d,
|
|
base_price_usd_90d,
|
|
cost_price_usd_90d,
|
|
gross_profit_base_usd_90d,
|
|
gross_profit_cost_usd_90d,
|
|
gross_margin_base_90d,
|
|
gross_margin_cost_90d,
|
|
customer_count_180d,
|
|
invoice_count_180d,
|
|
sales_qty_180d,
|
|
sales_usd_180d,
|
|
avg_price_usd_180d,
|
|
base_price_usd_180d,
|
|
cost_price_usd_180d,
|
|
gross_profit_base_usd_180d,
|
|
gross_profit_cost_usd_180d,
|
|
gross_margin_base_180d,
|
|
gross_margin_cost_180d,
|
|
sales_qty_365d,
|
|
sales_usd_365d,
|
|
avg_price_usd_365d,
|
|
base_price_usd_365d,
|
|
cost_price_usd_365d,
|
|
gross_profit_base_usd_365d,
|
|
gross_profit_cost_usd_365d,
|
|
gross_margin_base_365d,
|
|
gross_margin_cost_365d,
|
|
customer_count_365d,
|
|
invoice_count_365d,
|
|
customer_count_total,
|
|
invoice_count_total,
|
|
sales_qty_total,
|
|
sales_usd_total,
|
|
avg_price_usd_total,
|
|
base_price_usd_total,
|
|
cost_price_usd_total,
|
|
gross_profit_base_usd_total,
|
|
gross_profit_cost_usd_total,
|
|
gross_margin_base_total,
|
|
gross_margin_cost_total,
|
|
avg_stock_90d,
|
|
avg_stock_180d,
|
|
avg_stock_365d,
|
|
avg_stock_total,
|
|
CASE WHEN avg_stock_90d > 0 THEN sales_qty_90d / NULLIF(avg_stock_90d,0) * 4.0 ELSE 0 END AS stock_turnover_90d,
|
|
CASE WHEN avg_stock_180d > 0 THEN sales_qty_180d / NULLIF(avg_stock_180d,0) * 2.0 ELSE 0 END AS stock_turnover_180d,
|
|
CASE WHEN avg_stock_365d > 0 THEN sales_qty_365d / NULLIF(avg_stock_365d,0) ELSE 0 END AS stock_turnover_365d,
|
|
CASE WHEN avg_stock_total > 0 THEN sales_qty_total / NULLIF(avg_stock_total,0) * 360.0 / GREATEST(1, (current_date - DATE '2022-01-01') + 1) ELSE 0 END AS stock_turnover_total,
|
|
has_cost,
|
|
sales_index_90d,
|
|
CASE WHEN NOT has_cost THEN 0 WHEN sales_usd_90d <= 0 THEN 1 ELSE
|
|
ROUND((
|
|
LEAST(35, sales_usd_90d / 25000 * 35)
|
|
+ LEAST(30, GREATEST(gross_margin_cost_90d,0) / 0.45 * 30)
|
|
+ LEAST(20, product_group_count_90d / 8.0 * 20)
|
|
+ LEAST(15, sales_qty_90d / 500 * 15)
|
|
)::numeric, 4)
|
|
END AS customer_score_90d,
|
|
CASE WHEN NOT has_cost THEN 0 WHEN sales_usd_180d <= 0 THEN 1 ELSE
|
|
ROUND((
|
|
LEAST(35, sales_usd_180d / 50000 * 35)
|
|
+ LEAST(30, GREATEST(gross_margin_cost_180d,0) / 0.45 * 30)
|
|
+ LEAST(20, product_group_count_180d / 8.0 * 20)
|
|
+ LEAST(15, sales_qty_180d / 1000 * 15)
|
|
)::numeric, 4)
|
|
END AS customer_score_180d,
|
|
CASE WHEN NOT has_cost THEN 0 WHEN sales_usd_365d <= 0 THEN 1 ELSE
|
|
ROUND((
|
|
LEAST(35, sales_usd_365d / 100000 * 35)
|
|
+ LEAST(30, GREATEST(gross_margin_cost_365d,0) / 0.45 * 30)
|
|
+ LEAST(20, product_group_count_365d / 8.0 * 20)
|
|
+ LEAST(15, sales_qty_365d / 2000 * 15)
|
|
)::numeric, 4)
|
|
END AS customer_score_365d,
|
|
CASE WHEN NOT has_cost THEN 0 WHEN sales_usd_total <= 0 THEN 1 ELSE
|
|
ROUND((
|
|
LEAST(35, sales_usd_total / 300000 * 35)
|
|
+ LEAST(30, GREATEST(gross_margin_cost_total,0) / 0.45 * 30)
|
|
+ LEAST(20, product_group_count_total / 8.0 * 20)
|
|
+ LEAST(15, sales_qty_total / 6000 * 15)
|
|
)::numeric, 4)
|
|
END AS customer_score_total,
|
|
CASE WHEN NOT has_cost THEN 0 WHEN sales_usd_90d <= 0 THEN 1 ELSE
|
|
ROUND(
|
|
LEAST(30, GREATEST(gross_margin_cost_90d,0) / 0.45 * 30)
|
|
+ LEAST(20, (CASE WHEN avg_stock_90d > 0 THEN sales_qty_90d / NULLIF(avg_stock_90d,0) * 4.0 ELSE 0 END) / 4.0 * 20)
|
|
+ LEAST(20, sales_usd_90d / 25000 * 20)
|
|
+ LEAST(5, market_count_90d / 3.0 * 5)
|
|
+ LEAST(25, customer_count_90d / 8.0 * 25),
|
|
4
|
|
)
|
|
END AS performance_score,
|
|
CASE
|
|
WHEN NOT has_cost THEN 'MALIYET_YOK'
|
|
WHEN sales_index_90d >= 1.25 AND customer_count_90d >= 2 THEN 'YILDIZ_URUN'
|
|
WHEN sales_qty_90d = 0 AND sales_qty_365d > 0 THEN 'STOK_RISKI'
|
|
WHEN gross_margin_cost_90d < 0 THEN 'FIYAT_BASKISI'
|
|
WHEN sales_index_90d < 0.75 THEN 'TAKIP'
|
|
ELSE 'TAKIP'
|
|
END AS performance_bucket,
|
|
CASE
|
|
WHEN sales_index_90d >= 1.25 AND customer_count_90d >= 2 THEN 'Guclu satis kirilimi'
|
|
WHEN sales_qty_90d = 0 AND sales_qty_365d > 0 THEN 'Son 90 gun zayif, kontrol et'
|
|
ELSE 'Takip'
|
|
END AS recommendation,
|
|
last_sale_date
|
|
FROM Scored
|
|
ORDER BY sales_usd_90d DESC, sales_qty_90d DESC
|
|
LIMIT $1
|
|
`,
|
|
selectExpr("product_code"),
|
|
selectExpr("color_code"),
|
|
selectExpr("yaka_kodu"),
|
|
selectExpr("item_description"),
|
|
selectExpr("kategori"),
|
|
selectExpr("askili_yan"),
|
|
selectExpr("urun_ilk_grubu"),
|
|
selectExpr("urun_ana_grubu"),
|
|
selectExpr("urun_alt_grubu"),
|
|
selectExpr("market_key"),
|
|
selectExpr("country"),
|
|
selectExpr("customer_segment"),
|
|
selectExpr("customer_code"),
|
|
selectExpr("customer_name"),
|
|
strings.Join(groupCols, ", "),
|
|
)
|
|
|
|
rows, err := pg.QueryContext(ctx, query, limit, mode)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := make([]models.ProductPerformanceSalesBreakdownRow, 0, limit)
|
|
colorCodes := make([]string, 0, limit)
|
|
for rows.Next() {
|
|
var r models.ProductPerformanceSalesBreakdownRow
|
|
if err := rows.Scan(
|
|
&r.Breakdown, &r.ProductCode, &r.ColorCode, &r.YakaKodu, &r.ItemDescription,
|
|
&r.Kategori, &r.AskiliYan, &r.UrunIlkGrubu, &r.UrunAnaGrubu, &r.UrunAltGrubu,
|
|
&r.MarketKey, &r.Country, &r.CustomerSegment, &r.CustomerCode, &r.CustomerName,
|
|
&r.ProductCount, &r.ProductGroupCount90, &r.ProductGroupCount180, &r.ProductGroupCount365, &r.ProductGroupCountTotal,
|
|
&r.MarketCount90, &r.MarketCount180, &r.MarketCount365, &r.MarketCountTotal,
|
|
&r.StockQty, &r.CustomerCount90, &r.InvoiceCount90, &r.SalesQty90, &r.SalesUSD90,
|
|
&r.AvgPriceUSD90, &r.BasePriceUSD90, &r.CostPriceUSD90, &r.GrossProfitBase90,
|
|
&r.GrossProfitCost90, &r.GrossMarginBase90, &r.GrossMarginCost90, &r.CustomerCount180,
|
|
&r.InvoiceCount180, &r.SalesQty180, &r.SalesUSD180, &r.AvgPriceUSD180, &r.BasePriceUSD180,
|
|
&r.CostPriceUSD180, &r.GrossProfitBase180, &r.GrossProfitCost180, &r.GrossMarginBase180,
|
|
&r.GrossMarginCost180, &r.SalesQty365, &r.SalesUSD365, &r.AvgPriceUSD365, &r.BasePriceUSD365,
|
|
&r.CostPriceUSD365, &r.GrossProfitBase365, &r.GrossProfitCost365, &r.GrossMarginBase365,
|
|
&r.GrossMarginCost365, &r.CustomerCount365, &r.InvoiceCount365, &r.CustomerCountTotal, &r.InvoiceCountTotal, &r.SalesQtyTotal, &r.SalesUSDTotal,
|
|
&r.AvgPriceUSDTotal, &r.BasePriceUSDTotal, &r.CostPriceUSDTotal, &r.GrossProfitBaseTotal,
|
|
&r.GrossProfitCostTotal, &r.GrossMarginBaseTotal, &r.GrossMarginCostTotal,
|
|
&r.AvgStock90, &r.AvgStock180, &r.AvgStock365, &r.AvgStockTotal,
|
|
&r.StockTurnover90, &r.StockTurnover180, &r.StockTurnover365, &r.StockTurnoverTotal, &r.HasCost,
|
|
&r.SalesIndex90, &r.CustomerScore90, &r.CustomerScore180, &r.CustomerScore365, &r.CustomerScoreTotal, &r.PerformanceScore,
|
|
&r.PerformanceBucket, &r.Recommendation, &r.LastSaleDate,
|
|
); err != nil {
|
|
return nil, err
|
|
}
|
|
colorCodes = append(colorCodes, r.ColorCode)
|
|
out = append(out, r)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
colorDescriptions := productPerformanceColorDescriptions(ctx, colorCodes)
|
|
for i := range out {
|
|
out[i].ColorDescription = colorDescriptions[normalizeProductPerformanceCode(out[i].ColorCode)]
|
|
}
|
|
applyProductPerformanceSalesBreakdownScores(out)
|
|
return out, nil
|
|
}
|
|
|
|
func applyProductPerformanceSalesBreakdownScores(rows []models.ProductPerformanceSalesBreakdownRow) {
|
|
avg90 := productPerformanceSalesBreakdownAverage(rows, func(row models.ProductPerformanceSalesBreakdownRow) float64 { return row.SalesUSD90 })
|
|
avg180 := productPerformanceSalesBreakdownAverage(rows, func(row models.ProductPerformanceSalesBreakdownRow) float64 { return row.SalesUSD180 })
|
|
avg365 := productPerformanceSalesBreakdownAverage(rows, func(row models.ProductPerformanceSalesBreakdownRow) float64 { return row.SalesUSD365 })
|
|
avgTotal := productPerformanceSalesBreakdownAverage(rows, func(row models.ProductPerformanceSalesBreakdownRow) float64 { return row.SalesUSDTotal })
|
|
|
|
for i := range rows {
|
|
r := &rows[i]
|
|
r.SalesIndex90 = productPerformanceRelativeIndex(r.SalesUSD90, avg90)
|
|
r.SalesIndex180 = productPerformanceRelativeIndex(r.SalesUSD180, avg180)
|
|
r.SalesIndex365 = productPerformanceRelativeIndex(r.SalesUSD365, avg365)
|
|
r.SalesIndexTotal = productPerformanceRelativeIndex(r.SalesUSDTotal, avgTotal)
|
|
|
|
r.CustomerScore90 = productPerformanceCustomerScore("90d", r.SalesUSD90, r.GrossMarginCost90, float64(r.ProductGroupCount90), r.SalesQty90)
|
|
r.CustomerScore180 = productPerformanceCustomerScore("180d", r.SalesUSD180, r.GrossMarginCost180, float64(r.ProductGroupCount180), r.SalesQty180)
|
|
r.CustomerScore365 = productPerformanceCustomerScore("365d", r.SalesUSD365, r.GrossMarginCost365, float64(r.ProductGroupCount365), r.SalesQty365)
|
|
r.CustomerScoreTotal = productPerformanceCustomerScore("total", r.SalesUSDTotal, r.GrossMarginCostTotal, float64(r.ProductGroupCountTotal), r.SalesQtyTotal)
|
|
|
|
r.PerformanceScore90 = productPerformanceProductScore("90d", r.SalesUSD90, r.SalesIndex90, r.GrossMarginCost90, r.StockTurnover90, float64(r.MarketCount90), float64(r.CustomerCount90))
|
|
r.PerformanceScore180 = productPerformanceProductScore("180d", r.SalesUSD180, r.SalesIndex180, r.GrossMarginCost180, r.StockTurnover180, float64(r.MarketCount180), float64(r.CustomerCount180))
|
|
r.PerformanceScore365 = productPerformanceProductScore("365d", r.SalesUSD365, r.SalesIndex365, r.GrossMarginCost365, r.StockTurnover365, float64(r.MarketCount365), float64(r.CustomerCount365))
|
|
r.PerformanceScoreTotal = productPerformanceProductScore("total", r.SalesUSDTotal, r.SalesIndexTotal, r.GrossMarginCostTotal, r.StockTurnoverTotal, float64(r.MarketCountTotal), float64(r.CustomerCountTotal))
|
|
r.PerformanceScore = r.PerformanceScore90
|
|
}
|
|
}
|
|
|
|
func productPerformanceSalesBreakdownAverage(rows []models.ProductPerformanceSalesBreakdownRow, value func(models.ProductPerformanceSalesBreakdownRow) float64) float64 {
|
|
var sum, count float64
|
|
for _, row := range rows {
|
|
v := value(row)
|
|
if v <= 0 {
|
|
continue
|
|
}
|
|
sum += v
|
|
count++
|
|
}
|
|
if count == 0 {
|
|
return 0
|
|
}
|
|
return sum / count
|
|
}
|
|
|
|
type ProductPerformanceGroupedRequest struct {
|
|
Mode string
|
|
GroupLevels []string
|
|
ExpandedKeys map[string]bool
|
|
ExpandThroughLevel int
|
|
Limit int
|
|
MainGroup string
|
|
Filters map[string][]string
|
|
SortBy string
|
|
Descending bool
|
|
}
|
|
|
|
type ProductPerformanceGroupedFilterOptionsRequest struct {
|
|
Mode string
|
|
GroupLevels []string
|
|
MainGroup string
|
|
Fields []string
|
|
Limit int
|
|
}
|
|
|
|
func ListProductPerformanceGrouped(ctx context.Context, pg *sql.DB, req ProductPerformanceGroupedRequest) ([]map[string]any, error) {
|
|
if err := EnsureProductPerformanceTables(pg); err != nil {
|
|
return nil, err
|
|
}
|
|
if req.Limit <= 0 || req.Limit > 50000 {
|
|
req.Limit = 50000
|
|
}
|
|
levels := sanitizeProductPerformanceGroupLevels(req.GroupLevels)
|
|
if len(levels) == 0 {
|
|
levels = defaultProductPerformanceGroupLevels(req.Mode)
|
|
}
|
|
if req.ExpandThroughLevel > len(levels)-2 {
|
|
req.ExpandThroughLevel = len(levels) - 2
|
|
}
|
|
|
|
if out, ok, err := loadProductPerformancePreparedGroupedRows(ctx, pg, req, levels); ok || err != nil {
|
|
return out, err
|
|
}
|
|
|
|
sourceRows, err := productPerformanceGroupedSourceRows(ctx, pg, req.Mode, req.Limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
sourceRows = filterProductPerformanceGroupedRows(sourceRows, productPerformanceGroupedEffectiveFilters(req))
|
|
out := make([]map[string]any, 0, len(sourceRows))
|
|
appendProductPerformanceGroupedRows(&out, sourceRows, levels, 0, 0, []string{"tab:" + req.Mode}, req.ExpandedKeys, req.ExpandThroughLevel)
|
|
out = sortProductPerformancePreparedGroupedRows(out, req.SortBy, req.Descending)
|
|
if len(out) > 0 || !productPerformanceLiveFallbackEnabled() {
|
|
return out, nil
|
|
}
|
|
if out, ok, err := listProductPerformanceGroupedSQL(ctx, pg, req, levels); ok || err != nil {
|
|
return out, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func ListProductPerformanceGroupedFilterOptions(ctx context.Context, pg *sql.DB, req ProductPerformanceGroupedFilterOptionsRequest) (map[string][]string, error) {
|
|
if err := EnsureProductPerformanceTables(pg); err != nil {
|
|
return nil, err
|
|
}
|
|
if req.Limit <= 0 || req.Limit > 50000 {
|
|
req.Limit = 50000
|
|
}
|
|
levels := sanitizeProductPerformanceGroupLevels(req.GroupLevels)
|
|
if len(levels) == 0 {
|
|
levels = defaultProductPerformanceGroupLevels(req.Mode)
|
|
}
|
|
reportKey := productPerformanceGroupedSnapshotReportKey(req.Mode, levels, req.MainGroup)
|
|
if strings.TrimSpace(reportKey) == "" {
|
|
return map[string][]string{}, nil
|
|
}
|
|
levelSet := map[string]bool{}
|
|
for _, level := range levels {
|
|
levelSet[level] = true
|
|
}
|
|
out := map[string][]string{}
|
|
for _, field := range req.Fields {
|
|
field = strings.TrimSpace(field)
|
|
if !productPerformanceGroupedFilterOptionFieldAllowed(field) {
|
|
continue
|
|
}
|
|
values, err := productPerformanceGroupedSnapshotFilterOptions(ctx, pg, reportKey, field, levelSet, strings.TrimSpace(req.MainGroup), req.Limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
out[field] = values
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func productPerformanceGroupedFilterOptionFieldAllowed(field string) bool {
|
|
switch field {
|
|
case "kategori", "askili_yan", "urun_ilk_grubu", "urun_ana_grubu", "urun_alt_grubu",
|
|
"product_code", "color_yaka", "market_key", "country", "customer_segment",
|
|
"customer_code", "customer_name", "performance_bucket":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func productPerformanceGroupedSnapshotFilterOptions(ctx context.Context, pg *sql.DB, reportKey, field string, levelSet map[string]bool, mainGroup string, limit int) ([]string, error) {
|
|
if field == "urun_ana_grubu" && strings.Contains(reportKey, ":product_detail:") && strings.TrimSpace(mainGroup) != "" {
|
|
return []string{strings.TrimSpace(mainGroup)}, nil
|
|
}
|
|
if field == "performance_bucket" {
|
|
return productPerformanceGroupedSnapshotPayloadOptions(ctx, pg, reportKey, "performance_bucket", limit)
|
|
}
|
|
if !levelSet[field] {
|
|
return []string{}, nil
|
|
}
|
|
rows, err := pg.QueryContext(ctx, `
|
|
SELECT DISTINCT btrim(group_value) AS value
|
|
FROM mk_product_performance_grouped_snapshot
|
|
WHERE report_key = $1
|
|
AND group_field = $2
|
|
AND btrim(group_value) <> ''
|
|
ORDER BY value
|
|
LIMIT $3
|
|
`, reportKey, field, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanProductPerformanceGroupedFilterOptionRows(rows)
|
|
}
|
|
|
|
func productPerformanceGroupedSnapshotPayloadOptions(ctx context.Context, pg *sql.DB, reportKey, field string, limit int) ([]string, error) {
|
|
rows, err := pg.QueryContext(ctx, `
|
|
SELECT DISTINCT btrim(payload ->> $2) AS value
|
|
FROM mk_product_performance_grouped_snapshot
|
|
WHERE report_key = $1
|
|
AND btrim(payload ->> $2) <> ''
|
|
ORDER BY value
|
|
LIMIT $3
|
|
`, reportKey, field, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
return scanProductPerformanceGroupedFilterOptionRows(rows)
|
|
}
|
|
|
|
func scanProductPerformanceGroupedFilterOptionRows(rows *sql.Rows) ([]string, error) {
|
|
out := []string{}
|
|
for rows.Next() {
|
|
var value string
|
|
if err := rows.Scan(&value); err != nil {
|
|
return nil, err
|
|
}
|
|
value = strings.TrimSpace(value)
|
|
if value != "" {
|
|
out = append(out, value)
|
|
}
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func loadProductPerformancePreparedGroupedRows(ctx context.Context, pg *sql.DB, req ProductPerformanceGroupedRequest, levels []string) ([]map[string]any, bool, error) {
|
|
reportKey := productPerformanceGroupedSnapshotReportKey(req.Mode, levels, req.MainGroup)
|
|
if strings.TrimSpace(reportKey) == "" {
|
|
return nil, false, nil
|
|
}
|
|
effectiveFilters := productPerformancePreparedGroupedEffectiveFilters(req)
|
|
hasFilters := len(effectiveFilters) > 0
|
|
if hasFilters {
|
|
return nil, false, nil
|
|
}
|
|
hasManualExpansion := len(req.ExpandedKeys) > 0
|
|
query := `
|
|
SELECT payload
|
|
FROM mk_product_performance_grouped_snapshot
|
|
WHERE report_key = $1
|
|
`
|
|
args := []any{reportKey}
|
|
if !hasFilters && !hasManualExpansion {
|
|
maxVisibleLevel := req.ExpandThroughLevel + 1
|
|
if maxVisibleLevel < 0 {
|
|
maxVisibleLevel = 0
|
|
}
|
|
args = append(args, maxVisibleLevel)
|
|
query += fmt.Sprintf(" AND group_level <= $%d\n", len(args))
|
|
}
|
|
query += "ORDER BY row_order"
|
|
if !hasFilters && !hasManualExpansion {
|
|
args = append(args, req.Limit)
|
|
query += fmt.Sprintf("\nLIMIT $%d", len(args))
|
|
}
|
|
|
|
rows, err := pg.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, false, err
|
|
}
|
|
defer rows.Close()
|
|
|
|
out := make([]map[string]any, 0, minInt(req.Limit, 50000))
|
|
for rows.Next() {
|
|
var raw []byte
|
|
if err := rows.Scan(&raw); err != nil {
|
|
return nil, false, err
|
|
}
|
|
var item map[string]any
|
|
if err := json.Unmarshal(raw, &item); err != nil {
|
|
return nil, false, err
|
|
}
|
|
if item == nil {
|
|
item = map[string]any{}
|
|
}
|
|
out = append(out, item)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, false, err
|
|
}
|
|
if len(out) == 0 {
|
|
var exists bool
|
|
if err := pg.QueryRowContext(ctx, `
|
|
SELECT EXISTS (
|
|
SELECT 1
|
|
FROM mk_product_performance_grouped_snapshot_meta
|
|
WHERE report_key = $1
|
|
)
|
|
`, reportKey).Scan(&exists); err != nil {
|
|
return nil, false, err
|
|
}
|
|
if !exists {
|
|
return nil, false, nil
|
|
}
|
|
}
|
|
out = filterProductPerformanceGroupedRowsForExpansion(out, req.ExpandedKeys, req.ExpandThroughLevel, req.Limit)
|
|
out = sortProductPerformancePreparedGroupedRows(out, req.SortBy, req.Descending)
|
|
return out, true, nil
|
|
}
|
|
|
|
type productPerformanceGroupedSortNode struct {
|
|
Row map[string]any
|
|
Children []*productPerformanceGroupedSortNode
|
|
}
|
|
|
|
func sortProductPerformancePreparedGroupedRows(rows []map[string]any, sortBy string, descending bool) []map[string]any {
|
|
sortBy = strings.TrimSpace(sortBy)
|
|
if len(rows) == 0 || sortBy == "" {
|
|
return rows
|
|
}
|
|
roots := make([]*productPerformanceGroupedSortNode, 0)
|
|
stack := make([]*productPerformanceGroupedSortNode, 0, 16)
|
|
for _, row := range rows {
|
|
node := &productPerformanceGroupedSortNode{Row: row}
|
|
level := intFromMap(row, "level")
|
|
if level < 0 {
|
|
level = 0
|
|
}
|
|
if level < len(stack) {
|
|
stack = stack[:level]
|
|
}
|
|
if level > 0 && level-1 < len(stack) {
|
|
stack[level-1].Children = append(stack[level-1].Children, node)
|
|
} else {
|
|
roots = append(roots, node)
|
|
}
|
|
if level >= len(stack) {
|
|
stack = append(stack, node)
|
|
} else {
|
|
stack[level] = node
|
|
}
|
|
}
|
|
|
|
out := make([]map[string]any, 0, len(rows))
|
|
var appendNodes func(nodes []*productPerformanceGroupedSortNode)
|
|
appendNodes = func(nodes []*productPerformanceGroupedSortNode) {
|
|
sort.SliceStable(nodes, func(i, j int) bool {
|
|
cmp := compareProductPerformanceGroupedSortRows(nodes[i].Row, nodes[j].Row, sortBy)
|
|
if cmp == 0 {
|
|
cmp = strings.Compare(strings.ToLower(stringFromMap(nodes[i].Row, "label")), strings.ToLower(stringFromMap(nodes[j].Row, "label")))
|
|
}
|
|
if descending {
|
|
return cmp > 0
|
|
}
|
|
return cmp < 0
|
|
})
|
|
for _, node := range nodes {
|
|
out = append(out, node.Row)
|
|
if len(node.Children) > 0 {
|
|
appendNodes(node.Children)
|
|
}
|
|
}
|
|
}
|
|
appendNodes(roots)
|
|
return out
|
|
}
|
|
|
|
func compareProductPerformanceGroupedSortRows(left, right map[string]any, sortBy string) int {
|
|
leftValue := productPerformanceGroupedSortValue(left, sortBy)
|
|
rightValue := productPerformanceGroupedSortValue(right, sortBy)
|
|
leftNum, leftOK := numericProductPerformanceGroupedSortValue(leftValue)
|
|
rightNum, rightOK := numericProductPerformanceGroupedSortValue(rightValue)
|
|
if leftOK && rightOK {
|
|
switch {
|
|
case leftNum < rightNum:
|
|
return -1
|
|
case leftNum > rightNum:
|
|
return 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
leftText := strings.ToLower(strings.TrimSpace(fmt.Sprint(leftValue)))
|
|
rightText := strings.ToLower(strings.TrimSpace(fmt.Sprint(rightValue)))
|
|
return strings.Compare(leftText, rightText)
|
|
}
|
|
|
|
func productPerformanceGroupedSortValue(row map[string]any, sortBy string) any {
|
|
sortBy = strings.TrimSpace(sortBy)
|
|
if sortBy == "" {
|
|
return ""
|
|
}
|
|
if value, ok := row[sortBy]; ok {
|
|
return value
|
|
}
|
|
switch sortBy {
|
|
case "performance_score_total", "performance_score_90d":
|
|
return row["performance_score"]
|
|
case "gross_margin_base_total":
|
|
return row["gross_margin_total"]
|
|
case "gross_margin_cost_total":
|
|
return row["gross_margin_total"]
|
|
case "color_yaka":
|
|
return mapGroupValue(row, "color_yaka")
|
|
case "market_key":
|
|
return mapGroupValue(row, "market_key")
|
|
default:
|
|
if sortBy == stringFromMap(row, "group_field") {
|
|
return stringFromMap(row, "group_value")
|
|
}
|
|
return stringFromMap(row, "label")
|
|
}
|
|
}
|
|
|
|
func numericProductPerformanceGroupedSortValue(value any) (float64, bool) {
|
|
switch v := value.(type) {
|
|
case nil:
|
|
return 0, false
|
|
case float64:
|
|
return v, true
|
|
case float32:
|
|
return float64(v), true
|
|
case int:
|
|
return float64(v), true
|
|
case int64:
|
|
return float64(v), true
|
|
case int32:
|
|
return float64(v), true
|
|
case json.Number:
|
|
f, err := v.Float64()
|
|
return f, err == nil
|
|
case string:
|
|
text := strings.TrimSpace(v)
|
|
if text == "" {
|
|
return 0, false
|
|
}
|
|
parsed, err := strconv.ParseFloat(text, 64)
|
|
if err != nil {
|
|
return 0, false
|
|
}
|
|
return parsed, true
|
|
default:
|
|
return 0, false
|
|
}
|
|
}
|
|
|
|
type productPerformanceSQLGroupFilter struct {
|
|
Field string
|
|
Value string
|
|
Values []string
|
|
}
|
|
|
|
func listProductPerformanceGroupedSQL(ctx context.Context, pg *sql.DB, req ProductPerformanceGroupedRequest, levels []string) ([]map[string]any, bool, error) {
|
|
mode := strings.TrimSpace(req.Mode)
|
|
if mode != "products" && mode != "product_detail" && mode != "idle" {
|
|
return nil, false, nil
|
|
}
|
|
if err := EnsureProductPerformanceTables(pg); err != nil {
|
|
return nil, true, err
|
|
}
|
|
out := make([]map[string]any, 0, 512)
|
|
filters := productPerformanceGroupedBaseFilters(req)
|
|
err := appendProductPerformanceGroupedSQLRows(ctx, pg, &out, mode, levels, 0, 0, []string{"tab:" + mode}, filters, req.ExpandedKeys, req.ExpandThroughLevel, req.Limit)
|
|
if err != nil {
|
|
return out, true, err
|
|
}
|
|
out = sortProductPerformancePreparedGroupedRows(out, req.SortBy, req.Descending)
|
|
return out, true, nil
|
|
}
|
|
|
|
func productPerformanceGroupedBaseFilters(req ProductPerformanceGroupedRequest) []productPerformanceSQLGroupFilter {
|
|
effectiveFilters := productPerformanceGroupedEffectiveFilters(req)
|
|
filters := make([]productPerformanceSQLGroupFilter, 0, len(effectiveFilters))
|
|
for field, values := range effectiveFilters {
|
|
cleanValues := cleanProductPerformanceFilterValues(values)
|
|
if len(cleanValues) == 0 {
|
|
continue
|
|
}
|
|
filters = append(filters, productPerformanceSQLGroupFilter{Field: field, Values: cleanValues})
|
|
}
|
|
return filters
|
|
}
|
|
|
|
func productPerformanceGroupedEffectiveFilters(req ProductPerformanceGroupedRequest) map[string][]string {
|
|
filters := make(map[string][]string, len(req.Filters)+1)
|
|
for field, values := range req.Filters {
|
|
cleanValues := cleanProductPerformanceFilterValues(values)
|
|
if len(cleanValues) == 0 {
|
|
continue
|
|
}
|
|
filters[field] = cleanValues
|
|
}
|
|
if strings.TrimSpace(req.Mode) == "product_detail" {
|
|
mainGroup := cleanProductPerformanceFilterValues([]string{req.MainGroup})
|
|
if len(mainGroup) > 0 {
|
|
filters["urun_ana_grubu"] = mainGroup
|
|
}
|
|
}
|
|
return filters
|
|
}
|
|
|
|
func productPerformancePreparedGroupedEffectiveFilters(req ProductPerformanceGroupedRequest) map[string][]string {
|
|
filters := make(map[string][]string, len(req.Filters))
|
|
for field, values := range req.Filters {
|
|
if strings.TrimSpace(req.Mode) == "product_detail" && field == "urun_ana_grubu" {
|
|
continue
|
|
}
|
|
cleanValues := cleanProductPerformanceFilterValues(values)
|
|
if len(cleanValues) == 0 {
|
|
continue
|
|
}
|
|
filters[field] = cleanValues
|
|
}
|
|
return filters
|
|
}
|
|
|
|
func appendProductPerformanceGroupedSQLRows(ctx context.Context, pg *sql.DB, out *[]map[string]any, mode string, levels []string, level int, visualLevel int, parentKeys []string, filters []productPerformanceSQLGroupFilter, expandedKeys map[string]bool, expandThroughLevel int, limit int) error {
|
|
if level >= len(levels) {
|
|
return nil
|
|
}
|
|
|
|
field := levels[level]
|
|
rows, err := queryProductPerformanceSQLGroupRows(ctx, pg, mode, field, level, parentKeys, filters)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
for _, row := range rows {
|
|
value := stringFromMap(row, "group_value")
|
|
if shouldSkipProductPerformanceGroupValue(field, value) {
|
|
nextFilters := append(append([]productPerformanceSQLGroupFilter{}, filters...), productPerformanceSQLGroupFilter{Field: field, Value: value})
|
|
if err := appendProductPerformanceGroupedSQLRows(ctx, pg, out, mode, levels, level+1, visualLevel, parentKeys, nextFilters, expandedKeys, expandThroughLevel, limit); err != nil {
|
|
return err
|
|
}
|
|
continue
|
|
}
|
|
keyPart := field + ":" + value
|
|
key := strings.Join(append(parentKeys, keyPart), "|")
|
|
row["row_key"] = "group|" + key
|
|
row["key"] = key
|
|
row["level"] = visualLevel
|
|
row["group_field"] = field
|
|
row["group_value"] = value
|
|
row["label"] = value
|
|
row["__group"] = true
|
|
row[field] = value
|
|
deriveProductPerformanceGroupMetrics(row, field)
|
|
*out = append(*out, row)
|
|
if expandedKeys[key] || level <= expandThroughLevel {
|
|
nextFilters := append(append([]productPerformanceSQLGroupFilter{}, filters...), productPerformanceSQLGroupFilter{Field: field, Value: value})
|
|
if err := appendProductPerformanceGroupedSQLRows(ctx, pg, out, mode, levels, level+1, visualLevel+1, append(parentKeys, keyPart), nextFilters, expandedKeys, expandThroughLevel, limit); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func queryProductPerformanceSQLGroupRows(ctx context.Context, pg *sql.DB, mode, field string, level int, parentKeys []string, filters []productPerformanceSQLGroupFilter) ([]map[string]any, error) {
|
|
groupExpr, ok := productPerformanceSQLGroupExpr(field)
|
|
if !ok {
|
|
return nil, fmt.Errorf("unsupported product performance group field: %s", field)
|
|
}
|
|
whereSQL, args, err := productPerformanceSQLFilterWhere(filters)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
query := productPerformanceSQLSourceCTE(mode) + fmt.Sprintf(`
|
|
SELECT (
|
|
jsonb_build_object(
|
|
'group_value', group_value,
|
|
'label', group_value,
|
|
'period_start', '2022-01-01',
|
|
'period_end', COALESCE(to_char((SELECT kpi_date FROM LatestKPIDate),'YYYY-MM-DD'), ''),
|
|
'count', row_count,
|
|
'recommendation', CASE WHEN recommendation <> '' THEN recommendation ELSE row_count::text || ' satir' END,
|
|
'image_product_code', image_product_code,
|
|
'image_color_code', image_color_code,
|
|
'image_yaka_kodu', image_yaka_kodu,
|
|
'product_code', CASE WHEN $%d = 'product_code' THEN group_value ELSE '' END,
|
|
'color_code', CASE WHEN $%d = 'color_code' THEN group_value ELSE '' END,
|
|
'yaka_kodu', CASE WHEN $%d = 'yaka_kodu' THEN group_value ELSE '' END,
|
|
'item_description', CASE WHEN $%d = 'item_description' THEN group_value ELSE '' END,
|
|
'kategori', CASE WHEN $%d = 'kategori' THEN group_value ELSE '' END,
|
|
'askili_yan', CASE WHEN $%d = 'askili_yan' THEN group_value ELSE '' END,
|
|
'urun_ilk_grubu', CASE WHEN $%d = 'urun_ilk_grubu' THEN group_value ELSE '' END,
|
|
'urun_ana_grubu', CASE WHEN $%d = 'urun_ana_grubu' THEN group_value ELSE '' END,
|
|
'urun_alt_grubu', CASE WHEN $%d = 'urun_alt_grubu' THEN group_value ELSE '' END,
|
|
'market_key', CASE WHEN $%d = 'market_key' THEN group_value ELSE '' END
|
|
)
|
|
|| jsonb_build_object(
|
|
'stock_qty', stock_qty,
|
|
'avg_stock_90d', avg_stock_90d,
|
|
'avg_stock_180d', avg_stock_180d,
|
|
'avg_stock_365d', avg_stock_365d,
|
|
'avg_stock_total', avg_stock_total,
|
|
'sales_qty_90d', sales_qty_90d,
|
|
'sales_qty_180d', sales_qty_180d,
|
|
'sales_qty_365d', sales_qty_365d,
|
|
'sales_qty_total', sales_qty_total,
|
|
'sales_usd_90d', sales_usd_90d,
|
|
'sales_usd_180d', sales_usd_180d,
|
|
'sales_usd_365d', sales_usd_365d,
|
|
'sales_usd_total', sales_usd_total,
|
|
'avg_price_usd_90d', avg_price_usd_90d,
|
|
'avg_price_usd_180d', avg_price_usd_180d,
|
|
'cost_price_usd', cost_price_usd,
|
|
'base_price_usd', base_price_usd,
|
|
'base_price_try', base_price_try,
|
|
'gross_profit_usd_90d', gross_profit_usd_90d,
|
|
'gross_profit_usd_180d', gross_profit_usd_180d,
|
|
'gross_margin_90d', gross_margin_90d,
|
|
'gross_margin_180d', gross_margin_180d
|
|
)
|
|
|| jsonb_build_object(
|
|
'unit_profit_cost_90d', unit_profit_cost_90d,
|
|
'unit_profit_cost_180d', unit_profit_cost_180d,
|
|
'unit_profit_base_90d', unit_profit_base_90d,
|
|
'unit_profit_base_180d', unit_profit_base_180d,
|
|
'market_count_90d', market_count_90d,
|
|
'market_count_180d', market_count_180d,
|
|
'market_count_365d', market_count_365d,
|
|
'market_count_total', market_count_total,
|
|
'customer_count_90d', customer_count_90d,
|
|
'customer_count_180d', customer_count_180d,
|
|
'customer_count_365d', customer_count_365d,
|
|
'customer_count_total', customer_count_total,
|
|
'sales_index_90d', sales_index_90d,
|
|
'sales_index_180d', sales_index_180d,
|
|
'sales_index_365d', sales_index_365d,
|
|
'sales_index_total', sales_index_total,
|
|
'price_index_90d', price_index_90d,
|
|
'margin_index_90d', margin_index_90d,
|
|
'performance_score', performance_score,
|
|
'performance_bucket', performance_bucket,
|
|
'idle_cost_usd', idle_cost_usd,
|
|
'stock_days_90d', stock_days_90d,
|
|
'stock_days_180d', stock_days_180d,
|
|
'stock_days_365d', stock_days_365d,
|
|
'stock_days_total', stock_days_total,
|
|
'stock_turnover_90d', CASE WHEN avg_stock_90d > 0 THEN sales_qty_90d / NULLIF(avg_stock_90d,0) * 4.0 ELSE 0 END,
|
|
'stock_turnover_180d', CASE WHEN avg_stock_180d > 0 THEN sales_qty_180d / NULLIF(avg_stock_180d,0) * 2.0 ELSE 0 END,
|
|
'stock_turnover_365d', CASE WHEN avg_stock_365d > 0 THEN sales_qty_365d / NULLIF(avg_stock_365d,0) ELSE 0 END,
|
|
'stock_turnover_total', CASE WHEN avg_stock_total > 0 THEN sales_qty_total / NULLIF(avg_stock_total,0) * 360.0 / GREATEST(1, ((SELECT kpi_date FROM LatestKPIDate) - DATE '2022-01-01') + 1) ELSE 0 END
|
|
)
|
|
) AS row_json
|
|
FROM (
|
|
SELECT
|
|
COALESCE(%s, '') AS group_value,
|
|
COUNT(*)::integer AS row_count,
|
|
(ARRAY_AGG(product_code ORDER BY performance_score DESC NULLS LAST))[1] AS image_product_code,
|
|
(ARRAY_AGG(color_code ORDER BY performance_score DESC NULLS LAST))[1] AS image_color_code,
|
|
(ARRAY_AGG(yaka_kodu ORDER BY performance_score DESC NULLS LAST))[1] AS image_yaka_kodu,
|
|
COALESCE((ARRAY_AGG(NULLIF(product_code,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS product_code,
|
|
COALESCE((ARRAY_AGG(NULLIF(color_code,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS color_code,
|
|
COALESCE((ARRAY_AGG(NULLIF(yaka_kodu,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS yaka_kodu,
|
|
COALESCE((ARRAY_AGG(NULLIF(item_description,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS item_description,
|
|
COALESCE((ARRAY_AGG(NULLIF(kategori,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS kategori,
|
|
COALESCE((ARRAY_AGG(NULLIF(NULLIF(askili_yan,''), '-') ORDER BY performance_score DESC NULLS LAST))[1], '') AS askili_yan,
|
|
COALESCE((ARRAY_AGG(NULLIF(urun_ilk_grubu,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS urun_ilk_grubu,
|
|
COALESCE((ARRAY_AGG(NULLIF(urun_ana_grubu,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS urun_ana_grubu,
|
|
COALESCE((ARRAY_AGG(NULLIF(urun_alt_grubu,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS urun_alt_grubu,
|
|
COALESCE((ARRAY_AGG(NULLIF(market_key,'') ORDER BY performance_score DESC NULLS LAST))[1], '') AS market_key,
|
|
COALESCE(SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END),0) AS stock_qty,
|
|
COALESCE(SUM(CASE WHEN stock_variant_rank = 1 THEN avg_stock_90d ELSE 0 END),0) AS avg_stock_90d,
|
|
COALESCE(SUM(CASE WHEN stock_variant_rank = 1 THEN avg_stock_180d ELSE 0 END),0) AS avg_stock_180d,
|
|
COALESCE(SUM(CASE WHEN stock_variant_rank = 1 THEN avg_stock_365d ELSE 0 END),0) AS avg_stock_365d,
|
|
COALESCE(SUM(CASE WHEN stock_variant_rank = 1 THEN avg_stock_total ELSE 0 END),0) AS avg_stock_total,
|
|
COALESCE(SUM(sales_qty_90d),0) AS sales_qty_90d,
|
|
COALESCE(SUM(sales_qty_180d),0) AS sales_qty_180d,
|
|
COALESCE(SUM(sales_qty_365d),0) AS sales_qty_365d,
|
|
COALESCE(SUM(sales_qty_total),0) AS sales_qty_total,
|
|
COALESCE(SUM(sales_usd_90d),0) AS sales_usd_90d,
|
|
COALESCE(SUM(sales_usd_180d),0) AS sales_usd_180d,
|
|
COALESCE(SUM(sales_usd_365d),0) AS sales_usd_365d,
|
|
COALESCE(SUM(sales_usd_total),0) AS sales_usd_total,
|
|
CASE WHEN SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END) > 0 THEN SUM(CASE WHEN stock_variant_rank = 1 THEN stock_days_90d * stock_qty ELSE 0 END) / NULLIF(SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END),0) ELSE 0 END AS stock_days_90d,
|
|
CASE WHEN SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END) > 0 THEN SUM(CASE WHEN stock_variant_rank = 1 THEN stock_days_180d * stock_qty ELSE 0 END) / NULLIF(SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END),0) ELSE 0 END AS stock_days_180d,
|
|
CASE WHEN SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END) > 0 THEN SUM(CASE WHEN stock_variant_rank = 1 THEN stock_days_365d * stock_qty ELSE 0 END) / NULLIF(SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END),0) ELSE 0 END AS stock_days_365d,
|
|
CASE WHEN SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END) > 0 THEN SUM(CASE WHEN stock_variant_rank = 1 THEN stock_days_total * stock_qty ELSE 0 END) / NULLIF(SUM(CASE WHEN stock_variant_rank = 1 THEN stock_qty ELSE 0 END),0) ELSE 0 END AS stock_days_total,
|
|
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(sales_usd_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS avg_price_usd_90d,
|
|
CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(sales_usd_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE 0 END AS avg_price_usd_180d,
|
|
CASE
|
|
WHEN SUM(CASE WHEN sales_qty_total > 0 THEN sales_qty_total ELSE 0 END) > 0
|
|
THEN SUM(CASE WHEN sales_qty_total > 0 THEN cost_price_usd * sales_qty_total ELSE 0 END)
|
|
/ NULLIF(SUM(CASE WHEN sales_qty_total > 0 THEN sales_qty_total ELSE 0 END),0)
|
|
ELSE COALESCE(AVG(NULLIF(cost_price_usd,0)),0)
|
|
END AS cost_price_usd,
|
|
CASE
|
|
WHEN SUM(CASE WHEN sales_qty_total > 0 THEN sales_qty_total ELSE 0 END) > 0
|
|
THEN SUM(CASE WHEN sales_qty_total > 0 THEN base_price_usd * sales_qty_total ELSE 0 END)
|
|
/ NULLIF(SUM(CASE WHEN sales_qty_total > 0 THEN sales_qty_total ELSE 0 END),0)
|
|
ELSE COALESCE(AVG(NULLIF(base_price_usd,0)),0)
|
|
END AS base_price_usd,
|
|
CASE
|
|
WHEN SUM(CASE WHEN sales_qty_total > 0 THEN sales_qty_total ELSE 0 END) > 0
|
|
THEN SUM(CASE WHEN sales_qty_total > 0 THEN base_price_try * sales_qty_total ELSE 0 END)
|
|
/ NULLIF(SUM(CASE WHEN sales_qty_total > 0 THEN sales_qty_total ELSE 0 END),0)
|
|
ELSE COALESCE(AVG(NULLIF(base_price_try,0)),0)
|
|
END AS base_price_try,
|
|
COALESCE(SUM(gross_profit_usd_90d),0) AS gross_profit_usd_90d,
|
|
COALESCE(SUM(gross_profit_usd_180d),0) AS gross_profit_usd_180d,
|
|
CASE WHEN SUM(sales_usd_90d) > 0 THEN SUM(gross_profit_usd_90d) / NULLIF(SUM(sales_usd_90d),0) ELSE 0 END AS gross_margin_90d,
|
|
CASE WHEN SUM(sales_usd_180d) > 0 THEN SUM(gross_profit_usd_180d) / NULLIF(SUM(sales_usd_180d),0) ELSE 0 END AS gross_margin_180d,
|
|
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(unit_profit_cost_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS unit_profit_cost_90d,
|
|
CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(unit_profit_cost_180d * sales_qty_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE 0 END AS unit_profit_cost_180d,
|
|
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(unit_profit_base_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS unit_profit_base_90d,
|
|
CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(unit_profit_base_180d * sales_qty_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE 0 END AS unit_profit_base_180d,
|
|
COUNT(DISTINCT NULLIF(NULLIF(market_key,'STOK'),'')) FILTER (WHERE COALESCE(sales_usd_90d,0) > 0)::integer AS market_count_90d,
|
|
COUNT(DISTINCT NULLIF(NULLIF(market_key,'STOK'),'')) FILTER (WHERE COALESCE(sales_usd_180d,0) > 0)::integer AS market_count_180d,
|
|
COUNT(DISTINCT NULLIF(NULLIF(market_key,'STOK'),'')) FILTER (WHERE COALESCE(sales_usd_365d,0) > 0)::integer AS market_count_365d,
|
|
COUNT(DISTINCT NULLIF(NULLIF(market_key,'STOK'),'')) FILTER (WHERE COALESCE(sales_usd_total,0) > 0)::integer AS market_count_total,
|
|
COALESCE(SUM(CASE WHEN COALESCE(sales_usd_90d,0) > 0 THEN customer_count_90d ELSE 0 END),0)::integer AS customer_count_90d,
|
|
COALESCE(SUM(CASE WHEN COALESCE(sales_usd_180d,0) > 0 THEN customer_count_180d ELSE 0 END),0)::integer AS customer_count_180d,
|
|
COALESCE(SUM(CASE WHEN COALESCE(sales_usd_365d,0) > 0 THEN customer_count_365d ELSE 0 END),0)::integer AS customer_count_365d,
|
|
COALESCE(SUM(CASE WHEN COALESCE(sales_usd_total,0) > 0 THEN customer_count_total ELSE 0 END),0)::integer AS customer_count_total,
|
|
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(sales_index_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS sales_index_90d,
|
|
CASE WHEN SUM(sales_qty_180d) > 0 THEN SUM(sales_index_180d * sales_qty_180d) / NULLIF(SUM(sales_qty_180d),0) ELSE 0 END AS sales_index_180d,
|
|
CASE WHEN SUM(sales_qty_365d) > 0 THEN SUM(sales_index_365d * sales_qty_365d) / NULLIF(SUM(sales_qty_365d),0) ELSE 0 END AS sales_index_365d,
|
|
CASE WHEN SUM(sales_qty_total) > 0 THEN SUM(sales_index_total * sales_qty_total) / NULLIF(SUM(sales_qty_total),0) ELSE 0 END AS sales_index_total,
|
|
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(price_index_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS price_index_90d,
|
|
CASE WHEN SUM(sales_qty_90d) > 0 THEN SUM(margin_index_90d * sales_qty_90d) / NULLIF(SUM(sales_qty_90d),0) ELSE 0 END AS margin_index_90d,
|
|
CASE
|
|
WHEN SUM(CASE
|
|
WHEN COALESCE(sales_qty_90d,0) > 0 THEN sales_qty_90d
|
|
ELSE 0
|
|
END) > 0
|
|
THEN SUM(performance_score * CASE
|
|
WHEN COALESCE(sales_qty_90d,0) > 0 THEN sales_qty_90d
|
|
ELSE 0
|
|
END) / NULLIF(SUM(CASE
|
|
WHEN COALESCE(sales_qty_90d,0) > 0 THEN sales_qty_90d
|
|
ELSE 0
|
|
END),0)
|
|
ELSE COALESCE(AVG(performance_score),0)
|
|
END AS performance_score,
|
|
MODE() WITHIN GROUP (ORDER BY performance_bucket) AS performance_bucket,
|
|
COALESCE(MODE() WITHIN GROUP (ORDER BY NULLIF(recommendation,'')), '') AS recommendation,
|
|
COALESCE(SUM(CASE WHEN stock_variant_rank = 1 THEN idle_cost_usd ELSE 0 END),0) AS idle_cost_usd
|
|
FROM (
|
|
SELECT
|
|
Source.*,
|
|
ROW_NUMBER() OVER (
|
|
PARTITION BY COALESCE(%s, ''), product_code, color_code, yaka_kodu
|
|
ORDER BY performance_score DESC NULLS LAST
|
|
) AS stock_variant_rank
|
|
FROM Source
|
|
%s
|
|
) s
|
|
GROUP BY COALESCE(%s, '')
|
|
) g
|
|
ORDER BY group_value
|
|
`, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, len(args)+1, groupExpr, groupExpr, whereSQL, groupExpr)
|
|
args = append(args, field)
|
|
return queryProductPerformanceJSONRows(ctx, pg, query, args...)
|
|
}
|
|
|
|
func queryProductPerformanceSQLLeafRows(ctx context.Context, pg *sql.DB, mode string, filters []productPerformanceSQLGroupFilter, limit int) ([]map[string]any, error) {
|
|
whereSQL, args, err := productPerformanceSQLFilterWhere(filters)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
args = append(args, limit)
|
|
query := productPerformanceSQLSourceCTE(mode) + fmt.Sprintf(`
|
|
SELECT to_jsonb(t) || jsonb_build_object(
|
|
'row_key', 'leaf|' || product_code || '|' || color_code || '|' || yaka_kodu || '|' || market_key,
|
|
'stock_turnover_90d', CASE WHEN avg_stock_90d > 0 THEN sales_qty_90d / NULLIF(avg_stock_90d,0) * 4.0 ELSE 0 END,
|
|
'stock_turnover_180d', CASE WHEN avg_stock_180d > 0 THEN sales_qty_180d / NULLIF(avg_stock_180d,0) * 2.0 ELSE 0 END,
|
|
'stock_turnover_365d', CASE WHEN avg_stock_365d > 0 THEN sales_qty_365d / NULLIF(avg_stock_365d,0) ELSE 0 END,
|
|
'stock_turnover_total', CASE WHEN avg_stock_total > 0 THEN sales_qty_total / NULLIF(avg_stock_total,0) * 360.0 / GREATEST(1, ((SELECT kpi_date FROM LatestKPIDate) - DATE '2022-01-01') + 1) ELSE 0 END
|
|
) AS row_json
|
|
FROM Source t
|
|
%s
|
|
ORDER BY performance_score DESC, sales_usd_90d DESC
|
|
LIMIT $%d
|
|
`, whereSQL, len(args))
|
|
return queryProductPerformanceJSONRows(ctx, pg, query, args...)
|
|
}
|
|
|
|
func queryProductPerformanceJSONRows(ctx context.Context, pg *sql.DB, query string, args ...any) ([]map[string]any, error) {
|
|
rows, err := pg.QueryContext(ctx, query, args...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := []map[string]any{}
|
|
colorCodes := []string{}
|
|
for rows.Next() {
|
|
var raw []byte
|
|
if err := rows.Scan(&raw); err != nil {
|
|
return nil, err
|
|
}
|
|
row := map[string]any{}
|
|
if err := json.Unmarshal(raw, &row); err != nil {
|
|
return nil, err
|
|
}
|
|
colorCodes = append(colorCodes, stringFromMap(row, "color_code"))
|
|
out = append(out, row)
|
|
}
|
|
if err := rows.Err(); err != nil {
|
|
return nil, err
|
|
}
|
|
colorDescriptions := productPerformanceColorDescriptions(ctx, colorCodes)
|
|
for _, row := range out {
|
|
row["color_description"] = colorDescriptions[normalizeProductPerformanceCode(stringFromMap(row, "color_code"))]
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
func productPerformanceSQLSourceCTE(mode string) string {
|
|
idleWhere := ""
|
|
if mode == "idle" {
|
|
idleWhere = `
|
|
AND stock_qty > 0
|
|
AND (performance_bucket = 'STOK_RISKI' OR COALESCE(sales_qty_90d,0) = 0 OR COALESCE(stock_days_90d,0) > 180)`
|
|
}
|
|
return `
|
|
WITH LatestKPIDate AS (
|
|
SELECT MAX(kpi_date) AS kpi_date
|
|
FROM mk_product_performance_kpi_daily
|
|
),
|
|
Stock90Start AS (
|
|
SELECT DISTINCT ON (product_code, color_code, yaka_kodu)
|
|
product_code, color_code, yaka_kodu, stock_qty
|
|
FROM mk_product_performance_stock_daily, LatestKPIDate
|
|
WHERE stock_date <= LatestKPIDate.kpi_date - INTERVAL '89 days'
|
|
ORDER BY product_code, color_code, yaka_kodu, stock_date DESC
|
|
),
|
|
Stock180Start AS (
|
|
SELECT DISTINCT ON (product_code, color_code, yaka_kodu)
|
|
product_code, color_code, yaka_kodu, stock_qty
|
|
FROM mk_product_performance_stock_daily, LatestKPIDate
|
|
WHERE stock_date <= LatestKPIDate.kpi_date - INTERVAL '179 days'
|
|
ORDER BY product_code, color_code, yaka_kodu, stock_date DESC
|
|
),
|
|
Stock365Start AS (
|
|
SELECT DISTINCT ON (product_code, color_code, yaka_kodu)
|
|
product_code, color_code, yaka_kodu, stock_qty
|
|
FROM mk_product_performance_stock_daily, LatestKPIDate
|
|
WHERE stock_date <= LatestKPIDate.kpi_date - INTERVAL '359 days'
|
|
ORDER BY product_code, color_code, yaka_kodu, stock_date DESC
|
|
),
|
|
StockTotalStart AS (
|
|
SELECT DISTINCT ON (product_code, color_code, yaka_kodu)
|
|
product_code, color_code, yaka_kodu, stock_qty
|
|
FROM mk_product_performance_stock_daily
|
|
WHERE stock_date <= DATE '2022-01-01'
|
|
ORDER BY product_code, color_code, yaka_kodu, stock_date DESC
|
|
),
|
|
Source AS (
|
|
SELECT
|
|
product_code,
|
|
color_code,
|
|
yaka_kodu,
|
|
item_description,
|
|
kategori,
|
|
seri,
|
|
yas_grubu,
|
|
CASE WHEN btrim(COALESCE(askili_yan,'')) = '-' THEN '' ELSE COALESCE(askili_yan,'') END AS askili_yan,
|
|
CASE
|
|
WHEN btrim(COALESCE(urun_ilk_grubu,'')) = '-' THEN ''
|
|
WHEN upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON') THEN ''
|
|
ELSE COALESCE(urun_ilk_grubu,'')
|
|
END AS urun_ilk_grubu,
|
|
COALESCE(urun_ana_grubu,'') AS urun_ana_grubu,
|
|
urun_alt_grubu,
|
|
market_key,
|
|
COALESCE(stock_qty,0) AS stock_qty,
|
|
COALESCE(NULLIF(avg_stock_90d,0), (COALESCE((SELECT s.stock_qty FROM Stock90Start s WHERE s.product_code = mk_product_performance_kpi_daily.product_code AND s.color_code = mk_product_performance_kpi_daily.color_code AND s.yaka_kodu = mk_product_performance_kpi_daily.yaka_kodu),0) + COALESCE(stock_qty,0)) / 2.0) AS avg_stock_90d,
|
|
COALESCE(NULLIF(avg_stock_180d,0), (COALESCE((SELECT s.stock_qty FROM Stock180Start s WHERE s.product_code = mk_product_performance_kpi_daily.product_code AND s.color_code = mk_product_performance_kpi_daily.color_code AND s.yaka_kodu = mk_product_performance_kpi_daily.yaka_kodu),0) + COALESCE(stock_qty,0)) / 2.0) AS avg_stock_180d,
|
|
COALESCE(NULLIF(avg_stock_365d,0), (COALESCE((SELECT s.stock_qty FROM Stock365Start s WHERE s.product_code = mk_product_performance_kpi_daily.product_code AND s.color_code = mk_product_performance_kpi_daily.color_code AND s.yaka_kodu = mk_product_performance_kpi_daily.yaka_kodu),0) + COALESCE(stock_qty,0)) / 2.0) AS avg_stock_365d,
|
|
COALESCE(NULLIF(avg_stock_total,0), (COALESCE((SELECT s.stock_qty FROM StockTotalStart s WHERE s.product_code = mk_product_performance_kpi_daily.product_code AND s.color_code = mk_product_performance_kpi_daily.color_code AND s.yaka_kodu = mk_product_performance_kpi_daily.yaka_kodu),0) + COALESCE(stock_qty,0)) / 2.0) AS avg_stock_total,
|
|
COALESCE(sales_qty_90d,0) AS sales_qty_90d,
|
|
COALESCE(sales_qty_180d,0) AS sales_qty_180d,
|
|
COALESCE(sales_qty_365d,0) AS sales_qty_365d,
|
|
COALESCE(sales_qty_total,0) AS sales_qty_total,
|
|
COALESCE(sales_usd_90d,0) AS sales_usd_90d,
|
|
COALESCE(sales_usd_180d,0) AS sales_usd_180d,
|
|
COALESCE(sales_usd_365d,0) AS sales_usd_365d,
|
|
COALESCE(sales_usd_total,0) AS sales_usd_total,
|
|
COALESCE(stock_days_90d,0) AS stock_days_90d,
|
|
COALESCE(stock_days_180d,0) AS stock_days_180d,
|
|
COALESCE(stock_days_365d,0) AS stock_days_365d,
|
|
COALESCE(stock_days_total,0) AS stock_days_total,
|
|
COALESCE(avg_price_usd_90d,0) AS avg_price_usd_90d,
|
|
COALESCE(avg_price_usd_180d,0) AS avg_price_usd_180d,
|
|
COALESCE(cost_price_usd,0) AS cost_price_usd,
|
|
COALESCE(base_price_usd,0) AS base_price_usd,
|
|
COALESCE(base_price_try,0) AS base_price_try,
|
|
COALESCE(gross_profit_usd_90d,0) AS gross_profit_usd_90d,
|
|
COALESCE(gross_profit_usd_180d,0) AS gross_profit_usd_180d,
|
|
COALESCE(gross_margin_90d,0) AS gross_margin_90d,
|
|
COALESCE(gross_margin_180d,0) AS gross_margin_180d,
|
|
COALESCE(unit_profit_cost_90d,0) AS unit_profit_cost_90d,
|
|
COALESCE(unit_profit_cost_180d,0) AS unit_profit_cost_180d,
|
|
COALESCE(unit_profit_base_90d,0) AS unit_profit_base_90d,
|
|
COALESCE(unit_profit_base_180d,0) AS unit_profit_base_180d,
|
|
COALESCE(market_count_90d,0) AS market_count_90d,
|
|
COALESCE(market_count_180d,0) AS market_count_180d,
|
|
COALESCE(market_count_365d,0) AS market_count_365d,
|
|
COALESCE(market_count_total,0) AS market_count_total,
|
|
COALESCE(customer_count_90d,0) AS customer_count_90d,
|
|
COALESCE(customer_count_180d,0) AS customer_count_180d,
|
|
COALESCE(customer_count_365d,0) AS customer_count_365d,
|
|
COALESCE(customer_count_total,0) AS customer_count_total,
|
|
COALESCE(sales_index_90d,0) AS sales_index_90d,
|
|
COALESCE(sales_index_180d,0) AS sales_index_180d,
|
|
COALESCE(sales_index_365d,0) AS sales_index_365d,
|
|
COALESCE(sales_index_total,0) AS sales_index_total,
|
|
COALESCE(price_index_90d,0) AS price_index_90d,
|
|
COALESCE(margin_index_90d,0) AS margin_index_90d,
|
|
COALESCE(performance_score,0) AS performance_score,
|
|
COALESCE(performance_bucket,'') AS performance_bucket,
|
|
COALESCE(recommendation,'') AS recommendation,
|
|
COALESCE(stock_qty,0) * COALESCE(cost_price_usd,0) AS idle_cost_usd
|
|
FROM mk_product_performance_kpi_daily
|
|
WHERE kpi_date = (SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily)
|
|
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')` + idleWhere + `
|
|
)`
|
|
}
|
|
|
|
func productPerformanceSQLGroupExpr(field string) (string, bool) {
|
|
switch field {
|
|
case "urun_ilk_grubu":
|
|
return productPerformanceCleanFirstGroupSQL("urun_ilk_grubu"), true
|
|
case "askili_yan":
|
|
return "CASE WHEN btrim(COALESCE(askili_yan,'')) = '-' THEN '' ELSE COALESCE(askili_yan,'') END", true
|
|
case "urun_ana_grubu":
|
|
return "COALESCE(NULLIF(btrim(COALESCE(urun_ana_grubu,'')), ''), NULLIF(btrim(COALESCE(urun_alt_grubu,'')), ''), NULLIF(btrim(COALESCE(kategori,'')), ''), '')", true
|
|
case "kategori", "urun_alt_grubu", "product_code", "item_description", "color_code", "yaka_kodu", "performance_bucket":
|
|
return field, true
|
|
case "color_yaka":
|
|
return "concat_ws('/', NULLIF(btrim(COALESCE(color_code,'')), ''), NULLIF(btrim(COALESCE(yaka_kodu,'')), ''))", true
|
|
case "market_key":
|
|
return "btrim(regexp_replace(COALESCE(market_key,''), '^.*\\|', ''))", true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
func shouldSkipProductPerformanceGroupValue(field, value string) bool {
|
|
switch field {
|
|
case "urun_ilk_grubu", "askili_yan":
|
|
return strings.TrimSpace(value) == ""
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func productPerformanceSQLFilterWhere(filters []productPerformanceSQLGroupFilter) (string, []any, error) {
|
|
if len(filters) == 0 {
|
|
return "", nil, nil
|
|
}
|
|
parts := make([]string, 0, len(filters))
|
|
args := make([]any, 0, len(filters))
|
|
for _, filter := range filters {
|
|
expr, ok := productPerformanceSQLGroupExpr(filter.Field)
|
|
if !ok {
|
|
return "", nil, fmt.Errorf("unsupported product performance filter field: %s", filter.Field)
|
|
}
|
|
values := filter.Values
|
|
if len(values) == 0 && strings.TrimSpace(filter.Value) != "" {
|
|
values = []string{filter.Value}
|
|
}
|
|
values = cleanProductPerformanceFilterValues(values)
|
|
if len(values) == 0 {
|
|
continue
|
|
}
|
|
args = append(args, pq.Array(values))
|
|
parts = append(parts, fmt.Sprintf("COALESCE(%s, '') = ANY($%d)", expr, len(args)))
|
|
}
|
|
if len(parts) == 0 {
|
|
return "", nil, nil
|
|
}
|
|
return "WHERE " + strings.Join(parts, " AND "), args, nil
|
|
}
|
|
|
|
func cleanProductPerformanceFilterValues(values []string) []string {
|
|
out := make([]string, 0, len(values))
|
|
seen := map[string]bool{}
|
|
for _, value := range values {
|
|
clean := normalizeProductPerformanceGroupValue(value)
|
|
if clean == "" || seen[clean] {
|
|
continue
|
|
}
|
|
seen[clean] = true
|
|
out = append(out, clean)
|
|
if len(out) >= 300 {
|
|
break
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func filterProductPerformanceGroupedRows(rows []map[string]any, filters map[string][]string) []map[string]any {
|
|
if len(filters) == 0 || len(rows) == 0 {
|
|
return rows
|
|
}
|
|
cleanFilters := map[string]map[string]bool{}
|
|
for field, values := range filters {
|
|
cleanValues := cleanProductPerformanceFilterValues(values)
|
|
if len(cleanValues) == 0 {
|
|
continue
|
|
}
|
|
set := map[string]bool{}
|
|
for _, value := range cleanValues {
|
|
set[value] = true
|
|
}
|
|
cleanFilters[field] = set
|
|
}
|
|
if len(cleanFilters) == 0 {
|
|
return rows
|
|
}
|
|
out := make([]map[string]any, 0, len(rows))
|
|
for _, row := range rows {
|
|
matches := true
|
|
for field, allowed := range cleanFilters {
|
|
if !allowed[productPerformanceGroupedFilterValue(row, field)] {
|
|
matches = false
|
|
break
|
|
}
|
|
}
|
|
if matches {
|
|
out = append(out, row)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func filterProductPerformancePreparedGroupedRows(rows []map[string]any, filters map[string][]string) []map[string]any {
|
|
if len(filters) == 0 || len(rows) == 0 {
|
|
return rows
|
|
}
|
|
cleanFilters := map[string]map[string]bool{}
|
|
for field, values := range filters {
|
|
cleanValues := cleanProductPerformanceFilterValues(values)
|
|
if len(cleanValues) == 0 {
|
|
continue
|
|
}
|
|
set := map[string]bool{}
|
|
for _, value := range cleanValues {
|
|
set[value] = true
|
|
}
|
|
cleanFilters[field] = set
|
|
}
|
|
if len(cleanFilters) == 0 {
|
|
return rows
|
|
}
|
|
|
|
rowByKey := make(map[string]map[string]any, len(rows))
|
|
for _, row := range rows {
|
|
if key := stringFromMap(row, "key"); key != "" {
|
|
rowByKey[key] = row
|
|
}
|
|
}
|
|
|
|
matchedKeys := map[string]bool{}
|
|
allowedKeys := map[string]bool{}
|
|
for _, row := range rows {
|
|
key := stringFromMap(row, "key")
|
|
if key == "" {
|
|
continue
|
|
}
|
|
if !productPerformancePreparedGroupedPathMatches(key, rowByKey, cleanFilters) {
|
|
continue
|
|
}
|
|
matchedKeys[key] = true
|
|
for _, ancestor := range productPerformanceGroupKeyAncestors(key, true) {
|
|
allowedKeys[ancestor] = true
|
|
}
|
|
}
|
|
if len(allowedKeys) == 0 {
|
|
return []map[string]any{}
|
|
}
|
|
out := make([]map[string]any, 0, len(rows))
|
|
for _, row := range rows {
|
|
key := stringFromMap(row, "key")
|
|
if allowedKeys[key] || productPerformanceGroupRowHasMatchedAncestor(key, matchedKeys) {
|
|
out = append(out, row)
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func productPerformancePreparedGroupedPathMatches(key string, rowByKey map[string]map[string]any, filters map[string]map[string]bool) bool {
|
|
pathKeys := productPerformanceGroupKeyAncestors(key, true)
|
|
if len(pathKeys) == 0 {
|
|
pathKeys = []string{key}
|
|
}
|
|
for field, allowed := range filters {
|
|
matched := false
|
|
for _, pathKey := range pathKeys {
|
|
row := rowByKey[pathKey]
|
|
if row == nil {
|
|
continue
|
|
}
|
|
if allowed[productPerformancePreparedGroupedFilterValue(row, field)] {
|
|
matched = true
|
|
break
|
|
}
|
|
}
|
|
if !matched {
|
|
return false
|
|
}
|
|
}
|
|
return true
|
|
}
|
|
|
|
func productPerformancePreparedGroupedFilterValue(row map[string]any, field string) string {
|
|
field = strings.TrimSpace(field)
|
|
groupField := strings.TrimSpace(stringFromMap(row, "group_field"))
|
|
if groupField == field {
|
|
return normalizeProductPerformanceGroupValue(stringFromMap(row, "group_value"))
|
|
}
|
|
switch field {
|
|
case "market_key", "color_yaka", "urun_ilk_grubu", "askili_yan":
|
|
value := normalizeProductPerformanceGroupValue(mapGroupValue(row, field))
|
|
if value != "" {
|
|
return value
|
|
}
|
|
}
|
|
if value := normalizeProductPerformanceGroupValue(stringFromMap(row, field)); value != "" {
|
|
return value
|
|
}
|
|
return ""
|
|
}
|
|
|
|
func productPerformanceGroupRowHasMatchedAncestor(key string, matchedKeys map[string]bool) bool {
|
|
for _, ancestor := range productPerformanceGroupKeyAncestors(key, true) {
|
|
if matchedKeys[ancestor] {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func filterProductPerformanceGroupedRowsForExpansion(rows []map[string]any, expandedKeys map[string]bool, expandThroughLevel int, limit int) []map[string]any {
|
|
if len(rows) == 0 {
|
|
return rows
|
|
}
|
|
if limit <= 0 || limit > 50000 {
|
|
limit = 50000
|
|
}
|
|
out := make([]map[string]any, 0, minInt(limit, len(rows)))
|
|
for _, row := range rows {
|
|
if !productPerformanceGroupedRowVisible(row, expandedKeys, expandThroughLevel) {
|
|
continue
|
|
}
|
|
out = append(out, row)
|
|
if len(out) >= limit {
|
|
break
|
|
}
|
|
}
|
|
return out
|
|
}
|
|
|
|
func productPerformanceGroupedRowVisible(row map[string]any, expandedKeys map[string]bool, expandThroughLevel int) bool {
|
|
level := intFromMap(row, "level")
|
|
if level <= 0 {
|
|
return true
|
|
}
|
|
key := stringFromMap(row, "key")
|
|
if key == "" {
|
|
return true
|
|
}
|
|
ancestors := productPerformanceGroupKeyAncestors(key, false)
|
|
for ancestorLevel, ancestor := range ancestors {
|
|
if ancestorLevel <= expandThroughLevel {
|
|
continue
|
|
}
|
|
if expandedKeys != nil && expandedKeys[ancestor] {
|
|
continue
|
|
}
|
|
return false
|
|
}
|
|
return true
|
|
}
|
|
|
|
func productPerformanceGroupKeyAncestors(key string, includeSelf bool) []string {
|
|
parts := strings.Split(strings.TrimSpace(key), "|")
|
|
if len(parts) <= 1 {
|
|
return nil
|
|
}
|
|
last := len(parts) - 1
|
|
if !includeSelf {
|
|
last--
|
|
}
|
|
if last < 1 {
|
|
return nil
|
|
}
|
|
out := make([]string, 0, last)
|
|
for i := 1; i <= last; i++ {
|
|
out = append(out, strings.Join(parts[:i+1], "|"))
|
|
}
|
|
return out
|
|
}
|
|
|
|
func productPerformanceParentGroupKey(key string) string {
|
|
parts := strings.Split(strings.TrimSpace(key), "|")
|
|
if len(parts) <= 2 {
|
|
return ""
|
|
}
|
|
return strings.Join(parts[:len(parts)-1], "|")
|
|
}
|
|
|
|
func productPerformanceGroupedFilterValue(row map[string]any, field string) string {
|
|
switch field {
|
|
case "urun_ana_grubu":
|
|
value := strings.TrimSpace(stringFromMap(row, "urun_ana_grubu"))
|
|
if value != "" {
|
|
return normalizeProductPerformanceGroupValue(value)
|
|
}
|
|
if value = strings.TrimSpace(stringFromMap(row, "urun_alt_grubu")); value != "" {
|
|
return normalizeProductPerformanceGroupValue(value)
|
|
}
|
|
return normalizeProductPerformanceGroupValue(stringFromMap(row, "kategori"))
|
|
case "market_key", "color_yaka", "urun_ilk_grubu", "askili_yan":
|
|
return normalizeProductPerformanceGroupValue(mapGroupValue(row, field))
|
|
default:
|
|
return normalizeProductPerformanceGroupValue(stringFromMap(row, field))
|
|
}
|
|
}
|
|
|
|
func productPerformanceGroupedSourceRows(ctx context.Context, pg *sql.DB, mode string, limit int) ([]map[string]any, error) {
|
|
if rows, err := productPerformanceGroupedRawSnapshotSourceRows(ctx, pg, mode, limit); err != nil {
|
|
return nil, err
|
|
} else if rows != nil {
|
|
return rows, nil
|
|
}
|
|
|
|
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, productPerformanceSalesBreakdownMode(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 productPerformanceGroupedRawSnapshotSourceRows(ctx context.Context, pg *sql.DB, mode string, limit int) ([]map[string]any, error) {
|
|
reportKey, ok := productPerformanceGroupedSnapshotKey(mode)
|
|
if !ok {
|
|
return nil, nil
|
|
}
|
|
rows, exists, err := loadProductPerformanceSnapshotMapRows(ctx, pg, reportKey, limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
if !exists {
|
|
return nil, nil
|
|
}
|
|
if strings.TrimSpace(mode) == "idle" {
|
|
normalizeProductPerformanceGroupedSourceScores(rows, mode)
|
|
return productPerformanceIdleSourceRows(rows), nil
|
|
}
|
|
if mode == "products" || mode == "product_detail" {
|
|
rows = mergeProductPerformanceGeneralSnapshotMetrics(ctx, pg, rows)
|
|
rows = mergeProductPerformanceSalesSpreadKeys(ctx, pg, rows)
|
|
}
|
|
normalizeProductPerformanceGroupedSourceScores(rows, mode)
|
|
return rows, nil
|
|
}
|
|
|
|
func normalizeProductPerformanceGroupedSourceScores(rows []map[string]any, mode string) {
|
|
if !shouldCascadeProductPerformanceGroupedScores(mode) {
|
|
return
|
|
}
|
|
for _, row := range rows {
|
|
deriveProductPerformanceGroupMetrics(row, "")
|
|
for _, suffix := range productPerformancePeriodSuffixes() {
|
|
row["performance_score_"+suffix] = productPerformanceSalesPeriodScore(row, suffix)
|
|
}
|
|
row["performance_score"] = row["performance_score_90d"]
|
|
}
|
|
}
|
|
|
|
func mergeProductPerformanceGeneralSnapshotMetrics(ctx context.Context, pg *sql.DB, rows []map[string]any) []map[string]any {
|
|
if len(rows) == 0 {
|
|
return rows
|
|
}
|
|
generalRows, ok, err := loadProductPerformanceSnapshotMapRows(ctx, pg, productPerformanceSnapshotKey("general"), 50000)
|
|
if err != nil || !ok || len(generalRows) == 0 {
|
|
return rows
|
|
}
|
|
byKey := make(map[string]map[string]any, len(generalRows))
|
|
for _, row := range generalRows {
|
|
key := productPerformanceMapMarketVariantKey(row)
|
|
if key != "" {
|
|
byKey[key] = row
|
|
}
|
|
}
|
|
for _, row := range rows {
|
|
general := byKey[productPerformanceMapMarketVariantKey(row)]
|
|
if general == nil {
|
|
continue
|
|
}
|
|
for _, field := range []string{
|
|
"market_count_total", "customer_count_total", "invoice_count_total", "sales_index_total",
|
|
"avg_price_usd_total", "gross_profit_usd_total", "gross_margin_total",
|
|
"unit_profit_cost_total", "unit_profit_base_total", "first_sale_date",
|
|
} {
|
|
if value, ok := general[field]; ok {
|
|
row[field] = value
|
|
}
|
|
}
|
|
if value, ok := general["performance_score"]; ok {
|
|
if _, exists := row["performance_score_total"]; !exists {
|
|
row["performance_score_total"] = value
|
|
}
|
|
}
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func mergeProductPerformanceSalesSpreadKeys(ctx context.Context, pg *sql.DB, rows []map[string]any) []map[string]any {
|
|
if len(rows) == 0 {
|
|
return rows
|
|
}
|
|
const query = `
|
|
WITH Latest AS (
|
|
SELECT COALESCE(
|
|
(SELECT MAX(kpi_date) FROM mk_product_performance_kpi_daily),
|
|
(SELECT MAX(sales_date) FROM mk_product_performance_sales_daily),
|
|
current_date
|
|
)::date AS kpi_date
|
|
),
|
|
Spread AS (
|
|
SELECT
|
|
s.product_code,
|
|
s.color_code,
|
|
s.yaka_kodu,
|
|
s.market_key,
|
|
COALESCE(jsonb_agg(DISTINCT btrim(s.customer_code)) FILTER (
|
|
WHERE s.sales_date BETWEEN Latest.kpi_date - INTERVAL '89 days' AND Latest.kpi_date
|
|
AND COALESCE(s.sales_usd,0) > 0
|
|
AND btrim(COALESCE(s.customer_code,'')) NOT IN ('', '-')
|
|
), '[]'::jsonb) AS customer_keys_90d,
|
|
COALESCE(jsonb_agg(DISTINCT btrim(s.customer_code)) FILTER (
|
|
WHERE s.sales_date BETWEEN Latest.kpi_date - INTERVAL '179 days' AND Latest.kpi_date
|
|
AND COALESCE(s.sales_usd,0) > 0
|
|
AND btrim(COALESCE(s.customer_code,'')) NOT IN ('', '-')
|
|
), '[]'::jsonb) AS customer_keys_180d,
|
|
COALESCE(jsonb_agg(DISTINCT btrim(s.customer_code)) FILTER (
|
|
WHERE s.sales_date BETWEEN Latest.kpi_date - INTERVAL '359 days' AND Latest.kpi_date
|
|
AND COALESCE(s.sales_usd,0) > 0
|
|
AND btrim(COALESCE(s.customer_code,'')) NOT IN ('', '-')
|
|
), '[]'::jsonb) AS customer_keys_365d,
|
|
COALESCE(jsonb_agg(DISTINCT btrim(s.customer_code)) FILTER (
|
|
WHERE s.sales_date BETWEEN DATE '2022-01-01' AND Latest.kpi_date
|
|
AND COALESCE(s.sales_usd,0) > 0
|
|
AND btrim(COALESCE(s.customer_code,'')) NOT IN ('', '-')
|
|
), '[]'::jsonb) AS customer_keys_total
|
|
FROM mk_product_performance_sales_daily s
|
|
CROSS JOIN Latest
|
|
WHERE s.sales_date BETWEEN DATE '2022-01-01' AND Latest.kpi_date
|
|
AND upper(translate(btrim(COALESCE(s.urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
|
|
GROUP BY s.product_code, s.color_code, s.yaka_kodu, s.market_key
|
|
)
|
|
SELECT jsonb_build_object(
|
|
'product_code', product_code,
|
|
'color_code', color_code,
|
|
'yaka_kodu', yaka_kodu,
|
|
'market_key', market_key,
|
|
'__customer_keys_90d', customer_keys_90d,
|
|
'__customer_keys_180d', customer_keys_180d,
|
|
'__customer_keys_365d', customer_keys_365d,
|
|
'__customer_keys_total', customer_keys_total
|
|
)
|
|
FROM Spread`
|
|
spreadRows, err := queryProductPerformanceJSONRows(ctx, pg, query)
|
|
if err != nil {
|
|
log.Printf("[ProductPerformanceRefresh] product sales spread keys skipped err=%v", err)
|
|
return rows
|
|
}
|
|
byKey := make(map[string]map[string]any, len(spreadRows))
|
|
for _, row := range spreadRows {
|
|
key := productPerformanceMapMarketVariantKey(row)
|
|
if key != "" {
|
|
byKey[key] = row
|
|
}
|
|
}
|
|
for _, row := range rows {
|
|
spread := byKey[productPerformanceMapMarketVariantKey(row)]
|
|
if spread == nil {
|
|
continue
|
|
}
|
|
for _, suffix := range productPerformancePeriodSuffixes() {
|
|
field := "__customer_keys_" + suffix
|
|
if value, ok := spread[field]; ok {
|
|
row[field] = value
|
|
}
|
|
}
|
|
}
|
|
return rows
|
|
}
|
|
|
|
func productPerformanceMapMarketVariantKey(row map[string]any) string {
|
|
productCode := normalizeProductPerformanceProductCode(stringFromMap(row, "product_code"))
|
|
if productCode == "" {
|
|
return ""
|
|
}
|
|
return strings.Join([]string{
|
|
productCode,
|
|
strings.TrimSpace(stringFromMap(row, "color_code")),
|
|
strings.TrimSpace(stringFromMap(row, "yaka_kodu")),
|
|
displayProductPerformanceMarketName(stringFromMap(row, "market_key")),
|
|
}, "|")
|
|
}
|
|
|
|
func productPerformanceGroupedSnapshotRows(ctx context.Context, pg *sql.DB, mode string, limit int) ([]map[string]any, bool, error) {
|
|
reportKey, ok := productPerformanceGroupedSnapshotKey(mode)
|
|
if !ok {
|
|
return nil, false, nil
|
|
}
|
|
rows, exists, err := loadProductPerformanceSnapshotMapRows(ctx, pg, reportKey, limit)
|
|
if err != nil || !exists {
|
|
return rows, exists, err
|
|
}
|
|
if strings.TrimSpace(mode) == "idle" {
|
|
return productPerformanceIdleSourceRows(rows), true, nil
|
|
}
|
|
return rows, true, nil
|
|
}
|
|
|
|
func productPerformanceGroupedSnapshotReportKey(mode string, levels []string, mainGroup string) string {
|
|
levels = sanitizeProductPerformanceGroupLevels(levels)
|
|
if len(levels) == 0 {
|
|
levels = defaultProductPerformanceGroupLevels(mode)
|
|
}
|
|
parts := []string{"grouped", strings.TrimSpace(mode), productPerformanceGroupedLevelsKey(levels)}
|
|
if strings.TrimSpace(mainGroup) != "" {
|
|
parts = append(parts, strings.TrimSpace(mainGroup))
|
|
}
|
|
return productPerformanceSnapshotKey(parts...)
|
|
}
|
|
|
|
func productPerformanceGroupedLevelsKey(levels []string) string {
|
|
levels = sanitizeProductPerformanceGroupLevels(levels)
|
|
if len(levels) == 0 {
|
|
return ""
|
|
}
|
|
return strings.Join(levels, ">")
|
|
}
|
|
|
|
func productPerformanceGroupedSnapshotKey(mode string) (string, bool) {
|
|
switch strings.TrimSpace(mode) {
|
|
case "products", "product_detail", "idle", "":
|
|
return productPerformanceSnapshotKey("products"), true
|
|
case "order_product_customers":
|
|
return productPerformanceSnapshotKey("order-product-customers"), true
|
|
case "order_market_details":
|
|
return productPerformanceSnapshotKey("order-market-details"), true
|
|
case "sales_color_yaka_market_customer", "sales_product_country_segment_market_customer", "sales_market_customer_product", "sales_country_segment_market_customer_product":
|
|
return productPerformanceSnapshotKey("sales-breakdown", productPerformanceSalesBreakdownMode(mode)), true
|
|
default:
|
|
return "", false
|
|
}
|
|
}
|
|
|
|
func productPerformanceSalesBreakdownMode(mode string) string {
|
|
switch strings.TrimSpace(mode) {
|
|
case "sales_color_yaka_market_customer":
|
|
return "color_yaka_market_customer"
|
|
case "sales_product_country_segment_market_customer":
|
|
return "product_country_segment_market_customer"
|
|
case "sales_market_customer_product":
|
|
return "market_customer_product"
|
|
case "sales_country_segment_market_customer_product":
|
|
return "country_segment_market_customer_product"
|
|
default:
|
|
return mode
|
|
}
|
|
}
|
|
|
|
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
|
|
}
|
|
|
|
type productPerformanceGroupedSnapshotAvgState struct {
|
|
Weighted float64
|
|
Weight float64
|
|
Sum float64
|
|
Count float64
|
|
}
|
|
|
|
type productPerformanceGroupedSnapshotNode struct {
|
|
Key string
|
|
Level int
|
|
Field string
|
|
Value string
|
|
Row map[string]any
|
|
Count int
|
|
Children map[string]*productPerformanceGroupedSnapshotNode
|
|
ChildOrder []string
|
|
StockMetricSeen map[string]map[string]bool
|
|
IdleSeen map[string]bool
|
|
Avg map[string]*productPerformanceGroupedSnapshotAvgState
|
|
BucketCounts map[string]int
|
|
Image map[string]any
|
|
MarketSeen map[string]map[string]bool
|
|
CustomerSeen map[string]map[string]bool
|
|
}
|
|
|
|
func buildProductPerformanceGroupedSnapshotRows(sourceRows []map[string]any, levels []string, mode string) []map[string]any {
|
|
roots := map[string]*productPerformanceGroupedSnapshotNode{}
|
|
rootOrder := make([]string, 0)
|
|
for _, row := range sourceRows {
|
|
parentKeys := []string{"tab:" + mode}
|
|
parentChildren := roots
|
|
parentOrder := &rootOrder
|
|
visualLevel := 0
|
|
for _, field := range levels {
|
|
value := normalizeProductPerformanceGroupValue(mapGroupValue(row, field))
|
|
if shouldSkipProductPerformanceGroupValue(field, value) {
|
|
continue
|
|
}
|
|
keyPart := field + ":" + value
|
|
key := strings.Join(append(parentKeys, keyPart), "|")
|
|
node := parentChildren[key]
|
|
if node == nil {
|
|
node = &productPerformanceGroupedSnapshotNode{
|
|
Key: key,
|
|
Level: visualLevel,
|
|
Field: field,
|
|
Value: value,
|
|
Row: map[string]any{},
|
|
Children: map[string]*productPerformanceGroupedSnapshotNode{},
|
|
}
|
|
parentChildren[key] = node
|
|
*parentOrder = append(*parentOrder, key)
|
|
}
|
|
node.add(row)
|
|
parentKeys = append(parentKeys, keyPart)
|
|
parentChildren = node.Children
|
|
parentOrder = &node.ChildOrder
|
|
visualLevel++
|
|
}
|
|
}
|
|
|
|
out := make([]map[string]any, 0, len(sourceRows))
|
|
appendProductPerformanceGroupedSnapshotNodes(&out, roots, rootOrder)
|
|
applyProductPerformanceGroupedChildScoreAverages(out, mode)
|
|
return out
|
|
}
|
|
|
|
func applyProductPerformanceGroupedChildScoreAverages(rows []map[string]any, mode string) {
|
|
if !shouldCascadeProductPerformanceGroupedScores(mode) || len(rows) == 0 {
|
|
return
|
|
}
|
|
byKey := make(map[string]map[string]any, len(rows))
|
|
children := map[string][]map[string]any{}
|
|
maxLevel := -1
|
|
for _, row := range rows {
|
|
key := stringFromMap(row, "key")
|
|
if key == "" {
|
|
continue
|
|
}
|
|
byKey[key] = row
|
|
if level := intFromMap(row, "level"); level > maxLevel {
|
|
maxLevel = level
|
|
}
|
|
}
|
|
for _, row := range rows {
|
|
key := stringFromMap(row, "key")
|
|
parentKey := productPerformanceParentGroupKey(key)
|
|
if parentKey == "" || byKey[parentKey] == nil {
|
|
continue
|
|
}
|
|
children[parentKey] = append(children[parentKey], row)
|
|
}
|
|
for level := maxLevel - 1; level >= 0; level-- {
|
|
for _, row := range rows {
|
|
if intFromMap(row, "level") != level {
|
|
continue
|
|
}
|
|
childRows := children[stringFromMap(row, "key")]
|
|
if len(childRows) == 0 {
|
|
continue
|
|
}
|
|
for _, suffix := range productPerformancePeriodSuffixes() {
|
|
field := "performance_score_" + suffix
|
|
if !productPerformanceRowsHaveField(childRows, field) {
|
|
continue
|
|
}
|
|
row[field] = weightedAverageProductPerformanceScoreRows(childRows, field, suffix)
|
|
}
|
|
if score, ok := productPerformanceOptionalFloat(row, "performance_score_90d"); ok {
|
|
row["performance_score"] = score
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func shouldCascadeProductPerformanceGroupedScores(mode string) bool {
|
|
switch strings.TrimSpace(mode) {
|
|
case "products",
|
|
"product_detail",
|
|
"idle",
|
|
"sales_color_yaka_market_customer",
|
|
"sales_product_country_segment_market_customer",
|
|
"sales_market_customer_product",
|
|
"sales_country_segment_market_customer_product":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func productPerformanceRowsHaveField(rows []map[string]any, field string) bool {
|
|
for _, row := range rows {
|
|
if _, ok := productPerformanceOptionalFloat(row, field); ok {
|
|
return true
|
|
}
|
|
}
|
|
return false
|
|
}
|
|
|
|
func (n *productPerformanceGroupedSnapshotNode) add(row map[string]any) {
|
|
n.Count++
|
|
if n.Image == nil && stringFromMap(row, "product_code") != "" {
|
|
n.Image = row
|
|
}
|
|
if bucket := stringFromMap(row, "performance_bucket"); bucket != "" {
|
|
if n.BucketCounts == nil {
|
|
n.BucketCounts = map[string]int{}
|
|
}
|
|
n.BucketCounts[bucket]++
|
|
}
|
|
for key, value := range row {
|
|
if key == "row_key" || key == "key" || isProductPerformanceInternalGroupField(key) || isProductPerformanceMarginField(key) {
|
|
continue
|
|
}
|
|
switch {
|
|
case isProductPerformanceDistinctVariantStockMetric(key):
|
|
n.addDistinctVariantMetric(row, key, value)
|
|
case key == "idle_cost_usd":
|
|
variantKey := productPerformanceMapVariantKey(row)
|
|
if variantKey == "" {
|
|
n.Row[key] = floatFromAny(n.Row[key]) + floatFromAny(value)
|
|
continue
|
|
}
|
|
if n.IdleSeen == nil {
|
|
n.IdleSeen = map[string]bool{}
|
|
}
|
|
if !n.IdleSeen[variantKey] {
|
|
n.IdleSeen[variantKey] = true
|
|
n.Row[key] = floatFromAny(n.Row[key]) + floatFromAny(value)
|
|
}
|
|
case shouldAverageProductPerformanceField(key):
|
|
if n.Avg == nil {
|
|
n.Avg = map[string]*productPerformanceGroupedSnapshotAvgState{}
|
|
}
|
|
state := n.Avg[key]
|
|
if state == nil {
|
|
state = &productPerformanceGroupedSnapshotAvgState{}
|
|
n.Avg[key] = state
|
|
}
|
|
number := floatFromAny(value)
|
|
weight := productPerformanceMetricWeight(row, key)
|
|
if weight > 0 {
|
|
state.Weighted += number * weight
|
|
state.Weight += weight
|
|
}
|
|
state.Sum += number
|
|
state.Count++
|
|
case shouldSumProductPerformanceField(key):
|
|
n.Row[key] = floatFromAny(n.Row[key]) + floatFromAny(value)
|
|
default:
|
|
if _, ok := n.Row[key]; !ok {
|
|
n.Row[key] = value
|
|
}
|
|
}
|
|
}
|
|
n.addDistinctSpread(row)
|
|
}
|
|
|
|
func (n *productPerformanceGroupedSnapshotNode) addDistinctVariantMetric(row map[string]any, key string, value any) {
|
|
variantKey := productPerformanceMapVariantKey(row)
|
|
if variantKey == "" {
|
|
n.Row[key] = floatFromAny(n.Row[key]) + floatFromAny(value)
|
|
return
|
|
}
|
|
if n.StockMetricSeen == nil {
|
|
n.StockMetricSeen = map[string]map[string]bool{}
|
|
}
|
|
seen := n.StockMetricSeen[key]
|
|
if seen == nil {
|
|
seen = map[string]bool{}
|
|
n.StockMetricSeen[key] = seen
|
|
}
|
|
if seen[variantKey] {
|
|
return
|
|
}
|
|
seen[variantKey] = true
|
|
n.Row[key] = floatFromAny(n.Row[key]) + floatFromAny(value)
|
|
}
|
|
|
|
func (n *productPerformanceGroupedSnapshotNode) addDistinctSpread(row map[string]any) {
|
|
market := displayProductPerformanceMarketName(stringFromMap(row, "market_key"))
|
|
if market != "" && market != "STOK" {
|
|
for _, suffix := range productPerformancePeriodSuffixes() {
|
|
if floatFromMap(row, "sales_usd_"+suffix) > 0 {
|
|
if n.MarketSeen == nil {
|
|
n.MarketSeen = map[string]map[string]bool{}
|
|
}
|
|
if n.MarketSeen[suffix] == nil {
|
|
n.MarketSeen[suffix] = map[string]bool{}
|
|
}
|
|
n.MarketSeen[suffix][market] = true
|
|
}
|
|
}
|
|
}
|
|
customer := strings.TrimSpace(stringFromMap(row, "customer_code"))
|
|
if customer != "" && customer != "-" {
|
|
for _, suffix := range productPerformancePeriodSuffixes() {
|
|
if floatFromMap(row, "sales_usd_"+suffix) > 0 {
|
|
if n.CustomerSeen == nil {
|
|
n.CustomerSeen = map[string]map[string]bool{}
|
|
}
|
|
if n.CustomerSeen[suffix] == nil {
|
|
n.CustomerSeen[suffix] = map[string]bool{}
|
|
}
|
|
n.CustomerSeen[suffix][customer] = true
|
|
}
|
|
}
|
|
}
|
|
for _, suffix := range productPerformancePeriodSuffixes() {
|
|
n.CustomerSeen = productPerformanceAddSeenStrings(n.CustomerSeen, suffix, productPerformanceStringSliceFromMap(row, "__customer_keys_"+suffix))
|
|
}
|
|
}
|
|
|
|
func appendProductPerformanceGroupedSnapshotNodes(out *[]map[string]any, nodes map[string]*productPerformanceGroupedSnapshotNode, order []string) {
|
|
sort.SliceStable(order, func(i, j int) bool {
|
|
left := nodes[order[i]]
|
|
right := nodes[order[j]]
|
|
if left == nil || right == nil {
|
|
return order[i] < order[j]
|
|
}
|
|
return strings.Compare(left.Value, right.Value) < 0
|
|
})
|
|
for _, key := range order {
|
|
node := nodes[key]
|
|
if node == nil {
|
|
continue
|
|
}
|
|
*out = append(*out, node.snapshotRow())
|
|
if len(node.Children) > 0 {
|
|
appendProductPerformanceGroupedSnapshotNodes(out, node.Children, node.ChildOrder)
|
|
}
|
|
}
|
|
}
|
|
|
|
func (n *productPerformanceGroupedSnapshotNode) snapshotRow() map[string]any {
|
|
row := cloneMap(n.Row)
|
|
for key, state := range n.Avg {
|
|
if state == nil {
|
|
continue
|
|
}
|
|
if state.Weight > 0 {
|
|
row[key] = state.Weighted / state.Weight
|
|
} else if state.Count > 0 {
|
|
row[key] = state.Sum / state.Count
|
|
}
|
|
}
|
|
applyProductPerformanceDistinctSpread(row, n.MarketSeen, n.CustomerSeen)
|
|
deriveProductPerformanceGroupMetrics(row, n.Field)
|
|
if bucket := n.dominantBucket(); bucket != "" {
|
|
row["performance_bucket"] = bucket
|
|
}
|
|
clearProductPerformanceGroupDimensions(row, n.Field, n.Value)
|
|
row["__group"] = true
|
|
row["row_key"] = "group|" + n.Key
|
|
row["key"] = n.Key
|
|
row["level"] = n.Level
|
|
row["group_field"] = n.Field
|
|
row["group_value"] = n.Value
|
|
row["label"] = n.Value
|
|
row["count"] = n.Count
|
|
row["recommendation"] = productPerformanceGroupRecommendation(row, n.Field, n.Count)
|
|
image := n.Image
|
|
if image == nil {
|
|
image = row
|
|
}
|
|
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 (n *productPerformanceGroupedSnapshotNode) dominantBucket() string {
|
|
best := ""
|
|
bestCount := 0
|
|
for bucket, count := range n.BucketCounts {
|
|
if count > bestCount || (count == bestCount && bucket < best) {
|
|
best = bucket
|
|
bestCount = count
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
func appendProductPerformanceGroupedRows(out *[]map[string]any, sourceRows []map[string]any, levels []string, level int, visualLevel int, parentKeys []string, expandedKeys map[string]bool, expandThroughLevel int) {
|
|
if level >= len(levels) {
|
|
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 {
|
|
groupRows := grouped[value]
|
|
if shouldSkipProductPerformanceGroupValue(field, value) {
|
|
appendProductPerformanceGroupedRows(out, groupRows, levels, level+1, visualLevel, parentKeys, expandedKeys, expandThroughLevel)
|
|
continue
|
|
}
|
|
keyPart := field + ":" + value
|
|
key := strings.Join(append(parentKeys, keyPart), "|")
|
|
*out = append(*out, makeProductPerformanceGroupedRow(key, visualLevel, field, value, groupRows))
|
|
if expandedKeys[key] || level <= expandThroughLevel {
|
|
appendProductPerformanceGroupedRows(out, groupRows, levels, level+1, visualLevel+1, append(parentKeys, keyPart), expandedKeys, expandThroughLevel)
|
|
}
|
|
}
|
|
}
|
|
|
|
func makeProductPerformanceGroupedRow(key string, level int, field, value string, rows []map[string]any) map[string]any {
|
|
row := aggregateProductPerformanceRows(rows, field)
|
|
clearProductPerformanceGroupDimensions(row, field, value)
|
|
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["recommendation"] = productPerformanceGroupRecommendation(row, field, 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
|
|
}
|
|
|
|
var productPerformanceGroupDimensionFields = map[string]bool{
|
|
"product_code": true,
|
|
"color_yaka": true,
|
|
"color_code": true,
|
|
"yaka_kodu": true,
|
|
"item_description": true,
|
|
"kategori": true,
|
|
"askili_yan": true,
|
|
"urun_ilk_grubu": true,
|
|
"urun_ana_grubu": true,
|
|
"urun_alt_grubu": true,
|
|
"market_key": true,
|
|
"country": true,
|
|
"customer_segment": true,
|
|
"customer_code": true,
|
|
"customer_name": true,
|
|
}
|
|
|
|
func clearProductPerformanceGroupDimensions(row map[string]any, groupField, groupValue string) {
|
|
for field := range productPerformanceGroupDimensionFields {
|
|
row[field] = ""
|
|
}
|
|
if productPerformanceGroupDimensionFields[groupField] {
|
|
row[groupField] = groupValue
|
|
}
|
|
}
|
|
|
|
func aggregateProductPerformanceRows(rows []map[string]any, groupField string) map[string]any {
|
|
out := map[string]any{}
|
|
marketSeen := map[string]map[string]bool{}
|
|
customerSeen := map[string]map[string]bool{}
|
|
for _, row := range rows {
|
|
addProductPerformanceDistinctSpread(row, marketSeen, customerSeen)
|
|
for key, value := range row {
|
|
if key == "row_key" || key == "key" || isProductPerformanceInternalGroupField(key) {
|
|
continue
|
|
}
|
|
if isProductPerformanceMarginField(key) {
|
|
continue
|
|
}
|
|
if isProductPerformanceDistinctVariantStockMetric(key) {
|
|
out[key] = distinctProductPerformanceVariantNumber(rows, key)
|
|
continue
|
|
}
|
|
if key == "idle_cost_usd" {
|
|
out[key] = distinctProductPerformanceVariantStockCost(rows)
|
|
continue
|
|
}
|
|
if shouldAverageProductPerformanceField(key) {
|
|
continue
|
|
}
|
|
if shouldSumProductPerformanceField(key) {
|
|
out[key] = floatFromAny(out[key]) + floatFromAny(value)
|
|
} else if _, ok := out[key]; !ok {
|
|
out[key] = value
|
|
}
|
|
}
|
|
}
|
|
for _, row := range rows {
|
|
for key := range row {
|
|
if isProductPerformanceInternalGroupField(key) {
|
|
continue
|
|
}
|
|
if isProductPerformanceMarginField(key) {
|
|
continue
|
|
}
|
|
if shouldAverageProductPerformanceField(key) {
|
|
out[key] = weightedAverageProductPerformanceRows(rows, key, productPerformanceMetricWeightField(key))
|
|
}
|
|
}
|
|
}
|
|
applyProductPerformanceDistinctSpread(out, marketSeen, customerSeen)
|
|
deriveProductPerformanceGroupMetrics(out, groupField)
|
|
out["performance_bucket"] = dominantProductPerformanceValue(rows, "performance_bucket")
|
|
return out
|
|
}
|
|
|
|
func addProductPerformanceDistinctSpread(row map[string]any, marketSeen, customerSeen map[string]map[string]bool) {
|
|
market := displayProductPerformanceMarketName(stringFromMap(row, "market_key"))
|
|
if market != "" && market != "STOK" {
|
|
for _, suffix := range productPerformancePeriodSuffixes() {
|
|
if floatFromMap(row, "sales_usd_"+suffix) > 0 {
|
|
if marketSeen[suffix] == nil {
|
|
marketSeen[suffix] = map[string]bool{}
|
|
}
|
|
marketSeen[suffix][market] = true
|
|
}
|
|
}
|
|
}
|
|
customer := strings.TrimSpace(stringFromMap(row, "customer_code"))
|
|
if customer != "" && customer != "-" {
|
|
for _, suffix := range productPerformancePeriodSuffixes() {
|
|
if floatFromMap(row, "sales_usd_"+suffix) > 0 {
|
|
if customerSeen[suffix] == nil {
|
|
customerSeen[suffix] = map[string]bool{}
|
|
}
|
|
customerSeen[suffix][customer] = true
|
|
}
|
|
}
|
|
}
|
|
for _, suffix := range productPerformancePeriodSuffixes() {
|
|
customerSeen = productPerformanceAddSeenStrings(customerSeen, suffix, productPerformanceStringSliceFromMap(row, "__customer_keys_"+suffix))
|
|
}
|
|
}
|
|
|
|
func applyProductPerformanceDistinctSpread(row map[string]any, marketSeen, customerSeen map[string]map[string]bool) {
|
|
for _, suffix := range productPerformancePeriodSuffixes() {
|
|
marketField := "market_count_" + suffix
|
|
customerField := "customer_count_" + suffix
|
|
if seen := marketSeen[suffix]; len(seen) > 0 {
|
|
row[marketField] = len(seen)
|
|
}
|
|
if seen := customerSeen[suffix]; len(seen) > 0 {
|
|
row[customerField] = len(seen)
|
|
}
|
|
if floatFromMap(row, "sales_usd_"+suffix) > 0 && (suffix == "90d" || suffix == "total") {
|
|
if intFromMap(row, marketField) == 0 {
|
|
row[marketField] = 1
|
|
}
|
|
if intFromMap(row, customerField) == 0 {
|
|
if suffix == "total" {
|
|
row[customerField] = maxInt(1, intFromMap(row, "customer_count_90d"))
|
|
} else {
|
|
row[customerField] = 1
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
func productPerformanceAddSeenStrings(seen map[string]map[string]bool, suffix string, values []string) map[string]map[string]bool {
|
|
if len(values) == 0 {
|
|
return seen
|
|
}
|
|
if seen == nil {
|
|
seen = map[string]map[string]bool{}
|
|
}
|
|
if seen[suffix] == nil {
|
|
seen[suffix] = map[string]bool{}
|
|
}
|
|
for _, value := range values {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" || value == "-" {
|
|
continue
|
|
}
|
|
seen[suffix][value] = true
|
|
}
|
|
return seen
|
|
}
|
|
|
|
func productPerformanceStringSliceFromMap(row map[string]any, field string) []string {
|
|
value, ok := row[field]
|
|
if !ok || value == nil {
|
|
return nil
|
|
}
|
|
switch v := value.(type) {
|
|
case []string:
|
|
return v
|
|
case []any:
|
|
out := make([]string, 0, len(v))
|
|
for _, item := range v {
|
|
text := strings.TrimSpace(fmt.Sprint(item))
|
|
if text != "" {
|
|
out = append(out, text)
|
|
}
|
|
}
|
|
return out
|
|
case string:
|
|
text := strings.TrimSpace(v)
|
|
if text == "" {
|
|
return nil
|
|
}
|
|
var parsed []string
|
|
if strings.HasPrefix(text, "[") && json.Unmarshal([]byte(text), &parsed) == nil {
|
|
return parsed
|
|
}
|
|
return []string{text}
|
|
default:
|
|
return nil
|
|
}
|
|
}
|
|
|
|
func isProductPerformanceInternalGroupField(field string) bool {
|
|
return strings.HasPrefix(field, "__")
|
|
}
|
|
|
|
func productPerformanceGroupRecommendation(row map[string]any, groupField string, count int) string {
|
|
groupField = strings.TrimSpace(groupField)
|
|
salesQty90 := floatFromMap(row, "sales_qty_90d")
|
|
salesUSD90 := floatFromMap(row, "sales_usd_90d")
|
|
stockQty := floatFromMap(row, "stock_qty")
|
|
marginCost := floatFromMap(row, "gross_margin_cost_90d")
|
|
if marginCost == 0 {
|
|
marginCost = floatFromMap(row, "gross_margin_90d")
|
|
}
|
|
switch groupField {
|
|
case "market_key":
|
|
if salesQty90 <= 0 && stockQty > 0 {
|
|
return "Bu piyasada son 90 gunde satis yok"
|
|
}
|
|
if marginCost < 0 {
|
|
return "Bu piyasada ciplak marj negatif"
|
|
}
|
|
return fmt.Sprintf("Piyasa satisi %.0f adet / %.0f USD", salesQty90, salesUSD90)
|
|
case "color_yaka":
|
|
if salesQty90 <= 0 && stockQty > 0 {
|
|
return "Renk/yaka stokta, son 90 gun satisi yok"
|
|
}
|
|
if marginCost < 0 {
|
|
return "Renk/yaka ciplak marji negatif"
|
|
}
|
|
return fmt.Sprintf("Renk/yaka satisi %.0f adet", salesQty90)
|
|
default:
|
|
if recommendation := strings.TrimSpace(stringFromMap(row, "recommendation")); recommendation != "" {
|
|
return recommendation
|
|
}
|
|
return fmt.Sprintf("%d satir", count)
|
|
}
|
|
}
|
|
|
|
func isProductPerformanceMarginField(field string) bool {
|
|
return strings.HasPrefix(field, "gross_margin")
|
|
}
|
|
|
|
func isProductPerformanceDistinctVariantStockMetric(field string) bool {
|
|
return field == "stock_qty" || strings.HasPrefix(field, "avg_stock_")
|
|
}
|
|
|
|
func distinctProductPerformanceVariantNumber(rows []map[string]any, field string) float64 {
|
|
seen := map[string]bool{}
|
|
total := 0.0
|
|
hasKey := false
|
|
for _, row := range rows {
|
|
key := productPerformanceMapVariantKey(row)
|
|
if key == "" {
|
|
continue
|
|
}
|
|
hasKey = true
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
total += floatFromMap(row, field)
|
|
}
|
|
if hasKey {
|
|
return total
|
|
}
|
|
for _, row := range rows {
|
|
total += floatFromMap(row, field)
|
|
}
|
|
return total
|
|
}
|
|
|
|
func distinctProductPerformanceVariantStockCost(rows []map[string]any) float64 {
|
|
seen := map[string]bool{}
|
|
total := 0.0
|
|
hasKey := false
|
|
for _, row := range rows {
|
|
key := productPerformanceMapVariantKey(row)
|
|
if key == "" {
|
|
continue
|
|
}
|
|
hasKey = true
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
total += floatFromMap(row, "stock_qty") * floatFromMap(row, "cost_price_usd")
|
|
}
|
|
if hasKey {
|
|
return total
|
|
}
|
|
for _, row := range rows {
|
|
total += floatFromMap(row, "idle_cost_usd")
|
|
}
|
|
return total
|
|
}
|
|
|
|
func productPerformanceMapVariantKey(row map[string]any) string {
|
|
productCode := stringFromMap(row, "product_code")
|
|
colorCode := stringFromMap(row, "color_code")
|
|
yakaKodu := stringFromMap(row, "yaka_kodu")
|
|
if productCode == "" && colorCode == "" && yakaKodu == "" {
|
|
return ""
|
|
}
|
|
return productPerformanceVariantKey(productCode, colorCode, yakaKodu)
|
|
}
|
|
|
|
func deriveProductPerformanceGroupMetrics(out map[string]any, groupField string) {
|
|
normalizeProductPerformanceCostFields(out)
|
|
isGroup := strings.TrimSpace(groupField) != ""
|
|
if isGroup {
|
|
out["group_field"] = groupField
|
|
}
|
|
preservedCustomerScores := map[string]float64{}
|
|
preservedProductScores := map[string]float64{}
|
|
for _, suffix := range productPerformancePeriodSuffixes() {
|
|
if score, ok := productPerformanceOptionalFloat(out, "customer_score_"+suffix); ok {
|
|
preservedCustomerScores[suffix] = score
|
|
}
|
|
if score, ok := productPerformanceOptionalFloat(out, "performance_score_"+suffix); ok {
|
|
preservedProductScores[suffix] = score
|
|
}
|
|
}
|
|
if score, ok := productPerformanceOptionalFloat(out, "performance_score"); ok {
|
|
if _, exists := preservedProductScores["90d"]; !exists {
|
|
preservedProductScores["90d"] = score
|
|
}
|
|
}
|
|
for _, suffix := range productPerformancePeriodSuffixes() {
|
|
sales := floatFromMap(out, "sales_usd_"+suffix)
|
|
qty := floatFromMap(out, "sales_qty_"+suffix)
|
|
stockQty := floatFromMap(out, "stock_qty")
|
|
turnoverBase := floatFromMap(out, "avg_stock_"+suffix)
|
|
if turnoverBase <= 0 && !isGroup {
|
|
turnoverBase = stockQty
|
|
}
|
|
days := productPerformancePeriodDays(out, suffix)
|
|
avgDaily := floatFromMap(out, "avg_daily_sales_"+suffix)
|
|
if days > 0 {
|
|
avgDaily = qty / days
|
|
out["avg_daily_sales_"+suffix] = avgDaily
|
|
}
|
|
if avgDaily > 0 && turnoverBase > 0 {
|
|
out["stock_days_"+suffix] = turnoverBase / avgDaily
|
|
} else if qty <= 0 && stockQty > 0 {
|
|
out["stock_days_"+suffix] = 9999
|
|
} else if _, ok := out["stock_days_"+suffix]; !ok {
|
|
out["stock_days_"+suffix] = 0
|
|
}
|
|
out["stock_turnover_"+suffix] = productPerformanceAnnualizedStockTurnover(qty, turnoverBase, days)
|
|
if qty > 0 {
|
|
out["avg_price_usd_"+suffix] = sales / qty
|
|
}
|
|
basePrice, hasBasePrice := productPerformanceUnitCostForSuffix(out, "base_price_usd", suffix)
|
|
costPrice, hasCostPrice := productPerformanceUnitCostForSuffix(out, "cost_price_usd", suffix)
|
|
if hasBasePrice {
|
|
out["base_price_usd_"+suffix] = basePrice
|
|
}
|
|
if hasCostPrice {
|
|
out["cost_price_usd_"+suffix] = costPrice
|
|
}
|
|
if qty > 0 && hasBasePrice {
|
|
avgPrice := sales / qty
|
|
out["unit_profit_base_"+suffix] = avgPrice - basePrice
|
|
out["gross_profit_base_usd_"+suffix] = sales - (qty * basePrice)
|
|
} else {
|
|
out["unit_profit_base_"+suffix] = 0
|
|
out["gross_profit_base_usd_"+suffix] = 0
|
|
}
|
|
if qty > 0 && hasCostPrice {
|
|
avgPrice := sales / qty
|
|
out["unit_profit_cost_"+suffix] = avgPrice - costPrice
|
|
out["gross_profit_cost_usd_"+suffix] = sales - (qty * costPrice)
|
|
out["gross_profit_usd_"+suffix] = out["gross_profit_cost_usd_"+suffix]
|
|
} else {
|
|
out["unit_profit_cost_"+suffix] = 0
|
|
out["gross_profit_cost_usd_"+suffix] = 0
|
|
out["gross_profit_usd_"+suffix] = 0
|
|
}
|
|
if sales > 0 {
|
|
out["gross_margin_base_"+suffix] = floatFromMap(out, "gross_profit_base_usd_"+suffix) / sales
|
|
out["gross_margin_cost_"+suffix] = floatFromMap(out, "gross_profit_cost_usd_"+suffix) / sales
|
|
out["gross_margin_"+suffix] = floatFromMap(out, "gross_profit_usd_"+suffix) / sales
|
|
} else {
|
|
out["gross_margin_base_"+suffix] = 0
|
|
out["gross_margin_cost_"+suffix] = 0
|
|
out["gross_margin_"+suffix] = 0
|
|
}
|
|
}
|
|
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
|
|
}
|
|
if _, ok := out["net_stock_after_order"]; ok || orderQty > 0 {
|
|
out["net_stock_after_order"] = floatFromMap(out, "stock_qty") - orderQty
|
|
}
|
|
for _, suffix := range productPerformancePeriodSuffixes() {
|
|
if score, ok := preservedCustomerScores[suffix]; ok {
|
|
out["customer_score_"+suffix] = score
|
|
} else {
|
|
out["customer_score_"+suffix] = productPerformanceCustomerSalesPeriodScore(rowPeriodMetricMap(out, suffix))
|
|
}
|
|
if score, ok := preservedProductScores[suffix]; ok {
|
|
out["performance_score_"+suffix] = score
|
|
} else {
|
|
out["performance_score_"+suffix] = productPerformanceSalesPeriodScore(out, suffix)
|
|
}
|
|
}
|
|
out["performance_score"] = out["performance_score_90d"]
|
|
if floatFromMap(out, "order_qty") > 0 || floatFromMap(out, "order_usd") > 0 {
|
|
score := productPerformanceOrderGroupScore(out)
|
|
out["performance_score_90d"] = score
|
|
out["performance_score_180d"] = score
|
|
out["performance_score_365d"] = score
|
|
out["performance_score_total"] = score
|
|
out["performance_score"] = score
|
|
}
|
|
}
|
|
|
|
func normalizeProductPerformanceCostFields(row map[string]any) {
|
|
normalize := func(costField, baseField string) {
|
|
cost, hasCost := productPerformanceOptionalFloat(row, costField)
|
|
base, hasBase := productPerformanceOptionalFloat(row, baseField)
|
|
if !hasCost && !hasBase {
|
|
return
|
|
}
|
|
cost, base = normalizeProductPerformanceCostPair(cost, base)
|
|
if hasCost || cost > 0 {
|
|
row[costField] = cost
|
|
}
|
|
if hasBase || base > 0 {
|
|
row[baseField] = base
|
|
}
|
|
}
|
|
normalize("cost_price_usd", "base_price_usd")
|
|
for _, suffix := range productPerformancePeriodSuffixes() {
|
|
normalize("cost_price_usd_"+suffix, "base_price_usd_"+suffix)
|
|
}
|
|
}
|
|
|
|
func productPerformancePeriodSuffixes() []string {
|
|
return []string{"90d", "180d", "365d", "total"}
|
|
}
|
|
|
|
func productPerformancePeriodDays(row map[string]any, suffix string) float64 {
|
|
switch suffix {
|
|
case "90d":
|
|
return 90
|
|
case "180d":
|
|
return 180
|
|
case "365d":
|
|
return 360
|
|
case "total":
|
|
start := parseProductPerformanceDate(stringFromMap(row, "period_start"))
|
|
if start.IsZero() {
|
|
start = time.Date(2022, 1, 1, 0, 0, 0, 0, time.UTC)
|
|
}
|
|
end := parseProductPerformanceDate(stringFromMap(row, "period_end"))
|
|
if end.IsZero() {
|
|
end = parseProductPerformanceDate(stringFromMap(row, "kpi_date"))
|
|
}
|
|
if end.IsZero() || end.Before(start) {
|
|
return 0
|
|
}
|
|
return end.Sub(start).Hours()/24 + 1
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
const productPerformanceStockTurnoverYearDays = 360.0
|
|
|
|
func productPerformanceAnnualizedStockTurnover(salesQty, avgStock, periodDays float64) float64 {
|
|
if salesQty <= 0 || avgStock <= 0 {
|
|
return 0
|
|
}
|
|
raw := salesQty / avgStock
|
|
if periodDays <= 0 {
|
|
return raw
|
|
}
|
|
return raw * productPerformanceStockTurnoverYearDays / periodDays
|
|
}
|
|
|
|
func parseProductPerformanceDate(value string) time.Time {
|
|
value = strings.TrimSpace(value)
|
|
if value == "" {
|
|
return time.Time{}
|
|
}
|
|
if len(value) >= len("2006-01-02") {
|
|
value = value[:len("2006-01-02")]
|
|
}
|
|
t, err := time.Parse("2006-01-02", value)
|
|
if err != nil {
|
|
return time.Time{}
|
|
}
|
|
return t
|
|
}
|
|
|
|
func productPerformanceOptionalFloat(row map[string]any, field string) (float64, bool) {
|
|
value, ok := row[field]
|
|
if !ok {
|
|
return 0, false
|
|
}
|
|
return floatFromAny(value), true
|
|
}
|
|
|
|
func productPerformanceUnitCostForSuffix(row map[string]any, baseField, suffix string) (float64, bool) {
|
|
hasSuffix := false
|
|
if value, ok := row[baseField+"_"+suffix]; ok {
|
|
hasSuffix = true
|
|
if n := floatFromAny(value); n > 0 {
|
|
return n, true
|
|
}
|
|
}
|
|
if value, ok := row[baseField]; ok {
|
|
return floatFromAny(value), true
|
|
}
|
|
if hasSuffix {
|
|
return 0, true
|
|
}
|
|
return 0, false
|
|
}
|
|
|
|
func productPerformanceGroupScore(row map[string]any, groupField string) float64 {
|
|
if floatFromMap(row, "order_qty") > 0 || floatFromMap(row, "order_usd") > 0 {
|
|
return productPerformanceOrderGroupScore(row)
|
|
}
|
|
if isProductPerformanceCustomerGroup(groupField) {
|
|
return productPerformanceCustomerSalesGroupScore(row)
|
|
}
|
|
return productPerformanceSalesGroupScore(row)
|
|
}
|
|
|
|
func isProductPerformanceCustomerGroup(groupField string) bool {
|
|
switch strings.TrimSpace(groupField) {
|
|
case "customer_code", "customer_name":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func productPerformanceSalesGroupScore(row map[string]any) float64 {
|
|
return productPerformanceSalesPeriodScore(row, "90d")
|
|
}
|
|
|
|
func productPerformanceSalesPeriodScore(row map[string]any, suffix string) float64 {
|
|
salesUSD := floatFromMap(row, "sales_usd_"+suffix)
|
|
salesIndex := floatFromMap(row, "sales_index_"+suffix)
|
|
if salesIndex <= 0 && suffix == "total" {
|
|
salesIndex = floatFromMap(row, "sales_index_total")
|
|
}
|
|
margin := floatFromMap(row, "gross_margin_cost_"+suffix)
|
|
if margin == 0 {
|
|
margin = floatFromMap(row, "gross_margin_"+suffix)
|
|
}
|
|
salesQty := floatFromMap(row, "sales_qty_"+suffix)
|
|
stockTurnover := floatFromMap(row, "stock_turnover_"+suffix)
|
|
if stockTurnover == 0 {
|
|
avgStock := floatFromMap(row, "avg_stock_"+suffix)
|
|
if avgStock <= 0 && strings.TrimSpace(stringFromMap(row, "group_field")) == "" {
|
|
avgStock = floatFromMap(row, "stock_qty")
|
|
}
|
|
if avgStock > 0 {
|
|
stockTurnover = productPerformanceAnnualizedStockTurnover(salesQty, avgStock, productPerformancePeriodDays(row, suffix))
|
|
}
|
|
}
|
|
return productPerformanceProductScore(
|
|
suffix,
|
|
salesUSD,
|
|
salesIndex,
|
|
margin,
|
|
stockTurnover,
|
|
productPerformancePeriodCount(row, "market_count", suffix),
|
|
productPerformancePeriodCount(row, "customer_count", suffix),
|
|
productPerformancePeriodDays(row, suffix),
|
|
)
|
|
}
|
|
|
|
func productPerformanceCustomerSalesGroupScore(row map[string]any) float64 {
|
|
scores := []float64{
|
|
floatFromMap(row, "customer_score_90d"),
|
|
floatFromMap(row, "customer_score_180d"),
|
|
floatFromMap(row, "customer_score_365d"),
|
|
floatFromMap(row, "customer_score_total"),
|
|
}
|
|
total := 0.0
|
|
count := 0.0
|
|
for _, score := range scores {
|
|
if score <= 0 {
|
|
continue
|
|
}
|
|
total += score
|
|
count++
|
|
}
|
|
if count == 0 {
|
|
return 0
|
|
}
|
|
return total / count
|
|
}
|
|
|
|
func productPerformanceCustomerSalesPeriodScore(row map[string]any) float64 {
|
|
salesUSD := floatFromMap(row, "sales_usd")
|
|
margin := floatFromMap(row, "gross_margin_cost")
|
|
if margin == 0 {
|
|
margin = floatFromMap(row, "gross_margin")
|
|
}
|
|
salesQty := floatFromMap(row, "sales_qty")
|
|
productCount := floatFromMap(row, "product_count")
|
|
if groupCount := floatFromMap(row, "product_group_count"); groupCount > 0 {
|
|
productCount = groupCount
|
|
}
|
|
suffix := strings.TrimSpace(stringFromMap(row, "suffix"))
|
|
if suffix == "" {
|
|
suffix = "90d"
|
|
}
|
|
return productPerformanceCustomerScore(suffix, salesUSD, margin, productCount, salesQty)
|
|
}
|
|
|
|
func productPerformanceProductScore(suffix string, salesUSD, salesIndex, margin, stockTurnover, marketCount, customerCount float64, periodDays ...float64) float64 {
|
|
if salesUSD <= 0 {
|
|
return 1
|
|
}
|
|
days := productPerformanceScorePeriodDays(suffix, periodDays...)
|
|
revenueScore := productPerformanceRevenueScore(suffix, salesUSD, salesIndex, productPerformanceProductRevenueTargetForDays(suffix, days))
|
|
score := 0.30*productPerformanceMarginComponentScore(margin) +
|
|
0.20*productPerformanceRatioScore(stockTurnover, productPerformanceStockTurnoverTarget(suffix)) +
|
|
0.20*revenueScore +
|
|
0.05*productPerformanceRatioScore(marketCount, productPerformanceMarketSpreadTarget(suffix)) +
|
|
0.25*productPerformanceRatioScore(customerCount, productPerformanceCustomerSpreadTargetForDays(suffix, days))
|
|
return productPerformanceRoundScore(score)
|
|
}
|
|
|
|
func productPerformanceStockTurnoverTarget(suffix string) float64 {
|
|
return 4
|
|
}
|
|
|
|
func productPerformanceMarketSpreadTarget(suffix string) float64 {
|
|
switch suffix {
|
|
case "90d":
|
|
return 3
|
|
case "180d":
|
|
return 3
|
|
case "365d":
|
|
return 6
|
|
default:
|
|
return 6
|
|
}
|
|
}
|
|
|
|
func productPerformanceCustomerSpreadTarget(suffix string) float64 {
|
|
return productPerformanceCustomerSpreadTargetForDays(suffix, productPerformanceScorePeriodDays(suffix))
|
|
}
|
|
|
|
func productPerformanceCustomerSpreadTargetForDays(suffix string, periodDays float64) float64 {
|
|
switch suffix {
|
|
case "90d":
|
|
return 8
|
|
case "180d":
|
|
return 20
|
|
case "365d":
|
|
return 50
|
|
default:
|
|
return 20 * productPerformanceTotalPeriodMultiplier(periodDays)
|
|
}
|
|
}
|
|
|
|
func productPerformanceCustomerScore(suffix string, salesUSD, margin, productGroupCount, salesQty float64) float64 {
|
|
if salesUSD <= 0 {
|
|
return 1
|
|
}
|
|
score := 0.35*productPerformanceRatioScore(salesUSD, productPerformanceCustomerRevenueTarget(suffix)) +
|
|
0.30*productPerformanceMarginComponentScore(margin) +
|
|
0.20*productPerformanceRatioScore(productGroupCount, 8) +
|
|
0.15*productPerformanceRatioScore(salesQty, productPerformanceCustomerQtyTarget(suffix))
|
|
return productPerformanceRoundScore(score)
|
|
}
|
|
|
|
func productPerformanceRevenueScore(suffix string, salesUSD, salesIndex, absoluteTarget float64) float64 {
|
|
return productPerformanceRatioScore(salesUSD, absoluteTarget)
|
|
}
|
|
|
|
func productPerformanceRelativeIndex(value, average float64) float64 {
|
|
if value <= 0 || average <= 0 {
|
|
return 0
|
|
}
|
|
return value / average
|
|
}
|
|
|
|
func productPerformanceMarginComponentScore(margin float64) float64 {
|
|
return productPerformanceRatioScore(maxFloat(0, margin), 0.45)
|
|
}
|
|
|
|
func productPerformanceRatioScore(value, target float64) float64 {
|
|
if target <= 0 || value <= 0 {
|
|
return 0
|
|
}
|
|
return minFloat(100, maxFloat(0, value)*100/target)
|
|
}
|
|
|
|
func productPerformanceRoundScore(score float64) float64 {
|
|
if score < 0 {
|
|
score = 0
|
|
}
|
|
if score > 100 {
|
|
score = 100
|
|
}
|
|
return math.Round(score*10000) / 10000
|
|
}
|
|
|
|
func productPerformanceProductRevenueTarget(suffix string) float64 {
|
|
return productPerformanceProductRevenueTargetForDays(suffix, productPerformanceScorePeriodDays(suffix))
|
|
}
|
|
|
|
func productPerformanceProductRevenueTargetForDays(suffix string, periodDays float64) float64 {
|
|
switch suffix {
|
|
case "180d":
|
|
return 50000
|
|
case "365d":
|
|
return 100000
|
|
case "total":
|
|
return 50000 * productPerformanceTotalPeriodMultiplier(periodDays)
|
|
default:
|
|
return 25000
|
|
}
|
|
}
|
|
|
|
func productPerformanceScorePeriodDays(suffix string, periodDays ...float64) float64 {
|
|
if len(periodDays) > 0 && periodDays[0] > 0 {
|
|
return periodDays[0]
|
|
}
|
|
switch suffix {
|
|
case "90d":
|
|
return 90
|
|
case "180d":
|
|
return 180
|
|
case "365d":
|
|
return 360
|
|
case "total":
|
|
return productPerformancePeriodDays(map[string]any{
|
|
"kpi_date": time.Now().Format("2006-01-02"),
|
|
}, "total")
|
|
default:
|
|
return 0
|
|
}
|
|
}
|
|
|
|
func productPerformanceTotalPeriodMultiplier(periodDays float64) float64 {
|
|
if periodDays <= 0 {
|
|
periodDays = 180
|
|
}
|
|
return maxFloat(1, periodDays/180.0)
|
|
}
|
|
|
|
func productPerformanceCustomerRevenueTarget(suffix string) float64 {
|
|
switch suffix {
|
|
case "180d":
|
|
return 50000
|
|
case "365d":
|
|
return 100000
|
|
case "total":
|
|
return 300000
|
|
default:
|
|
return 25000
|
|
}
|
|
}
|
|
|
|
func productPerformanceCustomerQtyTarget(suffix string) float64 {
|
|
switch suffix {
|
|
case "180d":
|
|
return 1000
|
|
case "365d":
|
|
return 2000
|
|
case "total":
|
|
return 6000
|
|
default:
|
|
return 500
|
|
}
|
|
}
|
|
|
|
func productPerformancePeriodCount(row map[string]any, prefix, suffix string) float64 {
|
|
if value, ok := row[prefix+"_"+suffix]; ok {
|
|
if n := floatFromAny(value); n > 0 {
|
|
return n
|
|
}
|
|
}
|
|
if suffix == "total" {
|
|
if value, ok := row[prefix+"_total"]; ok {
|
|
return floatFromAny(value)
|
|
}
|
|
}
|
|
if value, ok := row[prefix+"_90d"]; ok {
|
|
return floatFromAny(value)
|
|
}
|
|
return floatFromAny(row[prefix])
|
|
}
|
|
|
|
func rowPeriodMetricMap(row map[string]any, suffix string) map[string]any {
|
|
return map[string]any{
|
|
"suffix": suffix,
|
|
"sales_usd": floatFromMap(row, "sales_usd_"+suffix),
|
|
"gross_margin_cost": floatFromMap(row, "gross_margin_cost_"+suffix),
|
|
"gross_margin": floatFromMap(row, "gross_margin_"+suffix),
|
|
"sales_qty": floatFromMap(row, "sales_qty_"+suffix),
|
|
"product_group_count": productPerformancePeriodCount(row, "product_group_count", suffix),
|
|
"product_count": floatFromMap(row, "product_count"),
|
|
}
|
|
}
|
|
|
|
func productPerformanceOrderGroupScore(row map[string]any) float64 {
|
|
orderUSD := floatFromMap(row, "order_usd")
|
|
margin := floatFromMap(row, "expected_margin_cost")
|
|
orderCount := floatFromMap(row, "order_count")
|
|
orderQty := floatFromMap(row, "order_qty")
|
|
netStockAfterOrder := floatFromMap(row, "net_stock_after_order")
|
|
score := minFloat(35, orderUSD/1000) +
|
|
minFloat(25, maxFloat(0, margin)*60) +
|
|
minFloat(15, orderCount*2) +
|
|
minFloat(15, orderQty/10)
|
|
if netStockAfterOrder >= 0 {
|
|
score += 10
|
|
}
|
|
return score
|
|
}
|
|
|
|
func minFloat(a, b float64) float64 {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func maxFloat(a, b float64) float64 {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
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, "item_description": true, "color_code": true, "yaka_kodu": true, "color_yaka": true, "market_key": true, "customer_code": true,
|
|
"customer_name": 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 level == "color_code" || level == "yaka_kodu" {
|
|
level = "color_yaka"
|
|
}
|
|
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_yaka", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "country", "market_key", "customer_segment", "customer_code", "customer_name"}
|
|
case "product_detail":
|
|
return []string{"urun_alt_grubu", "product_code", "color_yaka", "market_key"}
|
|
case "idle":
|
|
return []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"}
|
|
case "sales_product_country_segment_market_customer":
|
|
return []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka", "country", "customer_segment", "market_key", "customer_code", "customer_name"}
|
|
case "sales_market_customer_product":
|
|
return []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"}
|
|
case "sales_country_segment_market_customer_product":
|
|
return []string{"country", "customer_segment", "market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"}
|
|
case "order_product_customers":
|
|
return []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka", "market_key", "customer_code", "customer_name"}
|
|
case "order_market_details":
|
|
return []string{"market_key", "customer_code", "customer_name", "urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka"}
|
|
default:
|
|
return []string{"urun_ilk_grubu", "askili_yan", "kategori", "urun_ana_grubu", "urun_alt_grubu", "product_code", "color_yaka", "market_key"}
|
|
}
|
|
}
|
|
|
|
func mapGroupValue(row map[string]any, field string) string {
|
|
switch field {
|
|
case "urun_ilk_grubu":
|
|
return cleanProductPerformanceFirstGroup(stringFromMap(row, "urun_ilk_grubu"))
|
|
case "market_key":
|
|
return displayProductPerformanceMarketName(stringFromMap(row, "market_key"))
|
|
case "color_yaka":
|
|
parts := make([]string, 0, 2)
|
|
if color := strings.TrimSpace(stringFromMap(row, "color_code")); color != "" {
|
|
parts = append(parts, color)
|
|
}
|
|
if yaka := strings.TrimSpace(stringFromMap(row, "yaka_kodu")); yaka != "" {
|
|
parts = append(parts, yaka)
|
|
}
|
|
return strings.Join(parts, "/")
|
|
default:
|
|
return stringFromMap(row, field)
|
|
}
|
|
}
|
|
|
|
func shouldSumProductPerformanceField(field string) bool {
|
|
return strings.HasPrefix(field, "sales_qty_") ||
|
|
strings.HasPrefix(field, "sales_usd_") ||
|
|
strings.HasPrefix(field, "avg_daily_sales_") ||
|
|
strings.HasPrefix(field, "gross_profit_") ||
|
|
strings.HasPrefix(field, "invoice_count_") ||
|
|
strings.HasPrefix(field, "market_count_") ||
|
|
strings.HasPrefix(field, "customer_count_") ||
|
|
strings.HasPrefix(field, "product_group_count_") ||
|
|
strings.HasPrefix(field, "product_count_") ||
|
|
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 {
|
|
if strings.HasPrefix(field, "avg_daily_sales_") {
|
|
return false
|
|
}
|
|
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, "customer_score") ||
|
|
strings.HasPrefix(field, "stock_days") ||
|
|
strings.HasPrefix(field, "stock_turnover")
|
|
}
|
|
|
|
func productPerformanceMetricWeightField(field string) string {
|
|
if productPerformanceUsesCostWeight(field) {
|
|
return "__product_cost_weight"
|
|
}
|
|
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 productPerformanceUsesCostWeight(field string) bool {
|
|
if !(strings.HasPrefix(field, "base_price") || strings.HasPrefix(field, "cost_price")) {
|
|
return false
|
|
}
|
|
return !strings.Contains(field, "_90d") &&
|
|
!strings.Contains(field, "_180d") &&
|
|
!strings.Contains(field, "_365d") &&
|
|
!strings.Contains(field, "_total")
|
|
}
|
|
|
|
func productPerformanceMetricWeight(row map[string]any, field string) float64 {
|
|
if productPerformanceUsesScoreWeight(field) {
|
|
return productPerformanceScoreWeight(row, productPerformanceScoreSuffix(field))
|
|
}
|
|
weightField := productPerformanceMetricWeightField(field)
|
|
if weightField == "__product_cost_weight" {
|
|
return productPerformanceCostWeight(row)
|
|
}
|
|
return floatFromMap(row, weightField)
|
|
}
|
|
|
|
func productPerformanceUsesScoreWeight(field string) bool {
|
|
return strings.HasPrefix(field, "performance_score") ||
|
|
strings.HasPrefix(field, "customer_score")
|
|
}
|
|
|
|
func productPerformanceScoreSuffix(field string) string {
|
|
switch {
|
|
case strings.Contains(field, "_180d"):
|
|
return "180d"
|
|
case strings.Contains(field, "_365d"):
|
|
return "365d"
|
|
case strings.Contains(field, "_total"):
|
|
return "total"
|
|
default:
|
|
return "90d"
|
|
}
|
|
}
|
|
|
|
func productPerformanceScoreWeight(row map[string]any, suffix string) float64 {
|
|
if weight := floatFromMap(row, "sales_qty_"+suffix); weight > 0 {
|
|
return weight
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func productPerformanceCostWeight(row map[string]any) float64 {
|
|
if weight := floatFromMap(row, "sales_qty_total"); weight > 0 {
|
|
return weight
|
|
}
|
|
for _, field := range []string{"sales_qty_365d", "sales_qty_180d", "sales_qty_90d"} {
|
|
if weight := floatFromMap(row, field); weight > 0 {
|
|
return weight
|
|
}
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func weightedAverageProductPerformanceRows(rows []map[string]any, valueField, qtyField string) float64 {
|
|
if productPerformanceUsesScoreWeight(valueField) {
|
|
return weightedAverageProductPerformanceScoreRows(rows, valueField, productPerformanceScoreSuffix(valueField))
|
|
}
|
|
var weighted, qty float64
|
|
for _, row := range rows {
|
|
value := floatFromMap(row, valueField)
|
|
weight := floatFromMap(row, qtyField)
|
|
if qtyField == "__product_cost_weight" {
|
|
weight = productPerformanceCostWeight(row)
|
|
}
|
|
if weight > 0 {
|
|
weighted += value * weight
|
|
qty += weight
|
|
}
|
|
}
|
|
if qty > 0 {
|
|
return weighted / qty
|
|
}
|
|
if qtyField == "__product_cost_weight" {
|
|
return averageProductPerformanceDistinctVariantField(rows, valueField)
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func weightedAverageProductPerformanceScoreRows(rows []map[string]any, valueField, suffix string) float64 {
|
|
var weighted, weight, sum, count float64
|
|
for _, row := range rows {
|
|
value, ok := productPerformanceOptionalFloat(row, valueField)
|
|
if !ok {
|
|
continue
|
|
}
|
|
rowWeight := productPerformanceScoreWeight(row, suffix)
|
|
if rowWeight > 0 {
|
|
weighted += value * rowWeight
|
|
weight += rowWeight
|
|
}
|
|
sum += value
|
|
count++
|
|
}
|
|
if weight > 0 {
|
|
return weighted / weight
|
|
}
|
|
if count > 0 {
|
|
return sum / count
|
|
}
|
|
return 0
|
|
}
|
|
|
|
func averageProductPerformanceDistinctVariantField(rows []map[string]any, valueField string) float64 {
|
|
seen := map[string]bool{}
|
|
sum := 0.0
|
|
count := 0.0
|
|
hasKey := false
|
|
for _, row := range rows {
|
|
key := productPerformanceMapVariantKey(row)
|
|
if key == "" {
|
|
continue
|
|
}
|
|
hasKey = true
|
|
if seen[key] {
|
|
continue
|
|
}
|
|
seen[key] = true
|
|
value := floatFromMap(row, valueField)
|
|
if value <= 0 {
|
|
continue
|
|
}
|
|
sum += value
|
|
count++
|
|
}
|
|
if !hasKey {
|
|
for _, row := range rows {
|
|
value := floatFromMap(row, valueField)
|
|
if value <= 0 {
|
|
continue
|
|
}
|
|
sum += value
|
|
count++
|
|
}
|
|
}
|
|
if count <= 0 {
|
|
return 0
|
|
}
|
|
return sum / count
|
|
}
|
|
|
|
func dominantProductPerformanceValue(rows []map[string]any, field string) string {
|
|
counts := map[string]int{}
|
|
for _, row := range rows {
|
|
value := stringFromMap(row, field)
|
|
if field == "urun_ilk_grubu" {
|
|
value = cleanProductPerformanceFirstGroup(value)
|
|
}
|
|
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 {
|
|
return strings.TrimSpace(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 cleanProductPerformanceFirstGroup(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "-" {
|
|
return ""
|
|
}
|
|
switch normalizeProductPerformanceTurkishText(value) {
|
|
case "YETISKIN", "YETISKIN/GARSON", "GARSON":
|
|
return ""
|
|
default:
|
|
return value
|
|
}
|
|
}
|
|
|
|
func normalizeProductPerformanceTurkishText(value string) string {
|
|
value = strings.ToUpper(strings.TrimSpace(value))
|
|
return strings.NewReplacer(
|
|
"\u0130", "I",
|
|
"\u015E", "S",
|
|
"\u011E", "G",
|
|
"\u00DC", "U",
|
|
"\u00D6", "O",
|
|
"\u00C7", "C",
|
|
"\u0131", "I",
|
|
"\u015F", "S",
|
|
"\u011F", "G",
|
|
"\u00FC", "U",
|
|
"\u00F6", "O",
|
|
"\u00E7", "C",
|
|
).Replace(value)
|
|
}
|
|
|
|
func cleanProductPerformanceOptionalAttr(value string) string {
|
|
value = strings.TrimSpace(value)
|
|
if value == "-" {
|
|
return ""
|
|
}
|
|
return value
|
|
}
|
|
|
|
func floatFromMap(row map[string]any, field string) float64 {
|
|
return floatFromAny(row[field])
|
|
}
|
|
|
|
func intFromMap(row map[string]any, field string) int {
|
|
return int(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 minInt(a, b int) int {
|
|
if a < b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func maxInt(a, b int) int {
|
|
if a > b {
|
|
return a
|
|
}
|
|
return b
|
|
}
|
|
|
|
func ListProductPerformanceSalesDetails(ctx context.Context, pg *sql.DB, productCode, colorCode, yakaKodu string, limit int) ([]models.ProductPerformanceSalesDetailRow, error) {
|
|
if limit <= 0 || limit > 500 {
|
|
limit = 100
|
|
}
|
|
rows, err := pg.QueryContext(ctx, `
|
|
SELECT
|
|
to_char(sales_date,'YYYY-MM-DD') AS sales_date,
|
|
last_ref_number,
|
|
market_key,
|
|
customer_country,
|
|
customer_segment,
|
|
customer_code,
|
|
customer_name,
|
|
sales_qty,
|
|
sales_usd,
|
|
avg_price_usd
|
|
FROM mk_product_performance_sales_daily
|
|
WHERE product_code=$1
|
|
AND color_code=$2
|
|
AND yaka_kodu=$3
|
|
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
|
|
ORDER BY sales_usd DESC, sales_date DESC
|
|
LIMIT $4
|
|
`, strings.TrimSpace(productCode), strings.TrimSpace(colorCode), strings.TrimSpace(yakaKodu), limit)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := make([]models.ProductPerformanceSalesDetailRow, 0, limit)
|
|
for rows.Next() {
|
|
var r models.ProductPerformanceSalesDetailRow
|
|
if err := rows.Scan(&r.SalesDate, &r.RefNumber, &r.MarketKey, &r.Country, &r.CustomerSegment, &r.CustomerCode, &r.CustomerName, &r.SalesQty, &r.SalesUSD, &r.AvgPriceUSD); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func ListProductPerformanceStockSizes(ctx context.Context, productCode, colorCode, yakaKodu string) ([]models.ProductPerformanceStockSizeRow, error) {
|
|
if db.MssqlDB == nil {
|
|
return nil, fmt.Errorf("mssql db nil")
|
|
}
|
|
productCode = normalizeProductPerformanceProductCode(productCode)
|
|
colorCode = normalizeProductPerformanceCode(colorCode)
|
|
yakaKodu = normalizeProductPerformanceCode(yakaKodu)
|
|
rows, err := db.MssqlDB.QueryContext(ctx, `
|
|
;WITH ActiveWarehouses AS (
|
|
SELECT WarehouseCode
|
|
FROM (VALUES
|
|
('1-0-14'),('1-0-10'),('1-0-8'),('1-2-5'),('1-2-4'),('1-0-12'),('100'),('1-0-28'),
|
|
('1-0-24'),('1-2-6'),('1-1-14'),('1-0-2'),('1-0-52'),('1-1-2'),('1-0-21'),('1-1-3'),
|
|
('1-0-33'),('101'),('1-014'),('1-0-49'),('1-0-36'),('1-0-4'),('1-0-29')
|
|
) W(WarehouseCode)
|
|
),
|
|
Stock AS (
|
|
SELECT
|
|
SizeCode = UPPER(LTRIM(RTRIM(ISNULL(S.ItemDim1Code, '')))),
|
|
WarehouseCode = LTRIM(RTRIM(S.WarehouseCode)),
|
|
InventoryQty1 = SUM(S.In_Qty1 - S.Out_Qty1)
|
|
FROM trStock S WITH(NOLOCK)
|
|
INNER JOIN ActiveWarehouses W
|
|
ON W.WarehouseCode = LTRIM(RTRIM(S.WarehouseCode))
|
|
WHERE S.ItemTypeCode = 1
|
|
AND UPPER(LTRIM(RTRIM(S.ItemCode))) = @p1
|
|
AND UPPER(LTRIM(RTRIM(ISNULL(S.ColorCode, '')))) = @p2
|
|
AND UPPER(LTRIM(RTRIM(ISNULL(S.ItemDim2Code, '')))) = @p3
|
|
GROUP BY UPPER(LTRIM(RTRIM(ISNULL(S.ItemDim1Code, '')))), LTRIM(RTRIM(S.WarehouseCode))
|
|
),
|
|
Pick AS (
|
|
SELECT
|
|
SizeCode = UPPER(LTRIM(RTRIM(ISNULL(P.ItemDim1Code, '')))),
|
|
WarehouseCode = LTRIM(RTRIM(P.WarehouseCode)),
|
|
PickingQty1 = SUM(P.Qty1)
|
|
FROM PickingStates P WITH(NOLOCK)
|
|
INNER JOIN ActiveWarehouses W
|
|
ON W.WarehouseCode = LTRIM(RTRIM(P.WarehouseCode))
|
|
WHERE P.ItemTypeCode = 1
|
|
AND UPPER(LTRIM(RTRIM(P.ItemCode))) = @p1
|
|
AND UPPER(LTRIM(RTRIM(ISNULL(P.ColorCode, '')))) = @p2
|
|
AND UPPER(LTRIM(RTRIM(ISNULL(P.ItemDim2Code, '')))) = @p3
|
|
GROUP BY UPPER(LTRIM(RTRIM(ISNULL(P.ItemDim1Code, '')))), LTRIM(RTRIM(P.WarehouseCode))
|
|
),
|
|
Reserve AS (
|
|
SELECT
|
|
SizeCode = UPPER(LTRIM(RTRIM(ISNULL(R.ItemDim1Code, '')))),
|
|
WarehouseCode = LTRIM(RTRIM(R.WarehouseCode)),
|
|
ReserveQty1 = SUM(R.Qty1)
|
|
FROM ReserveStates R WITH(NOLOCK)
|
|
INNER JOIN ActiveWarehouses W
|
|
ON W.WarehouseCode = LTRIM(RTRIM(R.WarehouseCode))
|
|
WHERE R.ItemTypeCode = 1
|
|
AND UPPER(LTRIM(RTRIM(R.ItemCode))) = @p1
|
|
AND UPPER(LTRIM(RTRIM(ISNULL(R.ColorCode, '')))) = @p2
|
|
AND UPPER(LTRIM(RTRIM(ISNULL(R.ItemDim2Code, '')))) = @p3
|
|
GROUP BY UPPER(LTRIM(RTRIM(ISNULL(R.ItemDim1Code, '')))), LTRIM(RTRIM(R.WarehouseCode))
|
|
),
|
|
Disp AS (
|
|
SELECT
|
|
SizeCode = UPPER(LTRIM(RTRIM(ISNULL(D.ItemDim1Code, '')))),
|
|
WarehouseCode = LTRIM(RTRIM(D.WarehouseCode)),
|
|
DispOrderQty1 = SUM(D.Qty1)
|
|
FROM DispOrderStates D WITH(NOLOCK)
|
|
INNER JOIN ActiveWarehouses W
|
|
ON W.WarehouseCode = LTRIM(RTRIM(D.WarehouseCode))
|
|
WHERE D.ItemTypeCode = 1
|
|
AND UPPER(LTRIM(RTRIM(D.ItemCode))) = @p1
|
|
AND UPPER(LTRIM(RTRIM(ISNULL(D.ColorCode, '')))) = @p2
|
|
AND UPPER(LTRIM(RTRIM(ISNULL(D.ItemDim2Code, '')))) = @p3
|
|
GROUP BY UPPER(LTRIM(RTRIM(ISNULL(D.ItemDim1Code, '')))), LTRIM(RTRIM(D.WarehouseCode))
|
|
),
|
|
AvailableBySizeWarehouse AS (
|
|
SELECT
|
|
S.SizeCode,
|
|
StockQty =
|
|
ISNULL(S.InventoryQty1, 0)
|
|
- ISNULL(P.PickingQty1, 0)
|
|
- ISNULL(R.ReserveQty1, 0)
|
|
- ISNULL(D.DispOrderQty1, 0)
|
|
FROM Stock S
|
|
LEFT JOIN Pick P ON P.SizeCode = S.SizeCode AND P.WarehouseCode = S.WarehouseCode
|
|
LEFT JOIN Reserve R ON R.SizeCode = S.SizeCode AND R.WarehouseCode = S.WarehouseCode
|
|
LEFT JOIN Disp D ON D.SizeCode = S.SizeCode AND D.WarehouseCode = S.WarehouseCode
|
|
WHERE S.InventoryQty1 >= 0
|
|
)
|
|
SELECT
|
|
SizeCode,
|
|
StockQty = CAST(ROUND(SUM(StockQty), 2) AS FLOAT)
|
|
FROM AvailableBySizeWarehouse
|
|
GROUP BY SizeCode
|
|
HAVING SUM(StockQty) > 0
|
|
ORDER BY SizeCode
|
|
`, strings.TrimSpace(productCode), strings.TrimSpace(colorCode), strings.TrimSpace(yakaKodu))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
out := make([]models.ProductPerformanceStockSizeRow, 0, 32)
|
|
for rows.Next() {
|
|
var r models.ProductPerformanceStockSizeRow
|
|
if err := rows.Scan(&r.SizeCode, &r.StockQty); err != nil {
|
|
return nil, err
|
|
}
|
|
out = append(out, r)
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func productPerformanceWhere(f ProductPerformanceFilters) (string, []any) {
|
|
parts := []string{" AND " + productPerformanceAllowedFirstGroupSQL("urun_ilk_grubu")}
|
|
args := make([]any, 0, 6)
|
|
add := func(cond string, value any) {
|
|
args = append(args, value)
|
|
parts = append(parts, fmt.Sprintf(cond, len(args)))
|
|
}
|
|
if q := strings.TrimSpace(f.Search); q != "" {
|
|
args = append(args, q)
|
|
idx := len(args)
|
|
parts = append(parts, fmt.Sprintf(" AND (product_code ILIKE '%%' || $%d || '%%' OR item_description ILIKE '%%' || $%d || '%%')", idx, idx))
|
|
}
|
|
if v := normalizeProductPerformanceProductCode(f.ProductCode); v != "" {
|
|
add(" AND product_code = $%d", v)
|
|
}
|
|
if v := strings.TrimSpace(f.MarketKey); v != "" {
|
|
add(" AND market_key = $%d", v)
|
|
}
|
|
if v := strings.TrimSpace(f.Kategori); v != "" {
|
|
add(" AND kategori = $%d", v)
|
|
}
|
|
if v := strings.TrimSpace(f.Seri); v != "" {
|
|
args = append(args, v)
|
|
idx := len(args)
|
|
parts = append(parts, fmt.Sprintf(" AND (seri = $%d OR urun_ana_grubu = $%d)", idx, idx))
|
|
}
|
|
if v := strings.TrimSpace(f.Bucket); v != "" {
|
|
add(" AND performance_bucket = $%d", v)
|
|
}
|
|
return strings.Join(parts, ""), args
|
|
}
|
|
|
|
func productPerformanceHasServerFilters(f ProductPerformanceFilters) bool {
|
|
return strings.TrimSpace(f.Search) != "" ||
|
|
strings.TrimSpace(f.ProductCode) != "" ||
|
|
strings.TrimSpace(f.MarketKey) != "" ||
|
|
strings.TrimSpace(f.Kategori) != "" ||
|
|
strings.TrimSpace(f.Seri) != "" ||
|
|
strings.TrimSpace(f.Bucket) != "" ||
|
|
f.Page > 1
|
|
}
|
|
|
|
func productPerformanceOrderBy(sortBy string, desc bool) string {
|
|
allowed := map[string]string{
|
|
"product_code": "product_code",
|
|
"color_code": "color_code",
|
|
"yaka_kodu": "yaka_kodu",
|
|
"item_description": "item_description",
|
|
"kategori": "kategori",
|
|
"urun_ilk_grubu": "urun_ilk_grubu",
|
|
"askili_yan": "askili_yan",
|
|
"urun_ana_grubu": "urun_ana_grubu",
|
|
"urun_alt_grubu": "urun_alt_grubu",
|
|
"market_key": "market_key",
|
|
"stock_qty": "stock_qty",
|
|
"sales_qty_90d": "sales_qty_90d",
|
|
"sales_qty_180d": "sales_qty_180d",
|
|
"sales_qty_365d": "sales_qty_365d",
|
|
"sales_qty_total": "sales_qty_total",
|
|
"sales_usd_90d": "sales_usd_90d",
|
|
"sales_usd_180d": "sales_usd_180d",
|
|
"sales_usd_365d": "sales_usd_365d",
|
|
"sales_usd_total": "sales_usd_total",
|
|
"stock_days_90d": "stock_days_90d",
|
|
"stock_days_180d": "stock_days_180d",
|
|
"stock_days_365d": "stock_days_365d",
|
|
"stock_days_total": "stock_days_total",
|
|
"stock_turnover_90d": "stock_turnover_90d",
|
|
"stock_turnover_180d": "stock_turnover_180d",
|
|
"stock_turnover_365d": "stock_turnover_365d",
|
|
"stock_turnover_total": "stock_turnover_total",
|
|
"avg_price_usd_90d": "avg_price_usd_90d",
|
|
"avg_price_usd_180d": "avg_price_usd_180d",
|
|
"base_price_usd": "base_price_usd",
|
|
"cost_price_usd": "cost_price_usd",
|
|
"unit_profit_base_90d": "unit_profit_base_90d",
|
|
"unit_profit_cost_90d": "unit_profit_cost_90d",
|
|
"unit_profit_base_180d": "unit_profit_base_180d",
|
|
"unit_profit_cost_180d": "unit_profit_cost_180d",
|
|
"gross_margin_base_90d": "(CASE WHEN COALESCE(sales_usd_90d,0) <= 0 THEN 0 ELSE (sales_usd_90d - (sales_qty_90d * COALESCE(base_price_usd,0))) / NULLIF(sales_usd_90d,0) END)",
|
|
"gross_margin_cost_90d": "(CASE WHEN COALESCE(sales_usd_90d,0) <= 0 THEN 0 ELSE (sales_usd_90d - (sales_qty_90d * COALESCE(cost_price_usd,0))) / NULLIF(sales_usd_90d,0) END)",
|
|
"gross_margin_base_180d": "(CASE WHEN COALESCE(sales_usd_180d,0) <= 0 THEN 0 ELSE (sales_usd_180d - (sales_qty_180d * COALESCE(base_price_usd,0))) / NULLIF(sales_usd_180d,0) END)",
|
|
"gross_margin_cost_180d": "(CASE WHEN COALESCE(sales_usd_180d,0) <= 0 THEN 0 ELSE (sales_usd_180d - (sales_qty_180d * COALESCE(cost_price_usd,0))) / NULLIF(sales_usd_180d,0) END)",
|
|
"gross_margin_90d": "gross_margin_90d",
|
|
"gross_margin_180d": "gross_margin_180d",
|
|
"market_count_90d": "market_count_90d",
|
|
"customer_count_90d": "customer_count_90d",
|
|
"sales_index_90d": "sales_index_90d",
|
|
"performance_score": "performance_score",
|
|
"performance_bucket": "performance_bucket",
|
|
"last_sale_date": "last_sale_date",
|
|
}
|
|
col := allowed[strings.TrimSpace(sortBy)]
|
|
if col == "" {
|
|
col = "performance_score"
|
|
desc = true
|
|
}
|
|
dir := "ASC"
|
|
if desc {
|
|
dir = "DESC"
|
|
}
|
|
return col + " " + dir + ", product_code ASC, color_code ASC, yaka_kodu ASC"
|
|
}
|
|
|
|
type productPerformancePriceInfo struct {
|
|
cost float64
|
|
base float64
|
|
}
|
|
|
|
type productPerformanceAttrInfo struct {
|
|
itemDescription string
|
|
kategori string
|
|
askiliYan string
|
|
urunIlkGrubu string
|
|
urunAnaGrubu string
|
|
urunAltGrubu string
|
|
}
|
|
|
|
func productPerformanceVariantKey(productCode, colorCode, yakaKodu string) string {
|
|
return normalizeProductPerformanceProductCode(productCode) + "|" + strings.TrimSpace(colorCode) + "|" + strings.TrimSpace(yakaKodu)
|
|
}
|
|
|
|
func normalizeProductPerformanceCostPair(costPriceUSD, basePriceUSD float64) (float64, float64) {
|
|
if costPriceUSD > 0 && basePriceUSD > 0 && costPriceUSD > basePriceUSD {
|
|
return basePriceUSD, costPriceUSD
|
|
}
|
|
return costPriceUSD, basePriceUSD
|
|
}
|
|
|
|
const productPerformanceTurkishTranslateSQL = "U&'\\0130\\015E\\011E\\00DC\\00D6\\00C7\\0131\\015F\\011F\\00FC\\00F6\\00E7', 'ISGUOCisguoc'"
|
|
const productPerformanceExcludedFirstGroupsSQL = "('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')"
|
|
|
|
func productPerformanceNormalizedTextSQL(expr string) string {
|
|
return "upper(translate(btrim(COALESCE(" + expr + ",'')), " + productPerformanceTurkishTranslateSQL + "))"
|
|
}
|
|
|
|
func productPerformanceCleanFirstGroupSQL(expr string) string {
|
|
return "CASE WHEN btrim(COALESCE(" + expr + ",'')) = '-' THEN '' WHEN " + productPerformanceNormalizedTextSQL(expr) + " IN ('YETISKIN', 'YETISKIN/GARSON', 'GARSON') THEN '' ELSE COALESCE(" + expr + ",'') END"
|
|
}
|
|
|
|
func productPerformanceAllowedFirstGroupSQL(expr string) string {
|
|
return productPerformanceNormalizedTextSQL(expr) + " NOT IN " + productPerformanceExcludedFirstGroupsSQL
|
|
}
|
|
|
|
func isExcludedProductPerformanceFirstGroup(value string) bool {
|
|
switch normalizeProductPerformanceTurkishText(value) {
|
|
case "MALZEMELI FASON", "MALZEMESIZ FASON", "MAZLEMELI FASON", "MAZEMESIZ FASON", "DIGER":
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func productPerformancePriceLookup(ctx context.Context, pg *sql.DB, productCodes []string) (map[string]productPerformancePriceInfo, error) {
|
|
out := make(map[string]productPerformancePriceInfo, len(productCodes))
|
|
if len(productCodes) == 0 {
|
|
return out, nil
|
|
}
|
|
normalizedCodes := make([]string, 0, len(productCodes))
|
|
seen := map[string]bool{}
|
|
for _, productCode := range productCodes {
|
|
code := normalizeProductPerformanceProductCode(productCode)
|
|
if code == "" || seen[code] {
|
|
continue
|
|
}
|
|
seen[code] = true
|
|
normalizedCodes = append(normalizedCodes, code)
|
|
}
|
|
if len(normalizedCodes) == 0 {
|
|
return out, nil
|
|
}
|
|
rows, err := pg.QueryContext(ctx, `
|
|
SELECT product_code, COALESCE(cost_price_usd,0), COALESCE(base_price_usd,0)
|
|
FROM mk_product_performance_price_dim
|
|
WHERE product_code = ANY($1)
|
|
`, pq.Array(normalizedCodes))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var productCode string
|
|
var p productPerformancePriceInfo
|
|
if err := rows.Scan(&productCode, &p.cost, &p.base); err != nil {
|
|
return nil, err
|
|
}
|
|
p.cost, p.base = normalizeProductPerformanceCostPair(p.cost, p.base)
|
|
out[normalizeProductPerformanceProductCode(productCode)] = p
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func productPerformanceAttrLookup(ctx context.Context, pg *sql.DB, variantKeys []string) (map[string]productPerformanceAttrInfo, error) {
|
|
out := make(map[string]productPerformanceAttrInfo, len(variantKeys))
|
|
if len(variantKeys) == 0 {
|
|
return out, nil
|
|
}
|
|
rows, err := pg.QueryContext(ctx, `
|
|
WITH Latest AS (
|
|
SELECT MAX(kpi_date) AS kpi_date
|
|
FROM mk_product_performance_kpi_daily
|
|
)
|
|
SELECT DISTINCT ON (product_code, color_code, yaka_kodu)
|
|
product_code,
|
|
color_code,
|
|
yaka_kodu,
|
|
COALESCE(item_description,'') AS item_description,
|
|
COALESCE(kategori,'') AS kategori,
|
|
CASE WHEN btrim(COALESCE(askili_yan,'')) = '-' THEN '' ELSE COALESCE(askili_yan,'') END AS askili_yan,
|
|
COALESCE(urun_ilk_grubu,'') AS urun_ilk_grubu,
|
|
COALESCE(urun_ana_grubu,'') AS urun_ana_grubu,
|
|
COALESCE(urun_alt_grubu,'') AS urun_alt_grubu
|
|
FROM mk_product_performance_kpi_daily
|
|
WHERE kpi_date = (SELECT kpi_date FROM Latest)
|
|
AND product_code || '|' || color_code || '|' || yaka_kodu = ANY($1)
|
|
AND upper(translate(btrim(COALESCE(urun_ilk_grubu,'')), U&'\0130\015E\011E\00DC\00D6\00C7\0131\015F\011F\00FC\00F6\00E7', 'ISGUOCisguoc')) NOT IN ('MALZEMELI FASON', 'MALZEMESIZ FASON', 'MAZLEMELI FASON', 'MAZEMESIZ FASON', 'DIGER')
|
|
ORDER BY product_code, color_code, yaka_kodu, performance_score DESC
|
|
`, pq.Array(variantKeys))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var productCode, colorCode, yakaKodu string
|
|
var p productPerformanceAttrInfo
|
|
if err := rows.Scan(&productCode, &colorCode, &yakaKodu, &p.itemDescription, &p.kategori, &p.askiliYan, &p.urunIlkGrubu, &p.urunAnaGrubu, &p.urunAltGrubu); err != nil {
|
|
return nil, err
|
|
}
|
|
p.askiliYan = cleanProductPerformanceOptionalAttr(p.askiliYan)
|
|
p.urunIlkGrubu = cleanProductPerformanceFirstGroup(p.urunIlkGrubu)
|
|
out[productPerformanceVariantKey(productCode, colorCode, yakaKodu)] = p
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func productPerformanceStockLookup(ctx context.Context, pg *sql.DB, variantKeys []string) (map[string]float64, error) {
|
|
out := make(map[string]float64, len(variantKeys))
|
|
if len(variantKeys) == 0 {
|
|
return out, nil
|
|
}
|
|
rows, err := pg.QueryContext(ctx, `
|
|
WITH Latest AS (
|
|
SELECT MAX(stock_date) AS stock_date
|
|
FROM mk_product_performance_stock_daily
|
|
)
|
|
SELECT
|
|
product_code || '|' || color_code || '|' || yaka_kodu AS variant_key,
|
|
COALESCE(SUM(stock_qty),0) AS stock_qty
|
|
FROM mk_product_performance_stock_daily
|
|
WHERE stock_date = (SELECT stock_date FROM Latest)
|
|
AND product_code || '|' || color_code || '|' || yaka_kodu = ANY($1)
|
|
GROUP BY product_code, color_code, yaka_kodu
|
|
`, pq.Array(variantKeys))
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer rows.Close()
|
|
for rows.Next() {
|
|
var key string
|
|
var stockQty float64
|
|
if err := rows.Scan(&key, &stockQty); err != nil {
|
|
return nil, err
|
|
}
|
|
out[key] = stockQty
|
|
}
|
|
return out, rows.Err()
|
|
}
|
|
|
|
func productPerformanceOrderRecommendation(row models.ProductPerformanceOrderAnalysisRow) (string, string) {
|
|
switch {
|
|
case row.OrderQty > 0 && row.NetStockAfterOrder < 0 && row.ExpectedProfitCostUSD >= 0:
|
|
return "STOKSUZ_TALEP", "Acik siparis stoktan buyuk. Karli talep var; uretim/satin alma onceligi ver."
|
|
case row.ExpectedProfitCostUSD < 0:
|
|
return "FIYAT_BASKISI", "Acik siparis ciplak maliyete gore zarar yaziyor. Fiyat/maliyet kontrol edilmeli."
|
|
case row.ExpectedMarginCost >= 0.25 && row.NetStockAfterOrder >= 0:
|
|
return "YILDIZ_URUN", "Acik siparis karli ve stok karsiliyor. Teslimat korunmali."
|
|
case row.ExpectedMarginCost >= 0.25 && row.NetStockAfterOrder < 0:
|
|
return "FIYAT_FIRSATI", "Karli talep var ama stok yetersiz. Uretim planina alinmali."
|
|
case row.OverdueQty > 0:
|
|
return "TAKIP", "Termin gecikmesi olan acik siparis var. Operasyon takibi gerekli."
|
|
default:
|
|
return "TAKIP", "Siparis, stok ve fiyat duzenli izlenmeli."
|
|
}
|
|
}
|
|
|
|
func productPerformanceOrderGroupRecommendation(row models.ProductPerformanceOrderGroupRow) (string, string) {
|
|
switch {
|
|
case row.ExpectedProfitCostUSD < 0:
|
|
return "FIYAT_BASKISI", "Ciplak maliyete gore zarar yazan acik siparis var. Fiyat/maliyet acil kontrol edilmeli."
|
|
case row.ExpectedProfitBaseUSD < 0:
|
|
return "FIYAT_BASKISI", "Taban maliyete gore brut zarar var. Satis fiyati veya iskonto kontrol edilmeli."
|
|
case row.NetStockAfterOrder < 0 && row.ExpectedProfitCostUSD >= 0:
|
|
return "STOKSUZ_TALEP", "Karli acik talep var ama stok yetersiz. Uretim/satin alma onceligi ver."
|
|
case row.ExpectedMarginCost >= 0.25 && row.ExpectedMarginBase >= 0.15:
|
|
return "YILDIZ_URUN", "Piyasa/musteri acik siparisleri karli. Teslimat ve stok korunmali."
|
|
case row.OverdueQty > 0:
|
|
return "TAKIP", "Geciken acik siparis var. Operasyon takibi gerekli."
|
|
default:
|
|
return "TAKIP", "Siparis karliligi ve stok yeterliligi izlenmeli."
|
|
}
|
|
}
|
|
|
|
func productPerformanceOrderProductCustomerRecommendation(row models.ProductPerformanceOrderProductCustomerRow) (string, string) {
|
|
switch {
|
|
case row.ExpectedProfitCostUSD < 0:
|
|
return "FIYAT_BASKISI", "Bu musteri/urun acik siparisi ciplak maliyete gore zarar yaziyor."
|
|
case row.ExpectedProfitBaseUSD < 0:
|
|
return "FIYAT_BASKISI", "Bu musteri/urun acik siparisi taban maliyete gore brut zarar yaziyor."
|
|
case row.NetStockAfterOrder < 0 && row.ExpectedProfitCostUSD >= 0:
|
|
return "STOKSUZ_TALEP", "Musteride karli talep var ama stok yetersiz."
|
|
case row.ExpectedMarginCost >= 0.25 && row.ExpectedMarginBase >= 0.15:
|
|
return "YILDIZ_URUN", "Musteri bazinda karli acik talep var."
|
|
case row.OverdueQty > 0:
|
|
return "TAKIP", "Bu musteri/urun kiriliminda geciken acik siparis var."
|
|
default:
|
|
return "TAKIP", "Musteri talebi, fiyat ve stok birlikte izlenmeli."
|
|
}
|
|
}
|
|
|
|
func productPerformanceOrderMarketDetailRecommendation(row models.ProductPerformanceOrderMarketDetailRow) (string, string) {
|
|
switch {
|
|
case row.ExpectedProfitCostUSD < 0:
|
|
return "FIYAT_BASKISI", "Siparis satiri ciplak maliyete gore zarar yaziyor."
|
|
case row.ExpectedProfitBaseUSD < 0:
|
|
return "FIYAT_BASKISI", "Siparis satiri taban maliyete gore brut zarar yaziyor."
|
|
case row.NetStockAfterOrder < 0 && row.ExpectedProfitCostUSD >= 0:
|
|
return "STOKSUZ_TALEP", "Karli siparis var ama mevcut stok siparisi karsilamiyor."
|
|
case row.IsOverdue:
|
|
return "TAKIP", "Termin gecmis; teslimat aksiyonu gerekli."
|
|
case row.ExpectedMarginCost >= 0.25:
|
|
return "YILDIZ_URUN", "Siparis karli; teslimat ve stok korunmali."
|
|
default:
|
|
return "TAKIP", "Siparis fiyati, stok ve termin izlenmeli."
|
|
}
|
|
}
|
|
|
|
func dateOnly(t time.Time) time.Time {
|
|
y, m, d := t.Date()
|
|
return time.Date(y, m, d, 0, 0, 0, 0, t.Location())
|
|
}
|
|
|
|
type productPerformanceSalesDaily struct {
|
|
SalesDate time.Time
|
|
ProductCode string
|
|
ColorCode string
|
|
YakaKodu string
|
|
ItemDescription string
|
|
Kategori string
|
|
Seri string
|
|
YasGrubu string
|
|
AskiliYan string
|
|
UrunIlkGrubu string
|
|
UrunAnaGrubu string
|
|
UrunAltGrubu string
|
|
MarketKey string
|
|
ChannelCode string
|
|
CustomerCountry string
|
|
CustomerSegment string
|
|
CustomerCode string
|
|
CustomerName string
|
|
SalesQty float64
|
|
SalesTL float64
|
|
SalesUSD float64
|
|
AvgPriceUSD float64
|
|
InvoiceLineCount int
|
|
InvoiceCount int
|
|
CustomerCount int
|
|
LastRefNumber string
|
|
}
|
|
|
|
type productPerformanceStockDaily struct {
|
|
StockDate time.Time
|
|
ProductCode string
|
|
ColorCode string
|
|
YakaKodu string
|
|
StockQty float64
|
|
InQty float64
|
|
OutQty float64
|
|
KpiInQty float64
|
|
KpiOutQty float64
|
|
SalesMovementQty float64
|
|
ProductionInQty float64
|
|
PurchaseInQty float64
|
|
ConsumptionOutQty float64
|
|
CountDiffQty float64
|
|
}
|