diff --git a/svc/brand_sync_scheduler.go b/svc/brand_sync_scheduler.go new file mode 100644 index 0000000..171150f --- /dev/null +++ b/svc/brand_sync_scheduler.go @@ -0,0 +1,51 @@ +package main + +import ( + "bssapp-backend/queries" + "context" + "database/sql" + "log" + "os" + "strconv" + "strings" + "time" +) + +func startBrandSyncScheduler(pgDB *sql.DB, mssqlDB *sql.DB) { + enabled := strings.TrimSpace(strings.ToLower(os.Getenv("BRAND_SYNC_ENABLED"))) + if enabled == "0" || enabled == "false" || enabled == "off" { + log.Println("🛑 Brand sync scheduler disabled") + return + } + + intervalMin := 30 + if raw := strings.TrimSpace(os.Getenv("BRAND_SYNC_INTERVAL_MIN")); raw != "" { + if parsed, err := strconv.Atoi(raw); err == nil && parsed >= 5 { + intervalMin = parsed + } + } + + runOnce := func(reason string) { + if pgDB == nil || mssqlDB == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 120*time.Second) + defer cancel() + res, err := queries.SyncBrandsFromMSSQL(ctx, mssqlDB, pgDB) + if err != nil { + log.Printf("❌ Brand sync failed (%s): %v", reason, err) + return + } + log.Printf("✅ Brand sync ok (%s): total=%d upserted=%d deleted=%d", reason, res.Total, res.Upserted, res.Deleted) + } + + go func() { + runOnce("startup") + + t := time.NewTicker(time.Duration(intervalMin) * time.Minute) + defer t.Stop() + for range t.C { + runOnce("scheduled") + } + }() +} diff --git a/svc/main.go b/svc/main.go index 2ffdecc..1a7ae81 100644 --- a/svc/main.go +++ b/svc/main.go @@ -6,6 +6,7 @@ import ( "bssapp-backend/internal/mailer" "bssapp-backend/middlewares" "bssapp-backend/permissions" + "bssapp-backend/queries" "bssapp-backend/repository" "bssapp-backend/routes" "bssapp-backend/utils" @@ -109,6 +110,12 @@ func autoRegisterRouteV3( // 2) MODULE LOOKUP AUTO SEED (permission ekranları için) moduleLabel := strings.TrimSpace(strings.ReplaceAll(module, "_", " ")) + switch strings.ToLower(strings.TrimSpace(module)) { + case "pricing": + moduleLabel = "Ürün Fiyatlandırma" + case "costing": + moduleLabel = "Ürün Maliyetlendirme" + } if moduleLabel == "" { moduleLabel = module } @@ -460,6 +467,16 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router "system", "update", wrapV3(http.HandlerFunc(rdHandler.Save)), ) + bindV3(r, pgDB, + "/api/roles/{roleId}/departments/{deptCode}/members", "GET", + "system", "update", + wrapV3(http.HandlerFunc(rdHandler.Members)), + ) + bindV3(r, pgDB, + "/api/roles/{roleId}/departments/{deptCode}/members", "POST", + "system", "update", + wrapV3(http.HandlerFunc(rdHandler.AddMember)), + ) // ============================================================ // USERS @@ -770,167 +787,217 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router ) bindV3(r, pgDB, "/api/pricing/products", "GET", - "order", "view", + "pricing", "view", wrapV3(http.HandlerFunc(routes.GetProductPricingListHandler)), ) + bindV3(r, pgDB, + "/api/pricing/products/options", "GET", + "pricing", "view", + wrapV3(http.HandlerFunc(routes.GetProductPricingFilterOptionsHandler)), + ) + bindV3(r, pgDB, + "/api/pricing/brand-classification/lookups", "GET", + "pricing", "view", + wrapV3(routes.GetBrandClassificationLookupsHandler(pgDB)), + ) + bindV3(r, pgDB, + "/api/pricing/brand-classification/brands", "GET", + "pricing", "view", + wrapV3(routes.ListBrandsHandler(pgDB)), + ) + bindV3(r, pgDB, + "/api/pricing/brand-classification/brands/sync", "POST", + "pricing", "update", + wrapV3(routes.SyncBrandsFromMSSQLHandler(pgDB)), + ) + bindV3(r, pgDB, + "/api/pricing/brand-classification/brand/{code}/group", "POST", + "pricing", "update", + wrapV3(routes.SetBrandGroupHandler(pgDB)), + ) + bindV3(r, pgDB, + "/api/pricing/brand-classification/brands/group-bulk", "POST", + "pricing", "update", + wrapV3(routes.SetBrandGroupsBulkHandler(pgDB)), + ) + bindV3(r, pgDB, + "/api/pricing/pricing-rules", "GET", + "pricing", "view", + wrapV3(routes.GetPricingRulesHandler(pgDB)), + ) + bindV3(r, pgDB, + "/api/pricing/pricing-rules/bulk-save", "POST", + "pricing", "update", + wrapV3(routes.SavePricingRulesBulkHandler(pgDB)), + ) + bindV3(r, pgDB, + "/api/pricing/pricing-rules/options", "GET", + "pricing", "view", + wrapV3(routes.GetPricingRuleOptionsHandler(pgDB)), + ) + bindV3(r, pgDB, + "/api/pricing/pricing-rules/parameters", "GET", + "pricing", "view", + wrapV3(routes.GetPricingParameterRulesHandler(pgDB)), + ) bindV3(r, pgDB, "/api/pricing/production-product-costing/no-cost-products", "GET", - "order", "view", + "costing", "view", wrapV3(http.HandlerFunc(routes.GetProductionNoCostProductsHandler)), ) bindV3(r, pgDB, "/api/pricing/production-product-costing/has-cost-products", "GET", - "order", "view", + "costing", "view", wrapV3(http.HandlerFunc(routes.GetProductionHasCostProductsHandler)), ) bindV3(r, pgDB, "/api/pricing/production-product-costing/has-cost-history", "GET", - "order", "view", + "costing", "view", wrapV3(http.HandlerFunc(routes.GetProductionHasCostHistoryHandler)), ) bindV3(r, pgDB, "/api/pricing/production-product-costing/has-cost-detail-groups", "GET", - "order", "view", + "costing", "view", wrapV3(http.HandlerFunc(routes.GetProductionHasCostDetailGroupsHandler)), ) bindV3(r, pgDB, "/api/pricing/production-product-costing/has-cost-detail-header", "GET", - "order", "view", + "costing", "view", wrapV3(http.HandlerFunc(routes.GetProductionHasCostDetailHeaderHandler)), ) bindV3(r, pgDB, "/api/pricing/production-product-costing/production-types", "GET", - "order", "view", + "costing", "view", wrapV3(http.HandlerFunc(routes.GetProductionTypesHandler)), ) bindV3(r, pgDB, "/api/pricing/production-product-costing/detail-editor-options", "GET", - "order", "view", + "costing", "view", wrapV3(http.HandlerFunc(routes.GetProductionHasCostDetailEditorOptionsHandler)), ) bindV3(r, pgDB, "/api/pricing/production-product-costing/has-cost-detail-exchange-rates", "GET", - "order", "view", + "costing", "view", wrapV3(http.HandlerFunc(routes.GetProductionHasCostDetailExchangeRatesHandler)), ) bindV3(r, pgDB, "/api/pricing/production-product-costing/has-cost-detail-line-history", "GET", - "order", "view", + "costing", "view", wrapV3(http.HandlerFunc(routes.GetProductionHasCostDetailLineHistoryHandler)), ) bindV3(r, pgDB, "/api/pricing/production-product-costing/has-cost-detail-similar-history", "GET", - "order", "view", + "costing", "view", wrapV3(http.HandlerFunc(routes.GetProductionHasCostDetailSimilarHistoryHandler)), ) bindV3(r, pgDB, "/api/pricing/production-product-costing/has-cost-detail-bulk-prices", "POST", - "order", "view", + "costing", "view", wrapV3(http.HandlerFunc(routes.PostProductionHasCostDetailBulkPricesHandler)), ) bindV3(r, pgDB, "/api/pricing/production-product-costing/has-cost-detail/last-detail", "POST", - "order", "view", + "costing", "view", wrapV3(http.HandlerFunc(routes.PostProductionProductCostingHasCostDetailLastDetailHandler)), ) bindV3(r, pgDB, "/api/pricing/production-product-costing/options/hammadde-by-nos", "POST", - "order", "view", + "costing", "view", wrapV3(http.HandlerFunc(routes.PostProductionProductCostingOptionsHammaddeByNosHandler)), ) bindV3(r, pgDB, "/api/pricing/production-product-costing/onml/save", "POST", - "order", "view", + "costing", "view", wrapV3(routes.PostProductionProductCostingOnMLSaveHandlerWithMailer(ml)), ) bindV3(r, pgDB, "/api/pricing/production-product-costing/onml/pdf", "GET", - "order", "view", + "costing", "view", wrapV3(http.HandlerFunc(routes.GetProductionProductCostingOnMLPDFHandler)), ) bindV3(r, pgDB, "/api/pricing/production-product-costing/onml/delete", "POST", - "order", "view", + "costing", "view", wrapV3(http.HandlerFunc(routes.PostProductionProductCostingOnMLDeleteHandler)), ) bindV3(r, pgDB, "/api/pricing/production-product-costing/default-quantities", "GET", - "order", "view", + "costing", "view", wrapV3(http.HandlerFunc(routes.GetProductionProductCostingDefaultQuantitiesHandler)), ) bindV3(r, pgDB, "/api/pricing/production-product-costing/default-quantities/upsert", "POST", - "order", "view", + "costing", "view", wrapV3(http.HandlerFunc(routes.PostProductionProductCostingDefaultQuantitiesUpsertHandler)), ) bindV3(r, pgDB, "/api/pricing/production-product-costing/default-quantities/update-bulk", "POST", - "order", "view", + "costing", "view", wrapV3(http.HandlerFunc(routes.PostProductionProductCostingDefaultQuantitiesBulkUpdateHandler)), ) bindV3(r, pgDB, "/api/pricing/production-product-costing/default-quantities/calc-avg", "POST", - "order", "view", + "costing", "view", wrapV3(http.HandlerFunc(routes.PostProductionProductCostingDefaultQuantitiesCalcAvgHandler)), ) bindV3(r, pgDB, "/api/pricing/production-product-costing/default-quantities/lookup", "POST", - "order", "view", + "costing", "view", wrapV3(http.HandlerFunc(routes.PostProductionProductCostingDefaultQuantitiesLookupHandler)), ) bindV3(r, pgDB, "/api/pricing/production-product-costing/default-quantities/refresh", "POST", - "order", "view", + "costing", "view", wrapV3(http.HandlerFunc(routes.PostProductionProductCostingDefaultQuantitiesRefreshHandler)), ) bindV3(r, pgDB, "/api/pricing/production-product-costing/tbstok/exists-bulk", "POST", - "order", "view", + "costing", "view", wrapV3(http.HandlerFunc(routes.PostProductionProductCostingTbStokExistsBulkHandler)), ) bindV3(r, pgDB, "/api/pricing/production-product-costing/last10-warnings", "GET", - "order", "view", + "costing", "view", wrapV3(http.HandlerFunc(routes.GetProductionProductCostingLast10WarningsHandler)), ) bindV3(r, pgDB, "/api/pricing/production-product-costing/options/urun-ana-grup", "GET", - "order", "view", + "costing", "view", wrapV3(http.HandlerFunc(routes.GetProductionProductCostingUrunAnaGrupOptionsHandler)), ) bindV3(r, pgDB, "/api/pricing/production-product-costing/options/urun-alt-grup", "GET", - "order", "view", + "costing", "view", wrapV3(http.HandlerFunc(routes.GetProductionProductCostingUrunAltGrupOptionsHandler)), ) bindV3(r, pgDB, "/api/pricing/production-product-costing/options/urun-ana-alt-combos", "GET", - "order", "view", + "costing", "view", wrapV3(http.HandlerFunc(routes.GetProductionProductCostingUrunAnaAltCombosHandler)), ) bindV3(r, pgDB, "/api/pricing/production-product-costing/options/mtbolum", "GET", - "order", "view", + "costing", "view", wrapV3(http.HandlerFunc(routes.GetProductionProductCostingMTBolumOptionsHandler)), ) bindV3(r, pgDB, "/api/pricing/production-product-costing/maliyet-parca-eslestirme", "GET", - "order", "view", + "costing", "view", wrapV3(http.HandlerFunc(routes.GetProductionProductCostingParcaMappingsHandler)), ) bindV3(r, pgDB, "/api/pricing/production-product-costing/maliyet-parca-eslestirme", "DELETE", - "order", "view", + "costing", "view", wrapV3(http.HandlerFunc(routes.DeleteProductionProductCostingParcaMappingHandler)), ) bindV3(r, pgDB, "/api/pricing/production-product-costing/maliyet-parca-eslestirme/upsert", "POST", - "order", "view", + "costing", "view", wrapV3(http.HandlerFunc(routes.PostProductionProductCostingParcaMappingUpsertHandler)), ) bindV3(r, pgDB, "/api/pricing/production-product-costing/maliyet-parca-eslestirme/set-active", "POST", - "order", "view", + "costing", "view", wrapV3(http.HandlerFunc(routes.PostProductionProductCostingParcaMappingSetActiveHandler)), ) @@ -1062,6 +1129,20 @@ func main() { // ------------------------------------------------------- routes.EnsureTranslationPerfIndexes(pgDB) + // ------------------------------------------------------- + // 🏷️ BRAND CLASSIFICATION BOOTSTRAP + SYNC SCHEDULER + // ------------------------------------------------------- + // Ensure Postgres tables exist even before the UI hits the endpoints. + if err := queries.EnsureBrandClassificationTables(pgDB); err != nil { + log.Println("⚠️ mk_brands bootstrap failed:", err) + } + if err := queries.EnsurePricingRuleTables(pgDB); err != nil { + log.Println("pricing rule tables bootstrap failed:", err) + } + if err := queries.EnsurePricingParameterTables(pgDB); err != nil { + log.Println("mk_urunpricingprmtr bootstrap failed:", err) + } + // ------------------------------------------------------- // ✉️ MAILER INIT // ------------------------------------------------------- @@ -1081,6 +1162,8 @@ func main() { // ------------------------------------------------------- router := InitRoutes(pgDB, db.MssqlDB, graphMailer) startTranslationSyncScheduler(pgDB, db.MssqlDB) + startBrandSyncScheduler(pgDB, db.MssqlDB) + startPricingParameterSyncScheduler(pgDB, db.MssqlDB) handler := enableCORS( middlewares.GlobalAuthMiddleware( diff --git a/svc/models/product_pricing.go b/svc/models/product_pricing.go index eeda251..6db4e6f 100644 --- a/svc/models/product_pricing.go +++ b/svc/models/product_pricing.go @@ -5,7 +5,28 @@ type ProductPricing struct { CostPrice float64 `json:"CostPrice"` StockQty float64 `json:"StockQty"` StockEntryDate string `json:"StockEntryDate"` + LastCostingDate string `json:"LastCostingDate"` LastPricingDate string `json:"LastPricingDate"` + BasePriceUsd float64 `json:"BasePriceUsd"` + BasePriceTry float64 `json:"BasePriceTry"` + USD1 float64 `json:"USD1"` + USD2 float64 `json:"USD2"` + USD3 float64 `json:"USD3"` + USD4 float64 `json:"USD4"` + USD5 float64 `json:"USD5"` + USD6 float64 `json:"USD6"` + EUR1 float64 `json:"EUR1"` + EUR2 float64 `json:"EUR2"` + EUR3 float64 `json:"EUR3"` + EUR4 float64 `json:"EUR4"` + EUR5 float64 `json:"EUR5"` + EUR6 float64 `json:"EUR6"` + TRY1 float64 `json:"TRY1"` + TRY2 float64 `json:"TRY2"` + TRY3 float64 `json:"TRY3"` + TRY4 float64 `json:"TRY4"` + TRY5 float64 `json:"TRY5"` + TRY6 float64 `json:"TRY6"` AskiliYan string `json:"AskiliYan"` Kategori string `json:"Kategori"` UrunIlkGrubu string `json:"UrunIlkGrubu"` @@ -14,5 +35,6 @@ type ProductPricing struct { Icerik string `json:"Icerik"` Karisim string `json:"Karisim"` Marka string `json:"Marka"` + BrandCode string `json:"BrandCode"` BrandGroupSec string `json:"BrandGroupSec"` } diff --git a/svc/pricing_parameter_sync_scheduler.go b/svc/pricing_parameter_sync_scheduler.go new file mode 100644 index 0000000..911fe35 --- /dev/null +++ b/svc/pricing_parameter_sync_scheduler.go @@ -0,0 +1,60 @@ +package main + +import ( + "bssapp-backend/queries" + "context" + "database/sql" + "log" + "os" + "strconv" + "strings" + "time" +) + +func startPricingParameterSyncScheduler(pgDB *sql.DB, mssqlDB *sql.DB) { + enabled := strings.TrimSpace(strings.ToLower(os.Getenv("PRICING_PARAMETER_SYNC_ENABLED"))) + if enabled == "0" || enabled == "false" || enabled == "off" { + log.Println("Pricing parameter sync scheduler disabled") + return + } + + intervalMin := 30 + if raw := strings.TrimSpace(os.Getenv("PRICING_PARAMETER_SYNC_INTERVAL_MIN")); raw != "" { + if parsed, err := strconv.Atoi(raw); err == nil && parsed >= 5 { + intervalMin = parsed + } + } + + runOnce := func(reason string) { + if pgDB == nil || mssqlDB == nil { + return + } + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Minute) + defer cancel() + res, err := queries.SyncPricingParametersFromMSSQL(ctx, mssqlDB, pgDB) + if err != nil { + log.Printf("Pricing parameter sync failed (%s): %v", reason, err) + return + } + log.Printf( + "Pricing parameter sync ok (%s): total=%d upserted=%d deactivated=%d", + reason, + res.Total, + res.Upserted, + res.Deactivated, + ) + } + + go func() { + // Give the startup brand sync a short head start so brand_group_sec is + // attached during the first parameter cache fill. + time.Sleep(2 * time.Second) + runOnce("startup") + + ticker := time.NewTicker(time.Duration(intervalMin) * time.Minute) + defer ticker.Stop() + for range ticker.C { + runOnce("scheduled") + } + }() +} diff --git a/svc/queries/brand_classification.go b/svc/queries/brand_classification.go new file mode 100644 index 0000000..c8a13be --- /dev/null +++ b/svc/queries/brand_classification.go @@ -0,0 +1,191 @@ +package queries + +import ( + "context" + "database/sql" + "fmt" + "strings" + + "github.com/lib/pq" +) + +type BrandRow struct { + BrandCode string `json:"brand_code"` + BrandName string `json:"brand_name"` + GroupID int `json:"group_id"` + GroupCode string `json:"group_code"` + GroupName string `json:"group_name"` +} + +type BrandGroupOption struct { + ID int `json:"id"` + Code string `json:"code"` + Title string `json:"title"` + Description string `json:"description"` +} + +func EnsureBrandClassificationTables(pg *sql.DB) error { + stmts := []string{ + ` +CREATE TABLE IF NOT EXISTS mk_brands ( + brand_code TEXT PRIMARY KEY, + brand_name TEXT NOT NULL DEFAULT '', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +)`, + `CREATE INDEX IF NOT EXISTS ix_mk_brands_name ON mk_brands (brand_name)`, + ` +CREATE TABLE IF NOT EXISTS mk_brandgrp ( + id SMALLINT PRIMARY KEY, + code TEXT NOT NULL UNIQUE, + title TEXT NOT NULL, + description TEXT NOT NULL DEFAULT '', + sort_order SMALLINT NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now() +)`, + `ALTER TABLE mk_brandgrp ADD COLUMN IF NOT EXISTS description TEXT NOT NULL DEFAULT ''`, + ` +INSERT INTO mk_brandgrp (id, code, title, description, sort_order) +VALUES + (1, 'SARTORIAL', 'SARTORIAL', 'Klasik / terzilik odakli ana marka grubu', 1), + (2, 'PREMIUM', 'PREMIUM', 'Ust segment / premium koleksiyon marka grubu', 2), + (3, 'CORE', 'CORE', 'Ana koleksiyon / temel marka grubu', 3) +ON CONFLICT (id) DO NOTHING`, + `UPDATE mk_brandgrp SET description='Klasik / terzilik odakli ana marka grubu' WHERE id=1 AND COALESCE(description,'')=''`, + `UPDATE mk_brandgrp SET description='Ust segment / premium koleksiyon marka grubu' WHERE id=2 AND COALESCE(description,'')=''`, + `UPDATE mk_brandgrp SET description='Ana koleksiyon / temel marka grubu' WHERE id=3 AND COALESCE(description,'')=''`, + ` +CREATE TABLE IF NOT EXISTS mk_brandgrpmatch ( + brand_code TEXT NOT NULL REFERENCES mk_brands(brand_code) ON DELETE CASCADE, + grp_id SMALLINT NOT NULL REFERENCES mk_brandgrp(id) ON DELETE RESTRICT, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (brand_code) +)`, + `CREATE INDEX IF NOT EXISTS ix_mk_brandgrpmatch_grp ON mk_brandgrpmatch (grp_id)`, + } + for _, s := range stmts { + if _, err := pg.Exec(s); err != nil { + return err + } + } + return nil +} + +func ListBrandGroups(ctx context.Context, pg *sql.DB) ([]BrandGroupOption, error) { + rows, err := pg.QueryContext(ctx, `SELECT id, code, title, description FROM mk_brandgrp ORDER BY sort_order, id`) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]BrandGroupOption, 0, 8) + for rows.Next() { + var o BrandGroupOption + if err := rows.Scan(&o.ID, &o.Code, &o.Title, &o.Description); err != nil { + return nil, err + } + o.Code = strings.TrimSpace(o.Code) + o.Title = strings.TrimSpace(o.Title) + o.Description = strings.TrimSpace(o.Description) + out = append(out, o) + } + return out, rows.Err() +} + +func ListBrandsWithGroups(ctx context.Context, pg *sql.DB, q string, limit int) ([]BrandRow, error) { + if limit <= 0 { + limit = 5000 + } + q = strings.TrimSpace(q) + args := []any{} + where := "" + if q != "" { + args = append(args, "%"+q+"%") + where = "WHERE (b.brand_code ILIKE $1 OR b.brand_name ILIKE $1)" + } + args = append(args, limit) + + limitParam := fmt.Sprintf("$%d", len(args)) + sqlq := ` +SELECT + b.brand_code, + b.brand_name, + COALESCE(m.grp_id, 0) AS group_id, + COALESCE(g.code, '') AS group_code, + COALESCE(g.title, '') AS group_name +FROM mk_brands b +LEFT JOIN mk_brandgrpmatch m ON m.brand_code = b.brand_code +LEFT JOIN mk_brandgrp g ON g.id = m.grp_id +` + where + ` +ORDER BY b.brand_code +LIMIT ` + limitParam + ` +` + rows, err := pg.QueryContext(ctx, sqlq, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]BrandRow, 0, 1024) + for rows.Next() { + var r BrandRow + if err := rows.Scan(&r.BrandCode, &r.BrandName, &r.GroupID, &r.GroupCode, &r.GroupName); err != nil { + return nil, err + } + r.BrandCode = strings.TrimSpace(r.BrandCode) + r.BrandName = strings.TrimSpace(r.BrandName) + r.GroupCode = strings.TrimSpace(r.GroupCode) + r.GroupName = strings.TrimSpace(r.GroupName) + out = append(out, r) + } + return out, rows.Err() +} + +func UpsertBrand(ctx context.Context, tx *sql.Tx, code string, name string, active bool) error { + code = strings.TrimSpace(code) + name = strings.TrimSpace(name) + if code == "" { + return nil + } + _, err := tx.ExecContext(ctx, ` +INSERT INTO mk_brands (brand_code, brand_name, is_active, created_at, updated_at) +VALUES ($1, $2, $3, now(), now()) +ON CONFLICT (brand_code) DO UPDATE SET + brand_name = EXCLUDED.brand_name, + is_active = EXCLUDED.is_active, + updated_at = now() +`, code, name, active) + return err +} + +func DeleteBrandsNotIn(ctx context.Context, tx *sql.Tx, keepCodes []string) error { + // If keepCodes is empty, do nothing (avoid wiping table by mistake). + if len(keepCodes) == 0 { + return nil + } + // Use temp table style deletion via UNNEST. + _, err := tx.ExecContext(ctx, ` +DELETE FROM mk_brands +WHERE brand_code NOT IN (SELECT UNNEST($1::text[])) +`, pq.Array(keepCodes)) + return err +} + +func SetBrandGroup(ctx context.Context, tx *sql.Tx, brandCode string, grpID int) error { + brandCode = strings.TrimSpace(brandCode) + if brandCode == "" { + return nil + } + if grpID <= 0 { + _, err := tx.ExecContext(ctx, `DELETE FROM mk_brandgrpmatch WHERE brand_code=$1`, brandCode) + return err + } + _, err := tx.ExecContext(ctx, ` +INSERT INTO mk_brandgrpmatch (brand_code, grp_id, created_at, updated_at) +VALUES ($1, $2, now(), now()) +ON CONFLICT (brand_code) DO UPDATE SET + grp_id = EXCLUDED.grp_id, + updated_at = now() +`, brandCode, grpID) + return err +} diff --git a/svc/queries/brand_sync.go b/svc/queries/brand_sync.go new file mode 100644 index 0000000..f1876dd --- /dev/null +++ b/svc/queries/brand_sync.go @@ -0,0 +1,117 @@ +package queries + +import ( + "context" + "database/sql" + "sort" + "strings" + + "github.com/lib/pq" +) + +type BrandSyncResult struct { + Upserted int `json:"upserted"` + Deleted int `json:"deleted"` + Total int `json:"total"` +} + +// SyncBrandsFromMSSQL pulls brand attributes from MSSQL BAGGI_V3 and upserts them into Postgres mk_brands. +// Source: dbo.cdItemAttribute WHERE ItemTypeCode=1 AND AttributeTypeCode=10 +func SyncBrandsFromMSSQL(ctx context.Context, mssql *sql.DB, pg *sql.DB) (BrandSyncResult, error) { + out := BrandSyncResult{Upserted: 0, Deleted: 0, Total: 0} + if mssql == nil || pg == nil { + return out, sql.ErrConnDone + } + + if err := EnsureBrandClassificationTables(pg); err != nil { + return out, err + } + + q := ` +SELECT DISTINCT + LTRIM(RTRIM(a.AttributeCode)) AS BrandCode, + COALESCE(NULLIF(LTRIM(RTRIM(d.AttributeDescription)), ''), LTRIM(RTRIM(a.AttributeCode))) AS BrandName, + ISNULL(a.IsBlocked, 0) AS IsBlocked +FROM dbo.cdItemAttribute a WITH(NOLOCK) +LEFT JOIN dbo.cdItemAttributeDesc d WITH(NOLOCK) + ON d.ItemTypeCode = a.ItemTypeCode + AND d.AttributeTypeCode = a.AttributeTypeCode + AND d.AttributeCode = a.AttributeCode + AND d.LangCode = 'TR' +WHERE a.ItemTypeCode = 1 + AND a.AttributeTypeCode = 10 + AND ISNULL(a.IsBlocked, 0) = 0 + AND LEN(LTRIM(RTRIM(a.AttributeCode))) > 0; +` + rows, err := mssql.QueryContext(ctx, q) + if err != nil { + return out, err + } + defer rows.Close() + + type srcBrand struct { + Code string + Name string + IsBlocked bool + } + src := make([]srcBrand, 0, 1024) + keepCodes := make([]string, 0, 1024) + seen := make(map[string]struct{}, 2048) + + for rows.Next() { + var b srcBrand + if err := rows.Scan(&b.Code, &b.Name, &b.IsBlocked); err != nil { + return out, err + } + b.Code = strings.TrimSpace(b.Code) + if b.Code == "" { + continue + } + if _, ok := seen[b.Code]; ok { + continue + } + seen[b.Code] = struct{}{} + b.Name = strings.TrimSpace(b.Name) + src = append(src, b) + keepCodes = append(keepCodes, b.Code) + } + if err := rows.Err(); err != nil { + return out, err + } + + sort.Strings(keepCodes) + out.Total = len(keepCodes) + + tx, err := pg.BeginTx(ctx, nil) + if err != nil { + return out, err + } + defer tx.Rollback() + + for _, b := range src { + active := !b.IsBlocked + if err := UpsertBrand(ctx, tx, b.Code, b.Name, active); err != nil { + return out, err + } + out.Upserted++ + } + + if len(keepCodes) > 0 { + res, err := tx.ExecContext(ctx, ` +DELETE FROM mk_brands +WHERE brand_code NOT IN (SELECT UNNEST($1::text[])) +`, pq.Array(keepCodes)) + if err != nil { + return out, err + } + if n, _ := res.RowsAffected(); n > 0 { + out.Deleted = int(n) + } + } + + if err := tx.Commit(); err != nil { + return out, err + } + + return out, nil +} diff --git a/svc/queries/permission_role_dept.go b/svc/queries/permission_role_dept.go index a3020ed..973e036 100644 --- a/svc/queries/permission_role_dept.go +++ b/svc/queries/permission_role_dept.go @@ -34,6 +34,28 @@ DO UPDATE SET ` +const ListRoleDepartmentMembers = ` +SELECT DISTINCT + u.id, + COALESCE(NULLIF(BTRIM(u.full_name), ''), u.username) AS full_name, + u.username +FROM mk_dfusr u +JOIN dfrole_usr ru + ON ru.dfusr_id = u.id + AND ru.dfrole_id = $1 +JOIN dfusr_dprt ud + ON ud.dfusr_id = u.id + AND ud.is_active = TRUE +JOIN mk_dprt d + ON d.id = ud.dprt_id + AND d.code = $2 +WHERE u.is_active = TRUE +ORDER BY + COALESCE(NULLIF(BTRIM(u.full_name), ''), u.username), + u.username, + u.id +` + // LIST (role+department sets with summary) const ListRoleDepartmentPermissionSets = ` WITH role_dept AS ( @@ -88,7 +110,37 @@ SELECT AND pa.department_code = b.department_code ), '{}'::jsonb - ) AS module_flags + ) AS module_flags, + COALESCE( + ( + SELECT jsonb_agg( + jsonb_build_object( + 'id', member.id, + 'full_name', member.full_name, + 'username', member.username + ) + ORDER BY member.full_name, member.username, member.id + ) + FROM ( + SELECT DISTINCT + u.id, + COALESCE(NULLIF(BTRIM(u.full_name), ''), u.username) AS full_name, + u.username + FROM mk_dfusr u + JOIN dfrole_usr ru + ON ru.dfusr_id = u.id + AND ru.dfrole_id = b.role_id + JOIN dfusr_dprt ud + ON ud.dfusr_id = u.id + AND ud.is_active = TRUE + JOIN mk_dprt member_dept + ON member_dept.id = ud.dprt_id + AND member_dept.code = b.department_code + WHERE u.is_active = TRUE + ) member + ), + '[]'::jsonb + ) AS members FROM base b ORDER BY b.role_title, diff --git a/svc/queries/pricing_parameters.go b/svc/queries/pricing_parameters.go new file mode 100644 index 0000000..7af332a --- /dev/null +++ b/svc/queries/pricing_parameters.go @@ -0,0 +1,712 @@ +package queries + +import ( + "context" + "crypto/md5" + "database/sql" + "encoding/hex" + "fmt" + "log" + "strings" + "time" + + "github.com/lib/pq" +) + +type PricingParameterSyncResult struct { + Total int `json:"total"` + Upserted int `json:"upserted"` + Deactivated int `json:"deactivated"` +} + +type pricingParameterRow struct { + AskiliYan string + Kategori string + UrunIlkGrubu string + UrunAnaGrubu string + UrunAltGrubu string + Icerik string + Marka string + BrandCode string + BrandGroupSec string +} + +type PricingParameterRuleRow struct { + PricingParameterID int64 `json:"pricing_parameter_id"` + ScopeKey string `json:"scope_key"` + AskiliYan string `json:"askili_yan"` + Kategori string `json:"kategori"` + UrunIlkGrubu string `json:"urun_ilk_grubu"` + UrunAnaGrubu string `json:"urun_ana_grubu"` + UrunAltGrubu string `json:"urun_alt_grubu"` + Icerik string `json:"icerik"` + Marka string `json:"marka"` + BrandCode string `json:"brand_code"` + BrandGroupSec string `json:"brand_group"` + HasRule bool `json:"has_rule"` + Rule *PricingRuleRow `json:"rule"` +} + +// EnsurePricingParameterTables keeps the MSSQL-derived cascade cache close to +// the pricing rules. Rows are retained when they disappear from MSSQL and +// marked inactive so historical rule scopes remain understandable. +func EnsurePricingParameterTables(pg *sql.DB) error { + stmts := []string{ + ` +CREATE TABLE IF NOT EXISTS mk_urunpricingprmtr ( + id BIGSERIAL PRIMARY KEY, + askili_yan TEXT NOT NULL DEFAULT '', + kategori 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 '', + icerik TEXT NOT NULL DEFAULT '', + marka TEXT NOT NULL DEFAULT '', + brand_code TEXT NOT NULL DEFAULT '', + brand_group_sec TEXT NOT NULL DEFAULT '', + is_active BOOLEAN NOT NULL DEFAULT TRUE, + first_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + last_seen_at TIMESTAMPTZ NOT NULL DEFAULT now(), + scope_key TEXT GENERATED ALWAYS AS ( + md5(askili_yan || chr(31) || kategori || chr(31) || urun_ilk_grubu || + chr(31) || urun_ana_grubu || chr(31) || urun_alt_grubu || chr(31) || + icerik || chr(31) || marka || chr(31) || brand_code || chr(31) || + brand_group_sec) + ) STORED +)`, + `DROP INDEX IF EXISTS ux_mk_urunpricingprmtr_scope`, + ` +DO $$ +BEGIN + IF EXISTS ( + SELECT 1 + FROM information_schema.columns + WHERE table_schema=current_schema() + AND table_name='mk_urunpricingprmtr' + AND column_name='karisim' + ) THEN + DROP INDEX IF EXISTS ux_mk_urunpricingprmtr_active_scope; + DROP INDEX IF EXISTS ix_mk_urunpricingprmtr_scope_history; + ALTER TABLE mk_urunpricingprmtr DROP COLUMN IF EXISTS scope_key; + ALTER TABLE mk_urunpricingprmtr DROP COLUMN karisim; + END IF; +END $$`, + ` +ALTER TABLE mk_urunpricingprmtr +ADD COLUMN IF NOT EXISTS scope_key TEXT GENERATED ALWAYS AS ( + md5(askili_yan || chr(31) || kategori || chr(31) || urun_ilk_grubu || + chr(31) || urun_ana_grubu || chr(31) || urun_alt_grubu || chr(31) || + icerik || chr(31) || marka || chr(31) || brand_code || chr(31) || + brand_group_sec) +) STORED`, + ` +WITH ranked AS ( + SELECT + id, + ROW_NUMBER() OVER (PARTITION BY scope_key ORDER BY last_seen_at DESC, id DESC) AS rn + FROM mk_urunpricingprmtr + WHERE is_active=TRUE +) +UPDATE mk_urunpricingprmtr p +SET is_active=FALSE +FROM ranked r +WHERE p.id=r.id + AND r.rn > 1`, + `CREATE UNIQUE INDEX IF NOT EXISTS ux_mk_urunpricingprmtr_active_scope ON mk_urunpricingprmtr (scope_key) WHERE is_active = TRUE`, + `CREATE INDEX IF NOT EXISTS ix_mk_urunpricingprmtr_scope_history ON mk_urunpricingprmtr (scope_key, last_seen_at DESC, id DESC)`, + `CREATE INDEX IF NOT EXISTS ix_mk_urunpricingprmtr_active ON mk_urunpricingprmtr (is_active)`, + `CREATE INDEX IF NOT EXISTS ix_mk_urunpricingprmtr_ilk_ana ON mk_urunpricingprmtr (urun_ilk_grubu, urun_ana_grubu) WHERE is_active = TRUE`, + `CREATE INDEX IF NOT EXISTS ix_mk_urunpricingprmtr_brand ON mk_urunpricingprmtr (brand_code, brand_group_sec) WHERE is_active = TRUE`, + `ALTER TABLE mk_pricing_rule ADD COLUMN IF NOT EXISTS pricing_parameter_id BIGINT REFERENCES mk_urunpricingprmtr(id) ON DELETE SET NULL`, + `DROP INDEX IF EXISTS ux_mk_pricing_rule_parameter`, + `CREATE INDEX IF NOT EXISTS ix_mk_pricing_rule_parameter_latest ON mk_pricing_rule (pricing_parameter_id, created_at DESC, updated_at DESC) WHERE pricing_parameter_id IS NOT NULL`, + } + for _, stmt := range stmts { + if _, err := pg.Exec(stmt); err != nil { + return err + } + } + return nil +} + +func FillPricingRuleScopeFromParameter(ctx context.Context, tx *sql.Tx, item *PricingRuleSaveItem) error { + if item == nil || item.PricingParameterID <= 0 { + return nil + } + var p pricingParameterRow + if err := tx.QueryRowContext(ctx, ` +SELECT + askili_yan, kategori, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, + icerik, marka, brand_code, brand_group_sec +FROM mk_urunpricingprmtr +WHERE id=$1 AND is_active=TRUE +`, item.PricingParameterID).Scan( + &p.AskiliYan, + &p.Kategori, + &p.UrunIlkGrubu, + &p.UrunAnaGrubu, + &p.UrunAltGrubu, + &p.Icerik, + &p.Marka, + &p.BrandCode, + &p.BrandGroupSec, + ); err != nil { + return err + } + item.AskiliYan = pricingParameterScopeValue(p.AskiliYan) + item.Kategori = pricingParameterScopeValue(p.Kategori) + item.UrunIlkGrubu = pricingParameterScopeValue(p.UrunIlkGrubu) + item.UrunAnaGrubu = pricingParameterScopeValue(p.UrunAnaGrubu) + item.UrunAltGrubu = pricingParameterScopeValue(p.UrunAltGrubu) + item.Icerik = pricingParameterScopeValue(p.Icerik) + item.Karisim = nil + item.Marka = pricingParameterScopeValue(p.Marka) + item.BrandCode = pricingParameterScopeValue(p.BrandCode) + item.BrandGroupSec = pricingParameterScopeValue(p.BrandGroupSec) + return nil +} + +func VersionPricingParameterForRule(ctx context.Context, tx *sql.Tx, pricingParameterID int64) (int64, error) { + if pricingParameterID <= 0 { + return 0, nil + } + + var p pricingParameterRow + var scopeKey string + if err := tx.QueryRowContext(ctx, ` +SELECT + askili_yan, kategori, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, + icerik, marka, brand_code, brand_group_sec, scope_key +FROM mk_urunpricingprmtr +WHERE id=$1 + AND is_active=TRUE +`, pricingParameterID).Scan( + &p.AskiliYan, + &p.Kategori, + &p.UrunIlkGrubu, + &p.UrunAnaGrubu, + &p.UrunAltGrubu, + &p.Icerik, + &p.Marka, + &p.BrandCode, + &p.BrandGroupSec, + &scopeKey, + ); err != nil { + return 0, err + } + + if _, err := tx.ExecContext(ctx, ` +UPDATE mk_urunpricingprmtr +SET is_active=FALSE, + last_seen_at=now() +WHERE scope_key=$1 + AND is_active=TRUE +`, scopeKey); err != nil { + return 0, err + } + + var newID int64 + if err := tx.QueryRowContext(ctx, ` +INSERT INTO mk_urunpricingprmtr ( + askili_yan, kategori, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, + icerik, marka, brand_code, brand_group_sec, + is_active, first_seen_at, last_seen_at +) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,TRUE,now(),now()) +RETURNING id +`, + p.AskiliYan, + p.Kategori, + p.UrunIlkGrubu, + p.UrunAnaGrubu, + p.UrunAltGrubu, + p.Icerik, + p.Marka, + p.BrandCode, + p.BrandGroupSec, + ).Scan(&newID); err != nil { + return 0, err + } + return newID, nil +} + +func pricingParameterScopeValue(value string) []string { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + return []string{value} +} + +func SyncPricingParametersFromMSSQL(ctx context.Context, mssql *sql.DB, pg *sql.DB) (PricingParameterSyncResult, error) { + out := PricingParameterSyncResult{} + startedAt := time.Now() + if mssql == nil || pg == nil { + return out, sql.ErrConnDone + } + if err := EnsurePricingRuleTables(pg); err != nil { + return out, err + } + if err := EnsurePricingParameterTables(pg); err != nil { + return out, err + } + if err := EnsureBrandClassificationTables(pg); err != nil { + return out, err + } + + rows, err := mssql.QueryContext(ctx, ` +SELECT DISTINCT + COALESCE(LTRIM(RTRIM(ProductAtt45Desc)), '') AS AskiliYan, + COALESCE(LTRIM(RTRIM(ProductAtt44Desc)), '') AS Kategori, + COALESCE(LTRIM(RTRIM(ProductAtt42Desc)), '') AS UrunIlkGrubu, + COALESCE(LTRIM(RTRIM(ProductAtt01Desc)), '') AS UrunAnaGrubu, + COALESCE(LTRIM(RTRIM(ProductAtt02Desc)), '') AS UrunAltGrubu, + COALESCE(LTRIM(RTRIM(ProductAtt41Desc)), '') AS Icerik, + COALESCE(LTRIM(RTRIM(ProductAtt10Desc)), '') AS Marka, + COALESCE(LTRIM(RTRIM(ProductAtt10)), '') AS BrandCode +FROM ProductFilterWithDescription('TR') +WHERE ProductAtt42 IN ('SERI', 'AKSESUAR') + AND IsBlocked = 0 + AND LEN(LTRIM(RTRIM(ProductCode))) = 13; +`) + if err != nil { + return out, err + } + defer rows.Close() + + src := make([]pricingParameterRow, 0, 4096) + for rows.Next() { + var item pricingParameterRow + if err := rows.Scan( + &item.AskiliYan, + &item.Kategori, + &item.UrunIlkGrubu, + &item.UrunAnaGrubu, + &item.UrunAltGrubu, + &item.Icerik, + &item.Marka, + &item.BrandCode, + ); err != nil { + return out, err + } + item = trimPricingParameterRow(item) + src = append(src, item) + } + if err := rows.Err(); err != nil { + return out, err + } + out.Total = len(src) + log.Printf("Pricing parameter sync source loaded: rows=%d duration=%s", out.Total, time.Since(startedAt)) + + groupByBrand, err := pricingParameterBrandGroups(ctx, pg) + if err != nil { + return out, err + } + + tx, err := pg.BeginTx(ctx, nil) + if err != nil { + return out, err + } + defer tx.Rollback() + + if _, err := tx.ExecContext(ctx, ` +CREATE TEMP TABLE tmp_urunpricingprmtr_sync ( + askili_yan TEXT NOT NULL, + kategori TEXT NOT NULL, + urun_ilk_grubu TEXT NOT NULL, + urun_ana_grubu TEXT NOT NULL, + urun_alt_grubu TEXT NOT NULL, + icerik TEXT NOT NULL, + marka TEXT NOT NULL, + brand_code TEXT NOT NULL, + brand_group_sec TEXT NOT NULL, + scope_key TEXT NOT NULL PRIMARY KEY +) ON COMMIT DROP +`); err != nil { + return out, err + } + + copyStmt, err := tx.PrepareContext(ctx, pq.CopyIn( + "tmp_urunpricingprmtr_sync", + "askili_yan", + "kategori", + "urun_ilk_grubu", + "urun_ana_grubu", + "urun_alt_grubu", + "icerik", + "marka", + "brand_code", + "brand_group_sec", + "scope_key", + )) + if err != nil { + return out, err + } + + seenScopeKeys := make(map[string]struct{}, len(src)) + for _, item := range src { + item.BrandGroupSec = groupByBrand[item.BrandCode] + scopeKey := pricingParameterScopeKey(item) + if _, exists := seenScopeKeys[scopeKey]; exists { + continue + } + seenScopeKeys[scopeKey] = struct{}{} + if _, err := copyStmt.ExecContext(ctx, + item.AskiliYan, + item.Kategori, + item.UrunIlkGrubu, + item.UrunAnaGrubu, + item.UrunAltGrubu, + item.Icerik, + item.Marka, + item.BrandCode, + item.BrandGroupSec, + scopeKey, + ); err != nil { + _ = copyStmt.Close() + return out, err + } + } + if _, err := copyStmt.ExecContext(ctx); err != nil { + _ = copyStmt.Close() + return out, err + } + if err := copyStmt.Close(); err != nil { + return out, err + } + out.Upserted = len(seenScopeKeys) + log.Printf("Pricing parameter sync copy loaded: rows=%d duration=%s", out.Upserted, time.Since(startedAt)) + + res, err := tx.ExecContext(ctx, ` +UPDATE mk_urunpricingprmtr p +SET is_active=FALSE +WHERE p.is_active=TRUE + AND NOT EXISTS ( + SELECT 1 + FROM tmp_urunpricingprmtr_sync t + WHERE t.scope_key=p.scope_key + ) +`) + if err != nil { + return out, err + } + if n, err := res.RowsAffected(); err == nil { + out.Deactivated = int(n) + } + + if _, err := tx.ExecContext(ctx, ` +UPDATE mk_urunpricingprmtr p +SET last_seen_at=now() +FROM tmp_urunpricingprmtr_sync t +WHERE p.scope_key=t.scope_key + AND p.is_active=TRUE +`); err != nil { + return out, err + } + + insertResult, err := tx.ExecContext(ctx, ` +INSERT INTO mk_urunpricingprmtr ( + askili_yan, kategori, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, + icerik, marka, brand_code, brand_group_sec, + is_active, first_seen_at, last_seen_at +) +SELECT + askili_yan, kategori, urun_ilk_grubu, urun_ana_grubu, urun_alt_grubu, + icerik, marka, brand_code, brand_group_sec, + TRUE, now(), now() +FROM tmp_urunpricingprmtr_sync t +WHERE NOT EXISTS ( + SELECT 1 + FROM mk_urunpricingprmtr p + WHERE p.scope_key=t.scope_key + AND p.is_active=TRUE +) +`) + if err != nil { + return out, err + } + if n, err := insertResult.RowsAffected(); err == nil { + out.Upserted = int(n) + } + + if err := tx.Commit(); err != nil { + return out, err + } + log.Printf("Pricing parameter sync committed: rows=%d duration=%s", out.Upserted, time.Since(startedAt)) + return out, nil +} + +func pricingParameterScopeKey(item pricingParameterRow) string { + parts := []string{ + item.AskiliYan, + item.Kategori, + item.UrunIlkGrubu, + item.UrunAnaGrubu, + item.UrunAltGrubu, + item.Icerik, + item.Marka, + item.BrandCode, + item.BrandGroupSec, + } + sum := md5.Sum([]byte(strings.Join(parts, string(rune(31))))) + return hex.EncodeToString(sum[:]) +} + +func trimPricingParameterRow(item pricingParameterRow) pricingParameterRow { + item.AskiliYan = strings.TrimSpace(item.AskiliYan) + item.Kategori = strings.TrimSpace(item.Kategori) + item.UrunIlkGrubu = strings.TrimSpace(item.UrunIlkGrubu) + item.UrunAnaGrubu = strings.TrimSpace(item.UrunAnaGrubu) + item.UrunAltGrubu = strings.TrimSpace(item.UrunAltGrubu) + item.Icerik = strings.TrimSpace(item.Icerik) + item.Marka = strings.TrimSpace(item.Marka) + item.BrandCode = strings.TrimSpace(item.BrandCode) + item.BrandGroupSec = strings.TrimSpace(item.BrandGroupSec) + return item +} + +func pricingParameterBrandGroups(ctx context.Context, pg *sql.DB) (map[string]string, error) { + rows, err := pg.QueryContext(ctx, ` +SELECT m.brand_code, g.title +FROM mk_brandgrpmatch m +JOIN mk_brandgrp g ON g.id = m.grp_id +`) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make(map[string]string, 1024) + for rows.Next() { + var code, group string + if err := rows.Scan(&code, &group); err != nil { + return nil, err + } + out[strings.TrimSpace(code)] = strings.TrimSpace(group) + } + return out, rows.Err() +} + +func ListPricingParameterDistinctOptions(ctx context.Context, pg *sql.DB, field string, f PricingRuleOptionFilters, limit int) ([]string, error) { + field = strings.TrimSpace(field) + if limit <= 0 { + limit = 500 + } + fieldMap := map[string]string{ + "askili_yan": "askili_yan", + "kategori": "kategori", + "urun_ilk_grubu": "urun_ilk_grubu", + "urun_ana_grubu": "urun_ana_grubu", + "urun_alt_grubu": "urun_alt_grubu", + "icerik": "icerik", + "marka": "marka", + "brand_code": "brand_code", + "brand_group": "brand_group_sec", + } + target, ok := fieldMap[field] + if !ok { + return nil, fmt.Errorf("invalid field") + } + + type filter struct { + Field string + Values []string + } + filters := []filter{ + {"askili_yan", f.AskiliYan}, + {"kategori", f.Kategori}, + {"urun_ilk_grubu", f.UrunIlkGrubu}, + {"urun_ana_grubu", f.UrunAnaGrubu}, + {"urun_alt_grubu", f.UrunAltGrubu}, + {"icerik", f.Icerik}, + {"marka", f.Marka}, + {"brand_code", f.BrandCode}, + {"brand_group", f.BrandGroupSec}, + } + + args := make([]any, 0, len(filters)+1) + where := []string{"is_active=TRUE", target + " <> ''"} + for _, item := range filters { + if item.Field == field { + continue + } + values := normalizeTextList(item.Values) + if len(values) == 0 { + continue + } + args = append(args, pq.Array(values)) + where = append(where, fieldMap[item.Field]+fmt.Sprintf(" = ANY($%d::text[])", len(args))) + } + args = append(args, limit) + + rows, err := pg.QueryContext(ctx, ` +SELECT DISTINCT `+target+` +FROM mk_urunpricingprmtr +WHERE `+strings.Join(where, " AND ")+` +ORDER BY `+target+` +LIMIT $`+fmt.Sprint(len(args))+` +`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]string, 0, limit) + 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 ListPricingParameterRules(ctx context.Context, pg *sql.DB, f PricingRuleOptionFilters) ([]PricingParameterRuleRow, error) { + where, args := pricingParameterFilterSQL(f) + + rows, err := pg.QueryContext(ctx, ` +SELECT + p.id, + p.scope_key, + p.askili_yan, + p.kategori, + p.urun_ilk_grubu, + p.urun_ana_grubu, + p.urun_alt_grubu, + p.icerik, + p.marka, + p.brand_code, + p.brand_group_sec, + COALESCE(r.id::text, ''), + COALESCE(r.is_active, TRUE), + + COALESCE(tx.base_mult, 0)::float8, + COALESCE(tx.m1, 0)::float8, + COALESCE(tx.m2, 0)::float8, + COALESCE(tx.m3, 0)::float8, + COALESCE(tx.m4, 0)::float8, + COALESCE(tx.m5, 0)::float8, + COALESCE(tx.m6, 0)::float8, + COALESCE(tr.step, 0)::float8, + + COALESCE(ux.base_mult, 0)::float8, + COALESCE(ux.m1, 0)::float8, + COALESCE(ux.m2, 0)::float8, + COALESCE(ux.m3, 0)::float8, + COALESCE(ux.m4, 0)::float8, + COALESCE(ux.m5, 0)::float8, + COALESCE(ux.m6, 0)::float8, + COALESCE(ur.step, 0)::float8, + + COALESCE(ex.base_mult, 0)::float8, + COALESCE(ex.m1, 0)::float8, + COALESCE(ex.m2, 0)::float8, + COALESCE(ex.m3, 0)::float8, + COALESCE(ex.m4, 0)::float8, + COALESCE(ex.m5, 0)::float8, + COALESCE(ex.m6, 0)::float8, + COALESCE(er.step, 0)::float8 +FROM mk_urunpricingprmtr p +LEFT JOIN LATERAL ( + SELECT latest_rule.* + FROM mk_pricing_rule latest_rule + WHERE latest_rule.pricing_parameter_id = p.id + ORDER BY latest_rule.created_at DESC, latest_rule.updated_at DESC, latest_rule.id DESC + LIMIT 1 +) r ON TRUE +LEFT JOIN mk_pricex tx ON tx.rule_id = r.id AND tx.currency='TRY' +LEFT JOIN mk_pricex ux ON ux.rule_id = r.id AND ux.currency='USD' +LEFT JOIN mk_pricex ex ON ex.rule_id = r.id AND ex.currency='EUR' +LEFT JOIN mk_priceroll tr ON tr.rule_id = r.id AND tr.currency='TRY' +LEFT JOIN mk_priceroll ur ON ur.rule_id = r.id AND ur.currency='USD' +LEFT JOIN mk_priceroll er ON er.rule_id = r.id AND er.currency='EUR' +WHERE `+strings.Join(where, " AND ")+` +ORDER BY + p.urun_ilk_grubu, + p.urun_ana_grubu, + p.urun_alt_grubu, + p.marka, + p.brand_code, + p.id +`, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]PricingParameterRuleRow, 0, 1024) + for rows.Next() { + var item PricingParameterRuleRow + rule := PricingRuleRow{} + if err := rows.Scan( + &item.PricingParameterID, + &item.ScopeKey, + &item.AskiliYan, + &item.Kategori, + &item.UrunIlkGrubu, + &item.UrunAnaGrubu, + &item.UrunAltGrubu, + &item.Icerik, + &item.Marka, + &item.BrandCode, + &item.BrandGroupSec, + &rule.ID, + &rule.IsActive, + &rule.TryBase, &rule.Try1, &rule.Try2, &rule.Try3, &rule.Try4, &rule.Try5, &rule.Try6, &rule.TryStep, + &rule.UsdBase, &rule.Usd1, &rule.Usd2, &rule.Usd3, &rule.Usd4, &rule.Usd5, &rule.Usd6, &rule.UsdStep, + &rule.EurBase, &rule.Eur1, &rule.Eur2, &rule.Eur3, &rule.Eur4, &rule.Eur5, &rule.Eur6, &rule.EurStep, + ); err != nil { + return nil, err + } + rule.PricingParameterID = item.PricingParameterID + rule.AskiliYan = pricingParameterScopeValue(item.AskiliYan) + rule.Kategori = pricingParameterScopeValue(item.Kategori) + rule.UrunIlkGrubu = pricingParameterScopeValue(item.UrunIlkGrubu) + rule.UrunAnaGrubu = pricingParameterScopeValue(item.UrunAnaGrubu) + rule.UrunAltGrubu = pricingParameterScopeValue(item.UrunAltGrubu) + rule.Icerik = pricingParameterScopeValue(item.Icerik) + rule.Karisim = nil + rule.Marka = pricingParameterScopeValue(item.Marka) + rule.BrandCode = pricingParameterScopeValue(item.BrandCode) + rule.BrandGroupSec = pricingParameterScopeValue(item.BrandGroupSec) + item.HasRule = strings.TrimSpace(rule.ID) != "" + if item.HasRule { + item.Rule = &rule + } + out = append(out, item) + } + return out, rows.Err() +} + +func pricingParameterFilterSQL(f PricingRuleOptionFilters) ([]string, []any) { + type filter struct { + Column string + Values []string + } + filters := []filter{ + {"p.askili_yan", f.AskiliYan}, + {"p.kategori", f.Kategori}, + {"p.urun_ilk_grubu", f.UrunIlkGrubu}, + {"p.urun_ana_grubu", f.UrunAnaGrubu}, + {"p.urun_alt_grubu", f.UrunAltGrubu}, + {"p.icerik", f.Icerik}, + {"p.marka", f.Marka}, + {"p.brand_code", f.BrandCode}, + {"p.brand_group_sec", f.BrandGroupSec}, + } + where := []string{"p.is_active=TRUE"} + args := make([]any, 0, len(filters)) + for _, item := range filters { + values := normalizeTextList(item.Values) + if len(values) == 0 { + continue + } + args = append(args, pq.Array(values)) + where = append(where, item.Column+fmt.Sprintf(" = ANY($%d::text[])", len(args))) + } + return where, args +} diff --git a/svc/queries/pricing_rules.go b/svc/queries/pricing_rules.go new file mode 100644 index 0000000..0ebbadb --- /dev/null +++ b/svc/queries/pricing_rules.go @@ -0,0 +1,513 @@ +package queries + +import ( + "context" + "database/sql" + "fmt" + "strconv" + "strings" + + "github.com/lib/pq" +) + +// Rule tables: +// - mk_pricing_rule: the "scope" (filters) to which multipliers/roundings apply. +// - mk_pricex: per-currency multipliers (base + 1..6). +// - mk_priceroll: per-currency rounding step (ceil to step). + +func EnsurePricingRuleTables(pg *sql.DB) error { + stmts := []string{ + ` +CREATE TABLE IF NOT EXISTS mk_pricing_rule ( + id UUID PRIMARY KEY DEFAULT gen_random_uuid(), + + askili_yan TEXT[] NOT NULL DEFAULT '{}'::text[], + kategori TEXT[] NOT NULL DEFAULT '{}'::text[], + urun_ilk_grubu TEXT[] NOT NULL DEFAULT '{}'::text[], + urun_ana_grubu TEXT[] NOT NULL DEFAULT '{}'::text[], + urun_alt_grubu TEXT[] NOT NULL DEFAULT '{}'::text[], + icerik TEXT[] NOT NULL DEFAULT '{}'::text[], + karisim TEXT[] NOT NULL DEFAULT '{}'::text[], + marka TEXT[] NOT NULL DEFAULT '{}'::text[], + brand_code TEXT[] NOT NULL DEFAULT '{}'::text[], + brand_group TEXT[] NOT NULL DEFAULT '{}'::text[], + + is_active BOOLEAN NOT NULL DEFAULT TRUE, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now() +)`, + `CREATE INDEX IF NOT EXISTS ix_mk_pricing_rule_active ON mk_pricing_rule (is_active)`, + ` +CREATE TABLE IF NOT EXISTS mk_pricex ( + rule_id UUID NOT NULL REFERENCES mk_pricing_rule(id) ON DELETE CASCADE, + currency TEXT NOT NULL CHECK (currency IN ('TRY','USD','EUR')), + base_mult NUMERIC(18,6) NOT NULL DEFAULT 0, + m1 NUMERIC(18,6) NOT NULL DEFAULT 0, + m2 NUMERIC(18,6) NOT NULL DEFAULT 0, + m3 NUMERIC(18,6) NOT NULL DEFAULT 0, + m4 NUMERIC(18,6) NOT NULL DEFAULT 0, + m5 NUMERIC(18,6) NOT NULL DEFAULT 0, + m6 NUMERIC(18,6) NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (rule_id, currency) +)`, + `CREATE INDEX IF NOT EXISTS ix_mk_pricex_currency ON mk_pricex (currency)`, + ` +CREATE TABLE IF NOT EXISTS mk_priceroll ( + rule_id UUID NOT NULL REFERENCES mk_pricing_rule(id) ON DELETE CASCADE, + currency TEXT NOT NULL CHECK (currency IN ('TRY','USD','EUR')), + step NUMERIC(18,6) NOT NULL DEFAULT 0, + created_at TIMESTAMPTZ NOT NULL DEFAULT now(), + updated_at TIMESTAMPTZ NOT NULL DEFAULT now(), + PRIMARY KEY (rule_id, currency) + )`, + `CREATE INDEX IF NOT EXISTS ix_mk_priceroll_currency ON mk_priceroll (currency)`, + } + for _, s := range stmts { + if _, err := pg.Exec(s); err != nil { + return err + } + } + return nil +} + +type PricingRuleRow struct { + ID string `json:"id"` + PricingParameterID int64 `json:"pricing_parameter_id"` + + AskiliYan []string `json:"askili_yan"` + Kategori []string `json:"kategori"` + UrunIlkGrubu []string `json:"urun_ilk_grubu"` + UrunAnaGrubu []string `json:"urun_ana_grubu"` + UrunAltGrubu []string `json:"urun_alt_grubu"` + Icerik []string `json:"icerik"` + Karisim []string `json:"karisim"` + Marka []string `json:"marka"` + BrandCode []string `json:"brand_code"` + BrandGroupSec []string `json:"brand_group"` + + IsActive bool `json:"is_active"` + + // multipliers/rolls are per currency + TryBase float64 `json:"try_base"` + Try1 float64 `json:"try1"` + Try2 float64 `json:"try2"` + Try3 float64 `json:"try3"` + Try4 float64 `json:"try4"` + Try5 float64 `json:"try5"` + Try6 float64 `json:"try6"` + TryStep float64 `json:"try_step"` + + UsdBase float64 `json:"usd_base"` + Usd1 float64 `json:"usd1"` + Usd2 float64 `json:"usd2"` + Usd3 float64 `json:"usd3"` + Usd4 float64 `json:"usd4"` + Usd5 float64 `json:"usd5"` + Usd6 float64 `json:"usd6"` + UsdStep float64 `json:"usd_step"` + + EurBase float64 `json:"eur_base"` + Eur1 float64 `json:"eur1"` + Eur2 float64 `json:"eur2"` + Eur3 float64 `json:"eur3"` + Eur4 float64 `json:"eur4"` + Eur5 float64 `json:"eur5"` + Eur6 float64 `json:"eur6"` + EurStep float64 `json:"eur_step"` +} + +type PricingRuleSaveItem struct { + ID string `json:"id"` + PricingParameterID int64 `json:"pricing_parameter_id"` + + AskiliYan []string `json:"askili_yan"` + Kategori []string `json:"kategori"` + UrunIlkGrubu []string `json:"urun_ilk_grubu"` + UrunAnaGrubu []string `json:"urun_ana_grubu"` + UrunAltGrubu []string `json:"urun_alt_grubu"` + Icerik []string `json:"icerik"` + Karisim []string `json:"karisim"` + Marka []string `json:"marka"` + BrandCode []string `json:"brand_code"` + BrandGroupSec []string `json:"brand_group"` + + IsActive bool `json:"is_active"` + + TryBase float64 `json:"try_base"` + Try1 float64 `json:"try1"` + Try2 float64 `json:"try2"` + Try3 float64 `json:"try3"` + Try4 float64 `json:"try4"` + Try5 float64 `json:"try5"` + Try6 float64 `json:"try6"` + TryStep float64 `json:"try_step"` + + UsdBase float64 `json:"usd_base"` + Usd1 float64 `json:"usd1"` + Usd2 float64 `json:"usd2"` + Usd3 float64 `json:"usd3"` + Usd4 float64 `json:"usd4"` + Usd5 float64 `json:"usd5"` + Usd6 float64 `json:"usd6"` + UsdStep float64 `json:"usd_step"` + + EurBase float64 `json:"eur_base"` + Eur1 float64 `json:"eur1"` + Eur2 float64 `json:"eur2"` + Eur3 float64 `json:"eur3"` + Eur4 float64 `json:"eur4"` + Eur5 float64 `json:"eur5"` + Eur6 float64 `json:"eur6"` + EurStep float64 `json:"eur_step"` +} + +func ListPricingRules(ctx context.Context, pg *sql.DB) ([]PricingRuleRow, error) { + // Use LEFT joins so newly inserted rules show defaults. + q := ` +SELECT + r.id, + COALESCE(r.pricing_parameter_id, 0), + r.askili_yan, + r.kategori, + r.urun_ilk_grubu, + r.urun_ana_grubu, + r.urun_alt_grubu, + r.icerik, + r.karisim, + r.marka, + r.brand_code, + r.brand_group, + r.is_active, + + COALESCE(tx.base_mult, 0)::float8 AS try_base, + COALESCE(tx.m1, 0)::float8 AS try1, + COALESCE(tx.m2, 0)::float8 AS try2, + COALESCE(tx.m3, 0)::float8 AS try3, + COALESCE(tx.m4, 0)::float8 AS try4, + COALESCE(tx.m5, 0)::float8 AS try5, + COALESCE(tx.m6, 0)::float8 AS try6, + COALESCE(tr.step, 0)::float8 AS try_step, + + COALESCE(ux.base_mult, 0)::float8 AS usd_base, + COALESCE(ux.m1, 0)::float8 AS usd1, + COALESCE(ux.m2, 0)::float8 AS usd2, + COALESCE(ux.m3, 0)::float8 AS usd3, + COALESCE(ux.m4, 0)::float8 AS usd4, + COALESCE(ux.m5, 0)::float8 AS usd5, + COALESCE(ux.m6, 0)::float8 AS usd6, + COALESCE(ur.step, 0)::float8 AS usd_step, + + COALESCE(ex.base_mult, 0)::float8 AS eur_base, + COALESCE(ex.m1, 0)::float8 AS eur1, + COALESCE(ex.m2, 0)::float8 AS eur2, + COALESCE(ex.m3, 0)::float8 AS eur3, + COALESCE(ex.m4, 0)::float8 AS eur4, + COALESCE(ex.m5, 0)::float8 AS eur5, + COALESCE(ex.m6, 0)::float8 AS eur6, + COALESCE(er.step, 0)::float8 AS eur_step +FROM mk_pricing_rule r +LEFT JOIN mk_pricex tx ON tx.rule_id = r.id AND tx.currency='TRY' +LEFT JOIN mk_pricex ux ON ux.rule_id = r.id AND ux.currency='USD' +LEFT JOIN mk_pricex ex ON ex.rule_id = r.id AND ex.currency='EUR' +LEFT JOIN mk_priceroll tr ON tr.rule_id = r.id AND tr.currency='TRY' +LEFT JOIN mk_priceroll ur ON ur.rule_id = r.id AND ur.currency='USD' +LEFT JOIN mk_priceroll er ON er.rule_id = r.id AND er.currency='EUR' +ORDER BY r.created_at DESC; +` + rows, err := pg.QueryContext(ctx, q) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]PricingRuleRow, 0, 256) + for rows.Next() { + var r PricingRuleRow + if err := rows.Scan( + &r.ID, + &r.PricingParameterID, + pq.Array(&r.AskiliYan), + pq.Array(&r.Kategori), + pq.Array(&r.UrunIlkGrubu), + pq.Array(&r.UrunAnaGrubu), + pq.Array(&r.UrunAltGrubu), + pq.Array(&r.Icerik), + pq.Array(&r.Karisim), + pq.Array(&r.Marka), + pq.Array(&r.BrandCode), + pq.Array(&r.BrandGroupSec), + &r.IsActive, + + &r.TryBase, &r.Try1, &r.Try2, &r.Try3, &r.Try4, &r.Try5, &r.Try6, &r.TryStep, + &r.UsdBase, &r.Usd1, &r.Usd2, &r.Usd3, &r.Usd4, &r.Usd5, &r.Usd6, &r.UsdStep, + &r.EurBase, &r.Eur1, &r.Eur2, &r.Eur3, &r.Eur4, &r.Eur5, &r.Eur6, &r.EurStep, + ); err != nil { + return nil, err + } + out = append(out, r) + } + return out, rows.Err() +} + +func normalizeTextList(in []string) []string { + out := make([]string, 0, len(in)) + seen := map[string]struct{}{} + for _, v := range in { + v = strings.TrimSpace(v) + if v == "" { + continue + } + if _, ok := seen[v]; ok { + continue + } + seen[v] = struct{}{} + out = append(out, v) + } + return out +} + +// UpsertPricingRule persists rule scope + per-currency multipliers/roundings. +// Parameter-backed worksheet saves append a new rule version so older prices +// remain queryable. Legacy rules without a parameter id keep update behavior. +func UpsertPricingRule(ctx context.Context, tx *sql.Tx, item PricingRuleSaveItem) (string, error) { + if item.PricingParameterID > 0 { + versionedParameterID, err := VersionPricingParameterForRule(ctx, tx, item.PricingParameterID) + if err != nil { + return "", err + } + item.PricingParameterID = versionedParameterID + } + if err := FillPricingRuleScopeFromParameter(ctx, tx, &item); err != nil { + return "", err + } + item.AskiliYan = normalizeTextList(item.AskiliYan) + item.Kategori = normalizeTextList(item.Kategori) + item.UrunIlkGrubu = normalizeTextList(item.UrunIlkGrubu) + item.UrunAnaGrubu = normalizeTextList(item.UrunAnaGrubu) + item.UrunAltGrubu = normalizeTextList(item.UrunAltGrubu) + item.Icerik = normalizeTextList(item.Icerik) + item.Karisim = normalizeTextList(item.Karisim) + item.Marka = normalizeTextList(item.Marka) + item.BrandCode = normalizeTextList(item.BrandCode) + item.BrandGroupSec = normalizeTextList(item.BrandGroupSec) + + id := strings.TrimSpace(item.ID) + if item.PricingParameterID > 0 { + id = "" + } + if id == "" { + // create + if err := tx.QueryRowContext(ctx, ` +INSERT INTO mk_pricing_rule ( + pricing_parameter_id, + askili_yan,kategori,urun_ilk_grubu,urun_ana_grubu,urun_alt_grubu, + icerik,karisim,marka,brand_code,brand_group,is_active,created_at,updated_at +) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,$10,$11,$12,now(),now()) +RETURNING id +`, nullablePricingParameterID(item.PricingParameterID), pq.Array(item.AskiliYan), pq.Array(item.Kategori), pq.Array(item.UrunIlkGrubu), pq.Array(item.UrunAnaGrubu), pq.Array(item.UrunAltGrubu), + pq.Array(item.Icerik), pq.Array(item.Karisim), pq.Array(item.Marka), pq.Array(item.BrandCode), pq.Array(item.BrandGroupSec), + item.IsActive, + ).Scan(&id); err != nil { + return "", err + } + } else { + if _, err := tx.ExecContext(ctx, ` +UPDATE mk_pricing_rule SET + pricing_parameter_id=$2, + askili_yan=$3, + kategori=$4, + urun_ilk_grubu=$5, + urun_ana_grubu=$6, + urun_alt_grubu=$7, + icerik=$8, + karisim=$9, + marka=$10, + brand_code=$11, + brand_group=$12, + is_active=$13, + updated_at=now() +WHERE id=$1 +`, id, + nullablePricingParameterID(item.PricingParameterID), + pq.Array(item.AskiliYan), pq.Array(item.Kategori), pq.Array(item.UrunIlkGrubu), pq.Array(item.UrunAnaGrubu), pq.Array(item.UrunAltGrubu), + pq.Array(item.Icerik), pq.Array(item.Karisim), pq.Array(item.Marka), pq.Array(item.BrandCode), pq.Array(item.BrandGroupSec), + item.IsActive, + ); err != nil { + return "", err + } + } + + // multipliers upsert helper + upsertX := func(cur string, base, m1, m2, m3, m4, m5, m6 float64) error { + _, err := tx.ExecContext(ctx, ` +INSERT INTO mk_pricex (rule_id, currency, base_mult, m1, m2, m3, m4, m5, m6, created_at, updated_at) +VALUES ($1,$2,$3,$4,$5,$6,$7,$8,$9,now(),now()) +ON CONFLICT (rule_id, currency) DO UPDATE SET + base_mult=EXCLUDED.base_mult, + m1=EXCLUDED.m1, + m2=EXCLUDED.m2, + m3=EXCLUDED.m3, + m4=EXCLUDED.m4, + m5=EXCLUDED.m5, + m6=EXCLUDED.m6, + updated_at=now() +`, id, cur, base, m1, m2, m3, m4, m5, m6) + return err + } + upsertRoll := func(cur string, step float64) error { + _, err := tx.ExecContext(ctx, ` +INSERT INTO mk_priceroll (rule_id, currency, step, created_at, updated_at) +VALUES ($1,$2,$3,now(),now()) +ON CONFLICT (rule_id, currency) DO UPDATE SET + step=EXCLUDED.step, + updated_at=now() +`, id, cur, step) + return err + } + + if err := upsertX("TRY", item.TryBase, item.Try1, item.Try2, item.Try3, item.Try4, item.Try5, item.Try6); err != nil { + return "", err + } + if err := upsertRoll("TRY", item.TryStep); err != nil { + return "", err + } + if err := upsertX("USD", item.UsdBase, item.Usd1, item.Usd2, item.Usd3, item.Usd4, item.Usd5, item.Usd6); err != nil { + return "", err + } + if err := upsertRoll("USD", item.UsdStep); err != nil { + return "", err + } + if err := upsertX("EUR", item.EurBase, item.Eur1, item.Eur2, item.Eur3, item.Eur4, item.Eur5, item.Eur6); err != nil { + return "", err + } + if err := upsertRoll("EUR", item.EurStep); err != nil { + return "", err + } + + return id, nil +} + +func nullablePricingParameterID(id int64) any { + if id <= 0 { + return nil + } + return id +} + +type PricingRuleOptionFilters struct { + AskiliYan []string + Kategori []string + UrunIlkGrubu []string + UrunAnaGrubu []string + UrunAltGrubu []string + Icerik []string + Marka []string + BrandCode []string + BrandGroupSec []string +} + +// ListPricingRuleDistinctOptions returns distinct values for the requested field, applying cascade filters. +// Source is MSSQL ProductFilterWithDescription('TR'). +func ListPricingRuleDistinctOptions(ctx context.Context, mssql *sql.DB, field string, f PricingRuleOptionFilters, limit int) ([]string, error) { + field = strings.TrimSpace(field) + if limit <= 0 { + limit = 500 + } + + // Map API field -> MSSQL expression (TR descriptions + raw codes where needed) + type fieldExpr struct { + Expr string + // For BrandGroupSec we need to compute from Postgres later; for now it comes from ProductPricing list, + // so we only expose BrandCode/Marka cascades from MSSQL. + } + fieldMap := map[string]string{ + "askili_yan": "COALESCE(LTRIM(RTRIM(ProductAtt45Desc)), '')", + "kategori": "COALESCE(LTRIM(RTRIM(ProductAtt44Desc)), '')", + "urun_ilk_grubu": "COALESCE(LTRIM(RTRIM(ProductAtt42Desc)), '')", + "urun_ana_grubu": "COALESCE(LTRIM(RTRIM(ProductAtt01Desc)), '')", + "urun_alt_grubu": "COALESCE(LTRIM(RTRIM(ProductAtt02Desc)), '')", + "icerik": "COALESCE(LTRIM(RTRIM(ProductAtt41Desc)), '')", + "marka": "COALESCE(LTRIM(RTRIM(ProductAtt10Desc)), '')", + "brand_code": "COALESCE(LTRIM(RTRIM(ProductAtt10)), '')", + // "brand_group" is not MSSQL-backed (comes from mk_brandgrpmatch); handled later. + } + expr, ok := fieldMap[field] + if !ok { + return nil, fmt.Errorf("invalid field") + } + + // Build WHERE with OR lists like other endpoints + paramIndex := 1 + args := make([]any, 0, 64) + nextParam := func() string { + p := "@p" + strconv.Itoa(paramIndex) + paramIndex++ + return p + } + whereParts := []string{ + "ProductAtt42 IN ('SERI', 'AKSESUAR')", + "IsBlocked = 0", + "LEN(LTRIM(RTRIM(ProductCode))) = 13", + } + addIn := func(expr string, values []string) { + clean := make([]string, 0, len(values)) + for _, v := range values { + v = strings.TrimSpace(v) + if v != "" { + clean = append(clean, v) + } + } + if len(clean) == 0 { + return + } + ors := make([]string, 0, len(clean)) + for _, v := range clean { + p := nextParam() + ors = append(ors, expr+" = "+p) + args = append(args, v) + } + whereParts = append(whereParts, "("+strings.Join(ors, " OR ")+")") + } + + addIn(fieldMap["askili_yan"], f.AskiliYan) + addIn(fieldMap["kategori"], f.Kategori) + addIn(fieldMap["urun_ilk_grubu"], f.UrunIlkGrubu) + addIn(fieldMap["urun_ana_grubu"], f.UrunAnaGrubu) + addIn(fieldMap["urun_alt_grubu"], f.UrunAltGrubu) + addIn(fieldMap["icerik"], f.Icerik) + addIn(fieldMap["marka"], f.Marka) + addIn(fieldMap["brand_code"], f.BrandCode) + + whereSQL := strings.Join(whereParts, " AND ") + q := ` +SELECT TOP (` + strconv.Itoa(limit) + `) + v +FROM ( + SELECT DISTINCT ` + expr + ` AS v + FROM ProductFilterWithDescription('TR') + WHERE ` + whereSQL + ` +) t +WHERE ISNULL(LTRIM(RTRIM(v)), '') <> '' +ORDER BY v; +` + rows, err := mssql.QueryContext(ctx, q, args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]string, 0, limit) + for rows.Next() { + var v string + if err := rows.Scan(&v); err != nil { + return nil, err + } + v = strings.TrimSpace(v) + if v != "" { + out = append(out, v) + } + } + return out, rows.Err() +} diff --git a/svc/queries/product_pricing.go b/svc/queries/product_pricing.go index be9ccc4..1aaac7c 100644 --- a/svc/queries/product_pricing.go +++ b/svc/queries/product_pricing.go @@ -10,6 +10,8 @@ import ( "strconv" "strings" "time" + + "github.com/lib/pq" ) type ProductPricingFilters struct { @@ -34,7 +36,7 @@ type ProductPricingPage struct { Limit int } -func GetProductPricingPage(ctx context.Context, page int, limit int, filters ProductPricingFilters) (ProductPricingPage, error) { +func GetProductPricingPage(ctx context.Context, page int, limit int, filters ProductPricingFilters, includeTotal bool, sortBy string, descending bool) (ProductPricingPage, error) { result := ProductPricingPage{ Rows: []models.ProductPricing{}, TotalCount: 0, @@ -114,34 +116,53 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro } whereSQL := strings.Join(whereParts, " AND ") - countQuery := ` - SELECT COUNT(DISTINCT LTRIM(RTRIM(ProductCode))) - FROM ProductFilterWithDescription('TR') - WHERE ` + whereSQL + `; - ` - var totalCount int - if err := db.MssqlDB.QueryRowContext(ctx, countQuery, args...).Scan(&totalCount); err != nil { - return result, err - } - result.TotalCount = totalCount - if totalCount == 0 { + if includeTotal { + countQuery := ` + SELECT COUNT(DISTINCT LTRIM(RTRIM(ProductCode))) + FROM ProductFilterWithDescription('TR') + WHERE ` + whereSQL + `; + ` + var totalCount int + if err := db.MssqlDB.QueryRowContext(ctx, countQuery, args...).Scan(&totalCount); err != nil { + return result, err + } + result.TotalCount = totalCount + if totalCount == 0 { + result.TotalPages = 0 + result.Page = 1 + return result, nil + } + totalPages := int(math.Ceil(float64(totalCount) / float64(limit))) + if totalPages <= 0 { + totalPages = 1 + } + if page > totalPages { + page = totalPages + offset = (page - 1) * limit + } + result.Page = page + result.Limit = limit + result.TotalPages = totalPages + } else { + // Skip COUNT(*) for performance; client will infer hasMore from page size. + result.TotalCount = 0 result.TotalPages = 0 - result.Page = 1 - return result, nil + result.Page = page + result.Limit = limit } - totalPages := int(math.Ceil(float64(totalCount) / float64(limit))) - if totalPages <= 0 { - totalPages = 1 - } - if page > totalPages { - page = totalPages - offset = (page - 1) * limit - } - result.Page = page - result.Limit = limit - result.TotalPages = totalPages // Stage 1: fetch only paged products first (fast path). + sortBy = strings.TrimSpace(sortBy) + orderDir := "DESC" + if !descending { + orderDir = "ASC" + } + // Only allow a small safe list. + orderExpr := "CAST(ROUND(ISNULL(sb.InventoryQty1, 0), 2) AS DECIMAL(18, 2))" + if sortBy == "productCode" { + orderExpr = "rc.ProductCode" + orderDir = "ASC" + } productQuery := ` IF OBJECT_ID('tempdb..#req_codes') IS NOT NULL DROP TABLE #req_codes; IF OBJECT_ID('tempdb..#stock_base') IS NOT NULL DROP TABLE #stock_base; @@ -156,7 +177,8 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro MAX(f.UrunAltGrubu) AS UrunAltGrubu, MAX(f.Icerik) AS Icerik, MAX(f.Karisim) AS Karisim, - MAX(f.Marka) AS Marka + MAX(f.Marka) AS Marka, + MAX(f.BrandCode) AS BrandCode INTO #req_codes FROM ( SELECT @@ -169,7 +191,8 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro COALESCE(LTRIM(RTRIM(ProductAtt02Desc)), '') AS UrunAltGrubu, COALESCE(LTRIM(RTRIM(ProductAtt41Desc)), '') AS Icerik, COALESCE(LTRIM(RTRIM(ProductAtt29Desc)), '') AS Karisim, - COALESCE(LTRIM(RTRIM(ProductAtt10Desc)), '') AS Marka + COALESCE(LTRIM(RTRIM(ProductAtt10Desc)), '') AS Marka, + COALESCE(LTRIM(RTRIM(ProductAtt10)), '') AS BrandCode FROM ProductFilterWithDescription('TR') WHERE ` + whereSQL + ` ) f @@ -200,12 +223,13 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro rc.UrunAltGrubu, rc.Icerik, rc.Karisim, - rc.Marka + rc.Marka, + rc.BrandCode FROM #req_codes rc LEFT JOIN #stock_base sb ON sb.ItemCode = rc.ProductCode ORDER BY - CAST(ROUND(ISNULL(sb.InventoryQty1, 0), 2) AS DECIMAL(18, 2)) DESC, + ` + orderExpr + ` ` + orderDir + `, rc.ProductCode ASC OFFSET ` + strconv.Itoa(offset) + ` ROWS FETCH NEXT ` + strconv.Itoa(limit) + ` ROWS ONLY; @@ -252,6 +276,7 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro &item.Icerik, &item.Karisim, &item.Marka, + &item.BrandCode, ); err != nil { return result, err } @@ -284,6 +309,39 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro FROM (VALUES ` + strings.Join(valueRows, ",") + `) v(ProductCode) WHERE LEN(LTRIM(RTRIM(v.ProductCode))) > 0 ), + latest_pricelist_line AS ( + -- Base prices from Nebim V3 price lists (trPriceListLine). + -- Pick the latest record per (ItemCode, Currency) using ValidDate/ValidTime, then LastUpdatedDate. + SELECT + LTRIM(RTRIM(p.ItemCode)) AS ItemCode, + LTRIM(RTRIM(p.DocCurrencyCode)) AS DocCurrencyCode, + CAST(p.Price AS DECIMAL(18, 2)) AS Price, + ROW_NUMBER() OVER ( + PARTITION BY LTRIM(RTRIM(p.ItemCode)), LTRIM(RTRIM(p.DocCurrencyCode)) + ORDER BY p.ValidDate DESC, p.ValidTime DESC, p.LastUpdatedDate DESC + ) AS rn + FROM dbo.trPriceListLine p WITH(NOLOCK) + INNER JOIN req_codes rc + ON rc.ProductCode = LTRIM(RTRIM(p.ItemCode)) + WHERE p.ItemTypeCode = 1 + AND ISNULL(p.IsDisabled, 0) = 0 + AND LTRIM(RTRIM(p.DocCurrencyCode)) IN ('USD', 'TRY') + AND ( + (LTRIM(RTRIM(p.DocCurrencyCode)) = 'USD' AND LTRIM(RTRIM(p.PriceGroupCode)) = 'TM-USD') + OR (LTRIM(RTRIM(p.DocCurrencyCode)) = 'TRY' AND LTRIM(RTRIM(p.PriceGroupCode)) = 'TM-TRY') + ) + AND p.Price IS NOT NULL + AND p.Price > 0 + ), + base_prices AS ( + SELECT + ItemCode, + MAX(CASE WHEN DocCurrencyCode = 'USD' THEN Price END) AS BasePriceUsd, + MAX(CASE WHEN DocCurrencyCode = 'TRY' THEN Price END) AS BasePriceTry + FROM latest_pricelist_line + WHERE rn = 1 + GROUP BY ItemCode + ), latest_base_price AS ( SELECT LTRIM(RTRIM(b.ItemCode)) AS ItemCode, @@ -365,6 +423,8 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro SELECT rc.ProductCode, COALESCE(lp.CostPrice, 0) AS CostPrice, + COALESCE(bp.BasePriceUsd, 0) AS BasePriceUsd, + COALESCE(bp.BasePriceTry, 0) AS BasePriceTry, CAST(ROUND( ISNULL(sb.InventoryQty1, 0) - ISNULL(pb.PickingQty1, 0) @@ -372,11 +432,14 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro - ISNULL(db.DispOrderQty1, 0) , 2) AS DECIMAL(18, 2)) AS StockQty, COALESCE(se.StockEntryDate, '') AS StockEntryDate, + '' AS LastCostingDate, COALESCE(lp.LastPricingDate, '') AS LastPricingDate FROM req_codes rc LEFT JOIN latest_base_price lp ON lp.ItemCode = rc.ProductCode AND lp.rn = 1 + LEFT JOIN base_prices bp + ON bp.ItemCode = rc.ProductCode LEFT JOIN stock_entry_dates se ON se.ItemCode = rc.ProductCode LEFT JOIN stock_base sb @@ -397,8 +460,11 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro type metrics struct { CostPrice float64 + BasePriceUsd float64 + BasePriceTry float64 StockQty float64 StockEntryDate string + LastCostingDate string LastPricingDate string } metricsByCode := make(map[string]metrics, len(out)) @@ -410,8 +476,11 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro if err := metricsRows.Scan( &code, &m.CostPrice, + &m.BasePriceUsd, + &m.BasePriceTry, &m.StockQty, &m.StockEntryDate, + &m.LastCostingDate, &m.LastPricingDate, ); err != nil { return result, err @@ -425,12 +494,222 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro for i := range out { if m, ok := metricsByCode[strings.TrimSpace(out[i].ProductCode)]; ok { out[i].CostPrice = m.CostPrice + out[i].BasePriceUsd = m.BasePriceUsd + out[i].BasePriceTry = m.BasePriceTry out[i].StockQty = m.StockQty out[i].StockEntryDate = m.StockEntryDate + out[i].LastCostingDate = m.LastCostingDate out[i].LastPricingDate = m.LastPricingDate } } + // Stage 3: fetch costing date from UretimDB (separate MSSQL catalog). + // Pricing DB may not contain spUrtOnMLMas; do not fail listing on costing query errors. + if uretimDB := db.GetUretimDB(); uretimDB != nil { + costingQuery := ` + WITH req_codes AS ( + SELECT DISTINCT LTRIM(RTRIM(v.ProductCode)) AS ProductCode + FROM (VALUES ` + strings.Join(valueRows, ",") + `) v(ProductCode) + WHERE LEN(LTRIM(RTRIM(v.ProductCode))) > 0 + ) + SELECT + LTRIM(RTRIM(m.UrunKodu)) AS UrunKodu, + CONVERT(VARCHAR(10), MAX(m.Tarihi), 23) AS LastCostingDate + FROM dbo.spUrtOnMLMas m WITH(NOLOCK) + INNER JOIN req_codes rc + ON rc.ProductCode = LTRIM(RTRIM(m.UrunKodu)) + GROUP BY LTRIM(RTRIM(m.UrunKodu)); + ` + costRows, err := uretimDB.QueryContext(ctx, costingQuery, metricArgs...) + if err == nil { + costingByCode := make(map[string]string, len(out)) + for costRows.Next() { + var code, d string + if err := costRows.Scan(&code, &d); err != nil { + _ = costRows.Close() + costRows = nil + break + } + costingByCode[strings.TrimSpace(code)] = strings.TrimSpace(d) + } + if costRows != nil { + _ = costRows.Close() + for i := range out { + if d, ok := costingByCode[strings.TrimSpace(out[i].ProductCode)]; ok && d != "" { + out[i].LastCostingDate = d + } + } + } + } + } + + // Stage 4: fetch latest tier prices (USD1..6, EUR1..6, TRY1..6) from PostgreSQL sdprc/mmitem. + if pg := db.PgDB; pg != nil { + type tierRow struct { + Code string + Grp int + Crn string + Prc float64 + } + tierSQL := ` + WITH ranked AS ( + SELECT + mmitem.code AS code, + sdprc.sdprcgrp_id AS grp, + sdprc.crn AS crn, + COALESCE(sdprc.prc, 0) AS prc, + ROW_NUMBER() OVER ( + PARTITION BY mmitem.code, sdprc.crn, sdprc.sdprcgrp_id + ORDER BY sdprc.zlins_dttm DESC + ) AS rn + FROM sdprc + JOIN mmitem ON mmitem.id = sdprc.mmitem_id + WHERE mmitem.code = ANY($1) + AND sdprc.sdprcgrp_id BETWEEN 1 AND 6 + AND sdprc.crn IN ('USD', 'EUR', 'TRY') + AND sdprc.prc IS NOT NULL + AND sdprc.prc > 0 + ) + SELECT code, grp, crn, prc + FROM ranked + WHERE rn = 1; + ` + rows, err := pg.QueryContext(ctx, tierSQL, pq.Array(codes)) + if err == nil { + defer rows.Close() + type key struct { + Code string + } + tiers := make(map[string]map[string]map[int]float64, len(out)) + for rows.Next() { + var r tierRow + if err := rows.Scan(&r.Code, &r.Grp, &r.Crn, &r.Prc); err != nil { + break + } + r.Code = strings.TrimSpace(r.Code) + r.Crn = strings.TrimSpace(strings.ToUpper(r.Crn)) + if r.Code == "" || r.Grp < 1 || r.Grp > 6 || r.Crn == "" { + continue + } + if _, ok := tiers[r.Code]; !ok { + tiers[r.Code] = map[string]map[int]float64{} + } + if _, ok := tiers[r.Code][r.Crn]; !ok { + tiers[r.Code][r.Crn] = map[int]float64{} + } + tiers[r.Code][r.Crn][r.Grp] = r.Prc + } + + for i := range out { + code := strings.TrimSpace(out[i].ProductCode) + m, ok := tiers[code] + if !ok { + continue + } + apply := func(crn string, grp int, v float64) { + switch crn { + case "USD": + switch grp { + case 1: + out[i].USD1 = v + case 2: + out[i].USD2 = v + case 3: + out[i].USD3 = v + case 4: + out[i].USD4 = v + case 5: + out[i].USD5 = v + case 6: + out[i].USD6 = v + } + case "EUR": + switch grp { + case 1: + out[i].EUR1 = v + case 2: + out[i].EUR2 = v + case 3: + out[i].EUR3 = v + case 4: + out[i].EUR4 = v + case 5: + out[i].EUR5 = v + case 6: + out[i].EUR6 = v + } + case "TRY": + switch grp { + case 1: + out[i].TRY1 = v + case 2: + out[i].TRY2 = v + case 3: + out[i].TRY3 = v + case 4: + out[i].TRY4 = v + case 5: + out[i].TRY5 = v + case 6: + out[i].TRY6 = v + } + } + } + for crn, byGrp := range m { + for grp, v := range byGrp { + apply(crn, grp, v) + } + } + } + } + } + + // Stage 5: brand group (classification) from Postgres mk_brandgrpmatch. + // Show classification result in BrandGroupSec field (read-only in UI). + if pg := db.PgDB; pg != nil { + brandCodes := make([]string, 0, len(out)) + seen := make(map[string]struct{}, len(out)) + for _, it := range out { + code := strings.TrimSpace(it.BrandCode) + if code == "" { + continue + } + if _, ok := seen[code]; ok { + continue + } + seen[code] = struct{}{} + brandCodes = append(brandCodes, code) + } + if len(brandCodes) > 0 { + rows, err := pg.QueryContext(ctx, ` +SELECT + m.brand_code, + COALESCE(g.title, '') AS grp_title +FROM mk_brandgrpmatch m +JOIN mk_brandgrp g ON g.id = m.grp_id +WHERE m.brand_code = ANY($1) +`, pq.Array(brandCodes)) + if err == nil { + defer rows.Close() + grpByBrand := make(map[string]string, len(brandCodes)) + for rows.Next() { + var code, title string + if err := rows.Scan(&code, &title); err != nil { + break + } + grpByBrand[strings.TrimSpace(code)] = strings.TrimSpace(title) + } + for i := range out { + if title, ok := grpByBrand[strings.TrimSpace(out[i].BrandCode)]; ok { + out[i].BrandGroupSec = title + } else { + out[i].BrandGroupSec = "" + } + } + } + } + } + result.Rows = out return result, nil } diff --git a/svc/queries/product_pricing_options.go b/svc/queries/product_pricing_options.go new file mode 100644 index 0000000..c4ad634 --- /dev/null +++ b/svc/queries/product_pricing_options.go @@ -0,0 +1,123 @@ +package queries + +import ( + "bssapp-backend/db" + "context" + "database/sql" + "fmt" + "strings" +) + +// GetProductPricingFilterOptions returns distinct option values for ProductPricing filters. +// This is used to render filter dropdowns without loading the full dataset. +func GetProductPricingFilterOptions(ctx context.Context, field string, q string, limit int, scopeUrunIlkGrubu []string) ([]string, error) { + mssql := db.MssqlDB + if mssql == nil { + return nil, fmt.Errorf("mssql db is nil") + } + field = strings.TrimSpace(field) + q = strings.TrimSpace(q) + if limit <= 0 || limit > 200 { + limit = 120 + } + if len(scopeUrunIlkGrubu) > 3 { + scopeUrunIlkGrubu = scopeUrunIlkGrubu[:3] + } + + // Map UI filter fields -> MSSQL expression in ProductFilterWithDescription('TR') + var expr string + switch field { + case "productCode": + expr = "LTRIM(RTRIM(ProductCode))" + case "brandGroupSelection": + expr = `CASE ABS(CHECKSUM(LTRIM(RTRIM(ProductCode)))) % 3 + WHEN 0 THEN 'MARKA GRUBU A' + WHEN 1 THEN 'MARKA GRUBU B' + ELSE 'MARKA GRUBU C' + END` + case "marka": + expr = "COALESCE(LTRIM(RTRIM(ProductAtt10Desc)), '')" + case "askiliYan": + expr = "COALESCE(LTRIM(RTRIM(ProductAtt45Desc)), '')" + case "kategori": + expr = "COALESCE(LTRIM(RTRIM(ProductAtt44Desc)), '')" + case "urunIlkGrubu": + expr = "COALESCE(LTRIM(RTRIM(ProductAtt42Desc)), '')" + case "urunAnaGrubu": + expr = "COALESCE(LTRIM(RTRIM(ProductAtt01Desc)), '')" + case "urunAltGrubu": + expr = "COALESCE(LTRIM(RTRIM(ProductAtt02Desc)), '')" + case "icerik": + expr = "COALESCE(LTRIM(RTRIM(ProductAtt41Desc)), '')" + case "karisim": + expr = "COALESCE(LTRIM(RTRIM(ProductAtt29Desc)), '')" + default: + return nil, fmt.Errorf("invalid field") + } + + // NOTE: We keep the same base constraints as the listing query. + // q: prefix match to keep it sargable-ish. + args := make([]any, 0, 8) + where := []string{ + "ProductAtt42 IN ('SERI', 'AKSESUAR')", + "IsBlocked = 0", + "LEN(LTRIM(RTRIM(ProductCode))) = 13", + } + if len(scopeUrunIlkGrubu) > 0 && field != "urunIlkGrubu" { + // Cascade scope: allow limiting options by the already selected "Urun Ilk Grubu" (desc). + // We filter by desc value because UI uses desc fields. + placeholders := make([]string, 0, len(scopeUrunIlkGrubu)) + for _, v := range scopeUrunIlkGrubu { + v = strings.TrimSpace(v) + if v == "" { + continue + } + placeholders = append(placeholders, fmt.Sprintf("@p%d", len(args)+1)) + args = append(args, v) + } + if len(placeholders) > 0 { + where = append(where, fmt.Sprintf("COALESCE(LTRIM(RTRIM(ProductAtt42Desc)), '') IN (%s)", strings.Join(placeholders, ", "))) + } + } + if q != "" { + // For productCode, allow contains if user types middle; for others use prefix. + if field == "productCode" { + where = append(where, expr+fmt.Sprintf(" LIKE @p%d", len(args)+1)) + args = append(args, "%"+q+"%") + } else { + where = append(where, expr+fmt.Sprintf(" LIKE @p%d", len(args)+1)) + args = append(args, q+"%") + } + } + whereSQL := strings.Join(where, " AND ") + + sqlText := fmt.Sprintf(` +SELECT TOP (%d) + X.val +FROM ( + SELECT DISTINCT NULLIF(%s, '') AS val + FROM ProductFilterWithDescription('TR') + WHERE %s +) X +WHERE X.val IS NOT NULL +ORDER BY X.val ASC; +`, limit, expr, whereSQL) + + rows, err := mssql.QueryContext(ctx, sqlText, args...) + if err != nil { + return nil, err + } + defer rows.Close() + + out := make([]string, 0, limit) + for rows.Next() { + var v sql.NullString + if err := rows.Scan(&v); err != nil { + return nil, err + } + if s := strings.TrimSpace(v.String); s != "" { + out = append(out, s) + } + } + return out, rows.Err() +} diff --git a/svc/routes/brand_classification.go b/svc/routes/brand_classification.go new file mode 100644 index 0000000..3c72e4a --- /dev/null +++ b/svc/routes/brand_classification.go @@ -0,0 +1,218 @@ +package routes + +import ( + "bssapp-backend/db" + "bssapp-backend/queries" + "bssapp-backend/utils" + "database/sql" + "encoding/json" + "log" + "net/http" + "strings" + + "github.com/gorilla/mux" +) + +type BrandClassificationLookupResponse struct { + Groups []queries.BrandGroupOption `json:"groups"` +} + +type BrandSyncResponse struct { + Upserted int `json:"upserted"` + Deleted int `json:"deleted"` + Total int `json:"total"` +} + +type BrandSetGroupPayload struct { + GroupID int `json:"group_id"` +} + +type BrandBulkItem struct { + BrandCode string `json:"brand_code"` + GroupID int `json:"group_id"` +} + +type BrandBulkPayload struct { + Items []BrandBulkItem `json:"items"` +} + +func GetBrandClassificationLookupsHandler(pg *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if err := queries.EnsureBrandClassificationTables(pg); err != nil { + http.Error(w, "brand tables bootstrap error", http.StatusInternalServerError) + return + } + + traceID := utils.TraceIDFromRequest(r) + ctx := utils.ContextWithTraceID(r.Context(), traceID) + + groups, err := queries.ListBrandGroups(ctx, pg) + if err != nil { + http.Error(w, "brand groups lookup error", http.StatusInternalServerError) + return + } + + _ = json.NewEncoder(w).Encode(BrandClassificationLookupResponse{Groups: groups}) + } +} + +func ListBrandsHandler(pg *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + if err := queries.EnsureBrandClassificationTables(pg); err != nil { + http.Error(w, "brand tables bootstrap error", http.StatusInternalServerError) + return + } + + q := strings.TrimSpace(r.URL.Query().Get("q")) + traceID := utils.TraceIDFromRequest(r) + ctx := utils.ContextWithTraceID(r.Context(), traceID) + + rows, err := queries.ListBrandsWithGroups(ctx, pg, q, 20000) + if err != nil { + http.Error(w, "brand list error", http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(rows) + } +} + +func SyncBrandsFromMSSQLHandler(pg *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + mssql := db.GetDB() + if mssql == nil { + http.Error(w, "mssql connection not available", http.StatusServiceUnavailable) + return + } + + traceID := utils.TraceIDFromRequest(r) + ctx := utils.ContextWithTraceID(r.Context(), traceID) + + res, err := queries.SyncBrandsFromMSSQL(ctx, mssql, pg) + if err != nil { + log.Printf("brand sync error trace=%s err=%v", traceID, err) + http.Error(w, "brand sync error", http.StatusInternalServerError) + return + } + + _ = json.NewEncoder(w).Encode(BrandSyncResponse{ + Upserted: res.Upserted, + Deleted: res.Deleted, + Total: res.Total, + }) + } +} + +func SetBrandGroupHandler(pg *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + if err := queries.EnsureBrandClassificationTables(pg); err != nil { + http.Error(w, "brand tables bootstrap error", http.StatusInternalServerError) + return + } + + brandCode := strings.TrimSpace(mux.Vars(r)["code"]) + if brandCode == "" { + http.Error(w, "invalid brand_code", http.StatusBadRequest) + return + } + + var payload BrandSetGroupPayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, "invalid payload", http.StatusBadRequest) + return + } + if payload.GroupID < 0 || payload.GroupID > 3 { + http.Error(w, "invalid group_id", http.StatusBadRequest) + return + } + + traceID := utils.TraceIDFromRequest(r) + ctx := utils.ContextWithTraceID(r.Context(), traceID) + + tx, err := pg.BeginTx(ctx, nil) + if err != nil { + http.Error(w, "pg transaction start error", http.StatusInternalServerError) + return + } + defer tx.Rollback() + + if err := queries.SetBrandGroup(ctx, tx, brandCode, payload.GroupID); err != nil { + http.Error(w, "brand group save error", http.StatusInternalServerError) + return + } + + if err := tx.Commit(); err != nil { + http.Error(w, "pg transaction commit error", http.StatusInternalServerError) + return + } + + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "brand_code": brandCode, + "group_id": payload.GroupID, + }) + } +} + +func SetBrandGroupsBulkHandler(pg *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + if err := queries.EnsureBrandClassificationTables(pg); err != nil { + http.Error(w, "brand tables bootstrap error", http.StatusInternalServerError) + return + } + + var payload BrandBulkPayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, "invalid payload", http.StatusBadRequest) + return + } + if len(payload.Items) == 0 { + _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "updated": 0}) + return + } + + traceID := utils.TraceIDFromRequest(r) + ctx := utils.ContextWithTraceID(r.Context(), traceID) + + tx, err := pg.BeginTx(ctx, nil) + if err != nil { + http.Error(w, "pg transaction start error", http.StatusInternalServerError) + return + } + defer tx.Rollback() + + updated := 0 + for _, it := range payload.Items { + code := strings.TrimSpace(it.BrandCode) + if code == "" { + continue + } + if it.GroupID < 0 || it.GroupID > 3 { + http.Error(w, "invalid group_id", http.StatusBadRequest) + return + } + if err := queries.SetBrandGroup(ctx, tx, code, it.GroupID); err != nil { + http.Error(w, "brand group save error", http.StatusInternalServerError) + return + } + updated++ + } + + if err := tx.Commit(); err != nil { + http.Error(w, "pg transaction commit error", http.StatusInternalServerError) + return + } + + _ = json.NewEncoder(w).Encode(map[string]any{ + "success": true, + "updated": updated, + }) + } +} diff --git a/svc/routes/pricing_rules.go b/svc/routes/pricing_rules.go new file mode 100644 index 0000000..89efe3a --- /dev/null +++ b/svc/routes/pricing_rules.go @@ -0,0 +1,163 @@ +package routes + +import ( + "bssapp-backend/queries" + "bssapp-backend/utils" + "database/sql" + "encoding/json" + "net/http" + "strconv" + "strings" +) + +// Step-1/2 scope (distinct+cascade) comes from the PostgreSQL parameter cache. +// For now we implement: +// - Postgres tables (bootstrap) +// - List/Save rules (bulk) +// - Options endpoint for cascade (mk_urunpricingprmtr) + +type PricingRuleBulkSavePayload struct { + Items []queries.PricingRuleSaveItem `json:"items"` +} + +func GetPricingRulesHandler(pg *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + traceID := utils.TraceIDFromRequest(r) + ctx := utils.ContextWithTraceID(r.Context(), traceID) + + rows, err := queries.ListPricingRules(ctx, pg) + if err != nil { + http.Error(w, "pricing rules list error", http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(rows) + } +} + +// Very small “bulk upsert” for step-1/2: we only need to persist the multipliers+roundings for now. +// Rules are identified by UUID; new rows can be created via empty id (server generates). +func SavePricingRulesBulkHandler(pg *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + var payload PricingRuleBulkSavePayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + http.Error(w, "invalid payload", http.StatusBadRequest) + return + } + + traceID := utils.TraceIDFromRequest(r) + ctx := utils.ContextWithTraceID(r.Context(), traceID) + + tx, err := pg.BeginTx(ctx, nil) + if err != nil { + http.Error(w, "pg transaction start error", http.StatusInternalServerError) + return + } + defer tx.Rollback() + + updated := 0 + for _, it := range payload.Items { + // Zero means that no rounding rule has been configured yet. + if it.TryStep < 0 || it.UsdStep < 0 || it.EurStep < 0 { + http.Error(w, "invalid rounding step", http.StatusBadRequest) + return + } + id, err := queries.UpsertPricingRule(ctx, tx, it) + if err != nil { + http.Error(w, "pricing rule save error", http.StatusInternalServerError) + return + } + if id != "" { + updated++ + } + } + + if err := tx.Commit(); err != nil { + http.Error(w, "pg transaction commit error", http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(map[string]any{"success": true, "updated": updated}) + } +} + +func GetPricingRuleOptionsHandler(pg *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + field := strings.TrimSpace(r.URL.Query().Get("field")) + if field == "" { + http.Error(w, "missing field", http.StatusBadRequest) + return + } + + limit := 500 + if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { + if n, err := strconv.Atoi(raw); err == nil && n > 0 && n <= 5000 { + limit = n + } + } + + f := pricingRuleFiltersFromRequest(r) + + traceID := utils.TraceIDFromRequest(r) + ctx := utils.ContextWithTraceID(r.Context(), traceID) + + opts, err := queries.ListPricingParameterDistinctOptions(ctx, pg, field, f, limit) + if err != nil { + http.Error(w, "options lookup error", http.StatusInternalServerError) + return + } + + _ = json.NewEncoder(w).Encode(map[string]any{ + "field": field, + "options": opts, + }) + } +} + +func GetPricingParameterRulesHandler(pg *sql.DB) http.HandlerFunc { + return func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json; charset=utf-8") + + traceID := utils.TraceIDFromRequest(r) + ctx := utils.ContextWithTraceID(r.Context(), traceID) + rows, err := queries.ListPricingParameterRules(ctx, pg, pricingRuleFiltersFromRequest(r)) + if err != nil { + http.Error(w, "pricing parameter rules list error", http.StatusInternalServerError) + return + } + _ = json.NewEncoder(w).Encode(rows) + } +} + +func pricingRuleFiltersFromRequest(r *http.Request) queries.PricingRuleOptionFilters { + return queries.PricingRuleOptionFilters{ + AskiliYan: splitCSV(r.URL.Query().Get("askili_yan")), + Kategori: splitCSV(r.URL.Query().Get("kategori")), + UrunIlkGrubu: splitCSV(r.URL.Query().Get("urun_ilk_grubu")), + UrunAnaGrubu: splitCSV(r.URL.Query().Get("urun_ana_grubu")), + UrunAltGrubu: splitCSV(r.URL.Query().Get("urun_alt_grubu")), + Icerik: splitCSV(r.URL.Query().Get("icerik")), + Marka: splitCSV(r.URL.Query().Get("marka")), + BrandCode: splitCSV(r.URL.Query().Get("brand_code")), + BrandGroupSec: splitCSV(r.URL.Query().Get("brand_group")), + } +} + +func splitCSV(raw string) []string { + raw = strings.TrimSpace(raw) + if raw == "" { + return nil + } + parts := strings.Split(raw, ",") + out := make([]string, 0, len(parts)) + for _, p := range parts { + p = strings.TrimSpace(p) + if p != "" { + out = append(out, p) + } + } + return out +} diff --git a/svc/routes/product_pricing.go b/svc/routes/product_pricing.go index 3bedc29..c535159 100644 --- a/svc/routes/product_pricing.go +++ b/svc/routes/product_pricing.go @@ -55,8 +55,30 @@ func GetProductPricingListHandler(w http.ResponseWriter, r *http.Request) { Karisim: splitCSVParam(r.URL.Query().Get("karisim")), Marka: splitCSVParam(r.URL.Query().Get("marka")), } + if len(filters.UrunAnaGrubu) > 3 { + http.Error(w, "Urun Ana Grubu en fazla 3 secilebilir", http.StatusBadRequest) + return + } + includeTotal := true + if raw := strings.TrimSpace(r.URL.Query().Get("include_total")); raw != "" { + if raw == "0" || strings.EqualFold(raw, "false") { + includeTotal = false + } + } + // When primary group filters are present, COUNT(*) is acceptable and improves UX + // (accurate totalCount/totalPages). Force includeTotal on. + if len(filters.UrunIlkGrubu) > 0 || len(filters.UrunAnaGrubu) > 0 { + includeTotal = true + } - pageResult, err := queries.GetProductPricingPage(ctx, page, limit, filters) + sortBy := strings.TrimSpace(r.URL.Query().Get("sort_by")) + desc := true + if raw := strings.TrimSpace(r.URL.Query().Get("desc")); raw != "" { + if raw == "0" || strings.EqualFold(raw, "false") { + desc = false + } + } + pageResult, err := queries.GetProductPricingPage(ctx, page, limit, filters, includeTotal, sortBy, desc) if err != nil { if isPricingTimeoutLike(err, ctx.Err()) { log.Printf( @@ -101,6 +123,94 @@ func GetProductPricingListHandler(w http.ResponseWriter, r *http.Request) { _ = json.NewEncoder(w).Encode(pageResult.Rows) } +// GET /api/pricing/products/options?field=urunAnaGrubu&q=SER&limit=120 +func GetProductPricingFilterOptionsHandler(w http.ResponseWriter, r *http.Request) { + started := time.Now() + traceID := buildPricingTraceID(r) + w.Header().Set("X-Trace-ID", traceID) + + claims, ok := auth.GetClaimsFromContext(r.Context()) + if !ok || claims == nil { + log.Printf("[ProductPricingOptions] trace=%s unauthorized method=%s path=%s", traceID, r.Method, r.URL.Path) + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + ctx, cancel := context.WithTimeout(r.Context(), 12*time.Second) + defer cancel() + + field := strings.TrimSpace(r.URL.Query().Get("field")) + q := strings.TrimSpace(r.URL.Query().Get("q")) + scopeUrunIlkGrubu := splitCSVParam(r.URL.Query().Get("urun_ilk_grubu")) + limit := 120 + if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" { + if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 200 { + limit = parsed + } + } + + items, err := queries.GetProductPricingFilterOptions(ctx, field, q, limit, scopeUrunIlkGrubu) + if err != nil { + if isPricingTimeoutLike(err, ctx.Err()) { + log.Printf( + "[ProductPricingOptions] trace=%s timeout user=%s id=%d field=%s q=%s duration_ms=%d err=%v", + traceID, + claims.Username, + claims.ID, + field, + q, + time.Since(started).Milliseconds(), + err, + ) + http.Error(w, "Urun fiyatlandirma filtre secenekleri zaman asimina ugradi", http.StatusGatewayTimeout) + return + } + log.Printf( + "[ProductPricingOptions] trace=%s query_error user=%s id=%d field=%s q=%s duration_ms=%d err=%v", + traceID, + claims.Username, + claims.ID, + field, + q, + time.Since(started).Milliseconds(), + err, + ) + http.Error(w, "Filtre secenekleri alinamadi: "+err.Error(), http.StatusInternalServerError) + return + } + + type optionItem struct { + Label string `json:"label"` + Value string `json:"value"` + } + resp := struct { + Field string `json:"field"` + Count int `json:"count"` + Items []optionItem `json:"items"` + }{ + Field: field, + Count: len(items), + Items: make([]optionItem, 0, len(items)), + } + for _, v := range items { + resp.Items = append(resp.Items, optionItem{Label: v, Value: v}) + } + + log.Printf( + "[ProductPricingOptions] trace=%s success user=%s id=%d field=%s q=%s count=%d duration_ms=%d", + traceID, + claims.Username, + claims.ID, + field, + q, + len(items), + time.Since(started).Milliseconds(), + ) + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(resp) +} + func buildPricingTraceID(r *http.Request) string { if r != nil { if id := strings.TrimSpace(r.Header.Get("X-Request-ID")); id != "" { diff --git a/svc/routes/role_department_permissions.go b/svc/routes/role_department_permissions.go index 82b0417..00dd384 100644 --- a/svc/routes/role_department_permissions.go +++ b/svc/routes/role_department_permissions.go @@ -27,11 +27,22 @@ type Row struct { } type RoleDeptPermissionSummary struct { - RoleID int `json:"role_id"` - RoleTitle string `json:"role_title"` - DepartmentCode string `json:"department_code"` - DepartmentTitle string `json:"department_title"` - ModuleFlags map[string]bool `json:"module_flags"` + RoleID int `json:"role_id"` + RoleTitle string `json:"role_title"` + DepartmentCode string `json:"department_code"` + DepartmentTitle string `json:"department_title"` + ModuleFlags map[string]bool `json:"module_flags"` + Members []RoleDeptMember `json:"members"` +} + +type RoleDeptMember struct { + ID int64 `json:"id"` + FullName string `json:"full_name"` + Username string `json:"username"` +} + +type AddRoleDeptMemberPayload struct { + UserID int64 `json:"user_id"` } type ModuleLookupOption struct { @@ -132,12 +143,14 @@ func (h *RoleDepartmentPermissionHandler) List(w http.ResponseWriter, r *http.Re for rows.Next() { var item RoleDeptPermissionSummary var rawFlags []byte + var rawMembers []byte if err := rows.Scan( &item.RoleID, &item.RoleTitle, &item.DepartmentCode, &item.DepartmentTitle, &rawFlags, + &rawMembers, ); err != nil { http.Error(w, "scan error", http.StatusInternalServerError) return @@ -150,6 +163,13 @@ func (h *RoleDepartmentPermissionHandler) List(w http.ResponseWriter, r *http.Re return } } + item.Members = make([]RoleDeptMember, 0) + if len(rawMembers) > 0 { + if err := json.Unmarshal(rawMembers, &item.Members); err != nil { + http.Error(w, "members parse error", http.StatusInternalServerError) + return + } + } list = append(list, item) } @@ -294,6 +314,172 @@ func (h *RoleDepartmentPermissionHandler) Save(w http.ResponseWriter, r *http.Re w.Header().Set("Content-Type", "application/json; charset=utf-8") _ = json.NewEncoder(w).Encode(map[string]bool{"success": true}) } + +func (h *RoleDepartmentPermissionHandler) Members(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.GetClaimsFromContext(r.Context()) + if !ok || claims == nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + roleID, deptCode, ok := roleDepartmentFromRequest(w, r) + if !ok { + return + } + + members, err := listRoleDepartmentMembers(h.DB, roleID, deptCode) + if err != nil { + http.Error(w, "members query error", http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(members) +} + +func (h *RoleDepartmentPermissionHandler) AddMember(w http.ResponseWriter, r *http.Request) { + claims, ok := auth.GetClaimsFromContext(r.Context()) + if !ok || claims == nil { + http.Error(w, "unauthorized", http.StatusUnauthorized) + return + } + + roleID, deptCode, ok := roleDepartmentFromRequest(w, r) + if !ok { + return + } + + var payload AddRoleDeptMemberPayload + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil || payload.UserID <= 0 { + http.Error(w, "invalid user_id", http.StatusBadRequest) + return + } + + tx, err := h.DB.BeginTx(r.Context(), nil) + if err != nil { + http.Error(w, "transaction start error", http.StatusInternalServerError) + return + } + defer tx.Rollback() + + var userExists, roleExists, departmentExists bool + if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM mk_dfusr WHERE id=$1 AND is_active=TRUE)`, payload.UserID).Scan(&userExists); err != nil { + http.Error(w, "user lookup error", http.StatusInternalServerError) + return + } + if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM dfrole WHERE id=$1)`, roleID).Scan(&roleExists); err != nil { + http.Error(w, "role lookup error", http.StatusInternalServerError) + return + } + if err := tx.QueryRow(`SELECT EXISTS(SELECT 1 FROM mk_dprt WHERE code=$1 AND is_active=TRUE)`, deptCode).Scan(&departmentExists); err != nil { + http.Error(w, "department lookup error", http.StatusInternalServerError) + return + } + if !userExists || !roleExists || !departmentExists { + http.Error(w, "user, role or department not found", http.StatusBadRequest) + return + } + + if _, err := tx.Exec(` +INSERT INTO dfrole_usr (dfusr_id, dfrole_id) +VALUES ($1, $2) +ON CONFLICT DO NOTHING +`, payload.UserID, roleID); err != nil { + http.Error(w, "user role insert error", http.StatusInternalServerError) + return + } + + if _, err := tx.Exec(` +UPDATE dfusr_dprt ud +SET is_active=TRUE +FROM mk_dprt d +WHERE ud.dfusr_id=$1 + AND ud.dprt_id=d.id + AND d.code=$2 +`, payload.UserID, deptCode); err != nil { + http.Error(w, "user department update error", http.StatusInternalServerError) + return + } + + if _, err := tx.Exec(` +INSERT INTO dfusr_dprt (dfusr_id, dprt_id, is_active) +SELECT $1, d.id, TRUE +FROM mk_dprt d +WHERE d.code=$2 + AND NOT EXISTS ( + SELECT 1 + FROM dfusr_dprt ud + WHERE ud.dfusr_id=$1 + AND ud.dprt_id=d.id + ) +`, payload.UserID, deptCode); err != nil { + http.Error(w, "user department insert error", http.StatusInternalServerError) + return + } + + if err := tx.Commit(); err != nil { + http.Error(w, "transaction commit error", http.StatusInternalServerError) + return + } + + auditlog.Enqueue(r.Context(), auditlog.ActivityLog{ + ActionType: "role_department_member_add", + ActionCategory: "role_permission", + ActionTarget: fmt.Sprintf("/api/roles/%d/departments/%s/members", roleID, deptCode), + Description: "user added to role+department group", + Username: claims.Username, + RoleCode: claims.RoleCode, + DfUsrID: int64(claims.ID), + ChangeAfter: map[string]any{ + "user_id": payload.UserID, + "role_id": roleID, + "department_code": deptCode, + }, + IsSuccess: true, + }) + + members, err := listRoleDepartmentMembers(h.DB, roleID, deptCode) + if err != nil { + http.Error(w, "members query error", http.StatusInternalServerError) + return + } + w.Header().Set("Content-Type", "application/json; charset=utf-8") + _ = json.NewEncoder(w).Encode(members) +} + +func roleDepartmentFromRequest(w http.ResponseWriter, r *http.Request) (int, string, bool) { + vars := mux.Vars(r) + roleID, err := strconv.Atoi(vars["roleId"]) + if err != nil || roleID <= 0 { + http.Error(w, "invalid roleId", http.StatusBadRequest) + return 0, "", false + } + deptCode := strings.TrimSpace(vars["deptCode"]) + if deptCode == "" { + http.Error(w, "invalid deptCode", http.StatusBadRequest) + return 0, "", false + } + return roleID, deptCode, true +} + +func listRoleDepartmentMembers(db *sql.DB, roleID int, deptCode string) ([]RoleDeptMember, error) { + rows, err := db.Query(queries.ListRoleDepartmentMembers, roleID, deptCode) + if err != nil { + return nil, err + } + defer rows.Close() + + members := make([]RoleDeptMember, 0, 16) + for rows.Next() { + var member RoleDeptMember + if err := rows.Scan(&member.ID, &member.FullName, &member.Username); err != nil { + return nil, err + } + members = append(members, member) + } + return members, rows.Err() +} + func GetModuleLookupRoute(db *sql.DB) http.HandlerFunc { return func(w http.ResponseWriter, r *http.Request) { diff --git a/ui/quasar.config.js.temporary.compiled.1779449061737.mjs b/ui/quasar.config.js.temporary.compiled.1780404176831.mjs similarity index 100% rename from ui/quasar.config.js.temporary.compiled.1779449061737.mjs rename to ui/quasar.config.js.temporary.compiled.1780404176831.mjs diff --git a/ui/src/layouts/MainLayout.vue b/ui/src/layouts/MainLayout.vue index c5d37a7..b0d48fb 100644 --- a/ui/src/layouts/MainLayout.vue +++ b/ui/src/layouts/MainLayout.vue @@ -325,37 +325,55 @@ const menuItems = [ ] }, - { - label: 'Fiyatlandırma/Maliyetlendirme', - icon: 'request_quote', - - children: [ - { - label: 'Ürün Fiyatlandırma', - to: '/app/pricing/product-pricing', - permission: 'order:view' - }, - { - label: "Üretim'den Ürün Maliyetlendirme", - to: '/app/pricing/production-product-costing', - permission: 'order:view' - }, - { - label: 'Maliyet Parça Eşleştirme', - to: '/app/pricing/production-product-costing/maliyet-parca-eslestirme', - permission: 'order:view' - }, - { - label: 'Maliyet Varsayilan Miktarlar', - to: '/app/pricing/production-product-costing/default-quantities', - permission: 'order:view' - } - ] - }, - - { - label: 'Sistem', - icon: 'settings', + { + label: 'Ürün Maliyetlendirme', + icon: 'request_quote', + + children: [ + { + label: "Üretim'den Ürün Maliyetlendirme", + to: '/app/costing/production-product-costing', + permission: 'costing:view' + }, + { + label: 'Maliyet Parça Eşleştirme', + to: '/app/costing/production-product-costing/maliyet-parca-eslestirme', + permission: 'costing:view' + }, + { + label: 'Maliyet Varsayilan Miktarlar', + to: '/app/costing/production-product-costing/default-quantities', + permission: 'costing:view' + } + ] + }, + + { + label: 'Ürün Fiyatlandırma', + icon: 'sell', + + children: [ + { + label: 'Fiyatlandırma', + to: '/app/pricing/product-pricing', + permission: 'pricing:view' + }, + { + label: 'Marka Sınıflandırma', + to: '/app/pricing/brand-classification', + permission: 'pricing:view' + }, + { + label: 'Fiyat Çarpan Kuralları', + to: '/app/pricing/pricing-rules', + permission: 'pricing:view' + } + ] + }, + + { + label: 'Sistem', + icon: 'settings', children: [ diff --git a/ui/src/pages/BrandClassification.vue b/ui/src/pages/BrandClassification.vue new file mode 100644 index 0000000..1cc9578 --- /dev/null +++ b/ui/src/pages/BrandClassification.vue @@ -0,0 +1,214 @@ + + + + + diff --git a/ui/src/pages/PricingRules.vue b/ui/src/pages/PricingRules.vue new file mode 100644 index 0000000..df22db7 --- /dev/null +++ b/ui/src/pages/PricingRules.vue @@ -0,0 +1,892 @@ + + + + + diff --git a/ui/src/pages/ProductPricing.vue b/ui/src/pages/ProductPricing.vue index 61a2044..9260eb6 100644 --- a/ui/src/pages/ProductPricing.vue +++ b/ui/src/pages/ProductPricing.vue @@ -2,58 +2,106 @@
Urun Fiyatlandirma
-
- - - - Tumunu Sec - - - Tumunu Temizle - - - - - - - {{ option.label }} - - - - - - - - -
- Sayfa {{ currentPage }} / {{ Math.max(1, store.totalPages || 1) }} - Toplam {{ store.totalCount || 0 }} urun kodu +
+
+ + + + +
+ +
+ + + + Tumunu Sec + + + Tumunu Temizle + + + + + + + {{ option.label }} + + + + + + +
+ Sayfa {{ currentPage }} / {{ Math.max(1, store.totalPages || 1) }} - Toplam {{ store.totalCount || 0 }} urun kodu +
@@ -106,7 +154,12 @@ {{ getFilterBadgeValue(col.field) }} - +
+ + @@ -428,9 +492,10 @@ diff --git a/ui/src/router/routes.js b/ui/src/router/routes.js index 606f1ba..c712ea1 100644 --- a/ui/src/router/routes.js +++ b/ui/src/router/routes.js @@ -340,55 +340,96 @@ const routes = [ meta: { permission: 'order:view' } }, - /* ================= PRICING ================= */ - { - path: 'pricing/product-pricing', - name: 'product-pricing', - component: () => import('pages/ProductPricing.vue'), - meta: { permission: 'order:view' } - }, - { - path: 'pricing/production-product-costing', - name: 'production-product-costing', - component: () => import('pages/ProductionProductCosting.vue'), - meta: { permission: 'order:view' } - }, - { - path: 'pricing/production-product-costing/has-cost', - name: 'production-product-costing-has-cost', - component: () => import('pages/ProductionProductCostingHasCost.vue'), - meta: { permission: 'order:view' } - }, - { - path: 'pricing/production-product-costing/has-cost/history', - name: 'production-product-costing-has-cost-history', - component: () => import('pages/ProductionProductCostingHasCostHistory.vue'), - meta: { permission: 'order:view' } - }, - { - path: 'pricing/production-product-costing/has-cost/detail', - name: 'production-product-costing-has-cost-detail', - component: () => import('pages/ProductionProductCostingHasCostDetail.vue'), - meta: { permission: 'order:view' } - }, - { - path: 'pricing/production-product-costing/no-cost', - name: 'production-product-costing-no-cost', - component: () => import('pages/ProductionProductCostingNoCost.vue'), - meta: { permission: 'order:view' } - }, - { - path: 'pricing/production-product-costing/maliyet-parca-eslestirme', - name: 'production-product-costing-maliyet-parca-eslestirme', - component: () => import('pages/ProductionProductCostingMTBolumMapping.vue'), - meta: { permission: 'order:view' } - }, - { - path: 'pricing/production-product-costing/default-quantities', - name: 'production-product-costing-default-quantities', - component: () => import('pages/ProductionProductCostingDefaultQuantities.vue'), - meta: { permission: 'order:view' } - }, + /* ================= PRICING ================= */ + // Backward-compatible redirects (old "pricing/production-product-costing" URLs). + { + path: 'pricing/production-product-costing', + redirect: { name: 'production-product-costing' } + }, + { + path: 'pricing/production-product-costing/has-cost', + redirect: { name: 'production-product-costing-has-cost' } + }, + { + path: 'pricing/production-product-costing/has-cost/history', + redirect: { name: 'production-product-costing-has-cost-history' } + }, + { + path: 'pricing/production-product-costing/has-cost/detail', + redirect: { name: 'production-product-costing-has-cost-detail' } + }, + { + path: 'pricing/production-product-costing/no-cost', + redirect: { name: 'production-product-costing-no-cost' } + }, + { + path: 'pricing/production-product-costing/maliyet-parca-eslestirme', + redirect: { name: 'production-product-costing-maliyet-parca-eslestirme' } + }, + { + path: 'pricing/production-product-costing/default-quantities', + redirect: { name: 'production-product-costing-default-quantities' } + }, + { + path: 'pricing/product-pricing', + name: 'product-pricing', + component: () => import('pages/ProductPricing.vue'), + meta: { permission: 'pricing:view' } + }, + { + path: 'pricing/brand-classification', + name: 'brand-classification', + component: () => import('pages/BrandClassification.vue'), + meta: { permission: 'pricing:view' } + }, + { + path: 'pricing/pricing-rules', + name: 'pricing-rules', + component: () => import('pages/PricingRules.vue'), + meta: { permission: 'pricing:view' } + }, + { + path: 'costing/production-product-costing', + name: 'production-product-costing', + component: () => import('pages/ProductionProductCosting.vue'), + meta: { permission: 'costing:view' } + }, + { + path: 'costing/production-product-costing/has-cost', + name: 'production-product-costing-has-cost', + component: () => import('pages/ProductionProductCostingHasCost.vue'), + meta: { permission: 'costing:view' } + }, + { + path: 'costing/production-product-costing/has-cost/history', + name: 'production-product-costing-has-cost-history', + component: () => import('pages/ProductionProductCostingHasCostHistory.vue'), + meta: { permission: 'costing:view' } + }, + { + path: 'costing/production-product-costing/has-cost/detail', + name: 'production-product-costing-has-cost-detail', + component: () => import('pages/ProductionProductCostingHasCostDetail.vue'), + meta: { permission: 'costing:view' } + }, + { + path: 'costing/production-product-costing/no-cost', + name: 'production-product-costing-no-cost', + component: () => import('pages/ProductionProductCostingNoCost.vue'), + meta: { permission: 'costing:view' } + }, + { + path: 'costing/production-product-costing/maliyet-parca-eslestirme', + name: 'production-product-costing-maliyet-parca-eslestirme', + component: () => import('pages/ProductionProductCostingMTBolumMapping.vue'), + meta: { permission: 'costing:view' } + }, + { + path: 'costing/production-product-costing/default-quantities', + name: 'production-product-costing-default-quantities', + component: () => import('pages/ProductionProductCostingDefaultQuantities.vue'), + meta: { permission: 'costing:view' } + }, /* ================= PASSWORD ================= */ diff --git a/ui/src/stores/ProductPricingStore.js b/ui/src/stores/ProductPricingStore.js index 1bf0b2b..196b441 100644 --- a/ui/src/stores/ProductPricingStore.js +++ b/ui/src/stores/ProductPricingStore.js @@ -42,6 +42,7 @@ function mapRow (raw, index, baseIndex = 0) { productCode: toText(raw?.ProductCode), stockQty: toNumber(raw?.StockQty), stockEntryDate: toText(raw?.StockEntryDate), + lastCostingDate: toText(raw?.LastCostingDate), lastPricingDate: toText(raw?.LastPricingDate), askiliYan: toText(raw?.AskiliYan), kategori: toText(raw?.Kategori), @@ -54,26 +55,26 @@ function mapRow (raw, index, baseIndex = 0) { brandGroupSelection: toText(raw?.BrandGroupSec), costPrice: toNumber(raw?.CostPrice), expenseForBasePrice: 0, - basePriceUsd: 0, - basePriceTry: 0, - usd1: 0, - usd2: 0, - usd3: 0, - usd4: 0, - usd5: 0, - usd6: 0, - eur1: 0, - eur2: 0, - eur3: 0, - eur4: 0, - eur5: 0, - eur6: 0, - try1: 0, - try2: 0, - try3: 0, - try4: 0, - try5: 0, - try6: 0 + basePriceUsd: toNumber(raw?.BasePriceUsd), + basePriceTry: toNumber(raw?.BasePriceTry), + usd1: toNumber(raw?.USD1), + usd2: toNumber(raw?.USD2), + usd3: toNumber(raw?.USD3), + usd4: toNumber(raw?.USD4), + usd5: toNumber(raw?.USD5), + usd6: toNumber(raw?.USD6), + eur1: toNumber(raw?.EUR1), + eur2: toNumber(raw?.EUR2), + eur3: toNumber(raw?.EUR3), + eur4: toNumber(raw?.EUR4), + eur5: toNumber(raw?.EUR5), + eur6: toNumber(raw?.EUR6), + try1: toNumber(raw?.TRY1), + try2: toNumber(raw?.TRY2), + try3: toNumber(raw?.TRY3), + try4: toNumber(raw?.TRY4), + try5: toNumber(raw?.TRY5), + try6: toNumber(raw?.TRY6) } } @@ -95,11 +96,18 @@ function normalizeFilters (filters = {}) { return out } +function hasPrimaryFilter (filters = {}) { + return (Array.isArray(filters.urun_ilk_grubu) && filters.urun_ilk_grubu.length > 0) || + (Array.isArray(filters.urun_ana_grubu) && filters.urun_ana_grubu.length > 0) +} + function makeCacheKey (limit, page, filters) { return JSON.stringify({ limit: Number(limit) || 500, page: Number(page) || 1, - filters: normalizeFilters(filters) + filters: normalizeFilters(filters), + sortBy: toText(filters?.__sortBy), + descending: Boolean(filters?.__descending) }) } @@ -145,6 +153,8 @@ export const useProductPricingStore = defineStore('product-pricing-store', { const limit = Number(options?.limit) > 0 ? Number(options.limit) : 500 const page = Number(options?.page) > 0 ? Number(options.page) : 1 const filters = normalizeFilters(options?.filters || {}) + const sortBy = toText(options?.sortBy) + const descending = Boolean(options?.descending) const key = makeCacheKey(limit, page, filters) if (this.pageCache[key]) return if (this.prefetchInFlight[key]) { @@ -153,7 +163,12 @@ export const useProductPricingStore = defineStore('product-pricing-store', { } const run = async () => { try { - const params = { limit, page } + const includeTotal = hasPrimaryFilter(filters) ? 1 : 0 + const params = { limit, page, include_total: includeTotal } + if (sortBy) { + params.sort_by = sortBy + params.desc = descending ? 1 : 0 + } for (const k of Object.keys(filters)) { if (k === 'q') { params.q = filters.q @@ -169,11 +184,14 @@ export const useProductPricingStore = defineStore('product-pricing-store', { params, timeout: 180000 }) - const totalCount = Number(res?.headers?.['x-total-count'] || 0) - const totalPages = Math.max(1, Number(res?.headers?.['x-total-pages'] || 1)) - const currentPage = Math.max(1, Number(res?.headers?.['x-page'] || page)) - const data = Array.isArray(res?.data) ? res.data : [] - const mapped = data.map((x, i) => mapRow(x, i, 0)) + const totalCount = Number(res?.headers?.['x-total-count'] || 0) + let totalPages = Math.max(1, Number(res?.headers?.['x-total-pages'] || 0)) + const currentPage = Math.max(1, Number(res?.headers?.['x-page'] || page)) + const data = Array.isArray(res?.data) ? res.data : [] + const mapped = data.map((x, i) => mapRow(x, i, 0)) + if (!Number.isFinite(totalPages) || totalPages <= 0) { + totalPages = mapped.length >= limit ? currentPage + 1 : currentPage + } this.cachePut(key, { rows: mapped, totalCount: Number.isFinite(totalCount) ? totalCount : 0, @@ -202,6 +220,8 @@ export const useProductPricingStore = defineStore('product-pricing-store', { const append = Boolean(options?.append) const baseIndex = append ? this.rows.length : 0 const filters = normalizeFilters(options?.filters || {}) + const sortBy = toText(options?.sortBy) + const descending = Boolean(options?.descending) const cacheKey = makeCacheKey(limit, page, filters) const startedAt = Date.now() console.info('[product-pricing][frontend] request:start', { @@ -237,7 +257,12 @@ export const useProductPricingStore = defineStore('product-pricing-store', { } } - const params = { limit, page } + const includeTotal = hasPrimaryFilter(filters) ? 1 : 0 + const params = { limit, page, include_total: includeTotal } + if (sortBy) { + params.sort_by = sortBy + params.desc = descending ? 1 : 0 + } for (const key of Object.keys(filters)) { if (key === 'q') { params.q = filters.q @@ -253,11 +278,15 @@ export const useProductPricingStore = defineStore('product-pricing-store', { timeout: 180000 }) const traceId = res?.headers?.['x-trace-id'] || null - const totalCount = Number(res?.headers?.['x-total-count'] || 0) - const totalPages = Math.max(1, Number(res?.headers?.['x-total-pages'] || 1)) - const currentPage = Math.max(1, Number(res?.headers?.['x-page'] || page)) - const data = Array.isArray(res?.data) ? res.data : [] + const totalCount = Number(res?.headers?.['x-total-count'] || 0) + let totalPages = Math.max(1, Number(res?.headers?.['x-total-pages'] || 0)) + const currentPage = Math.max(1, Number(res?.headers?.['x-page'] || page)) + const data = Array.isArray(res?.data) ? res.data : [] const mapped = data.map((x, i) => mapRow(x, i, baseIndex)) + if (!Number.isFinite(totalPages) || totalPages <= 0) { + // When server skips count, infer "hasMore" from page size. + totalPages = mapped.length >= limit ? currentPage + 1 : currentPage + } const payload = { rows: mapped, totalCount: Number.isFinite(totalCount) ? totalCount : 0, @@ -265,7 +294,15 @@ export const useProductPricingStore = defineStore('product-pricing-store', { page: Number.isFinite(currentPage) ? currentPage : page } this.cachePut(cacheKey, payload) - this.applyPageResult(payload, page) + if (append) { + this.rows = [...cloneRows(this.rows || []), ...mapped.map((r) => ({ ...r }))] + this.totalCount = Number.isFinite(payload.totalCount) ? payload.totalCount : this.totalCount + this.totalPages = Math.max(1, Number(payload.totalPages || this.totalPages || 1)) + this.page = Math.max(1, Number(payload.page || page)) + this.hasMore = this.page < this.totalPages + } else { + this.applyPageResult(payload, page) + } // Background prefetch for next page to reduce perceived wait on page change. if (this.page < this.totalPages) { @@ -311,6 +348,8 @@ export const useProductPricingStore = defineStore('product-pricing-store', { } }, + // fetchAllByGroups removed: keep paging server-side. + updateCell (row, field, val) { if (!row || !field) return row[field] = toNumber(val) diff --git a/ui/src/stores/RoleDeptPermissionListStore.js b/ui/src/stores/RoleDeptPermissionListStore.js index 5a6ff43..ce51e13 100644 --- a/ui/src/stores/RoleDeptPermissionListStore.js +++ b/ui/src/stores/RoleDeptPermissionListStore.js @@ -94,7 +94,14 @@ export const useRoleDeptPermissionListStore = defineStore('roleDeptPermissionLis role_title: r.role_title || '', department_code: r.department_code || '', department_title: r.department_title || '', - module_flags: flags + module_flags: flags, + members: Array.isArray(r.members) + ? r.members.map((member) => ({ + id: Number(member?.id || 0), + full_name: String(member?.full_name || ''), + username: String(member?.username || '') + })).filter((member) => member.id > 0) + : [] } }) } catch (err) {