Merge remote-tracking branch 'origin/master'

This commit is contained in:
M_Kececi
2026-06-02 16:14:54 +03:00
parent 5f3e975b6d
commit b4e87cfd47
25 changed files with 4918 additions and 287 deletions

View File

@@ -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")
}
}()
}

View File

@@ -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(

View File

@@ -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"`
}

View File

@@ -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")
}
}()
}

View File

@@ -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
}

117
svc/queries/brand_sync.go Normal file
View File

@@ -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
}

View File

@@ -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,

View File

@@ -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
}

View File

@@ -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()
}

View File

@@ -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
}

View File

@@ -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()
}

View File

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

163
svc/routes/pricing_rules.go Normal file
View File

@@ -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
}

View File

@@ -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 != "" {

View File

@@ -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) {

View File

@@ -326,29 +326,47 @@ const menuItems = [
},
{
label: 'Fiyatlandırma/Maliyetlendirme',
label: 'Ürün 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'
to: '/app/costing/production-product-costing',
permission: 'costing:view'
},
{
label: 'Maliyet Parça Eşleştirme',
to: '/app/pricing/production-product-costing/maliyet-parca-eslestirme',
permission: 'order:view'
to: '/app/costing/production-product-costing/maliyet-parca-eslestirme',
permission: 'costing:view'
},
{
label: 'Maliyet Varsayilan Miktarlar',
to: '/app/pricing/production-product-costing/default-quantities',
permission: 'order:view'
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'
}
]
},

View File

@@ -0,0 +1,214 @@
<template>
<q-page class="q-pa-md">
<div class="row items-center q-col-gutter-sm q-mb-md">
<div class="col-12 col-md">
<div class="text-h6">Marka Sınıflandırma</div>
<div class="text-caption text-grey-7">
Kaynak: BAGGI_V3 `cdItemAttribute` (ItemTypeCode=1, AttributeTypeCode=10)
</div>
</div>
<div class="col-12 col-md-auto">
<q-input
v-model="search"
dense
outlined
clearable
placeholder="Marka ara (kod / ad)"
@keyup.enter="reload"
style="min-width: 260px"
/>
</div>
<div class="col-12 col-md-auto row items-center q-gutter-sm">
<q-btn
color="secondary"
outline
:loading="loading"
label="Yenile"
@click="reload"
/>
<q-btn
color="primary"
unelevated
icon="save"
:disable="!canUpdate || selectedDirtyCount === 0 || saving"
:loading="saving"
label="Kaydet (Seçili)"
@click="saveSelected"
/>
</div>
</div>
<q-table
flat
bordered
:rows="rows"
:columns="columns"
row-key="brand_code"
selection="multiple"
v-model:selected="selected"
:loading="loading"
:pagination="{ rowsPerPage: 0 }"
hide-bottom
class="mk-table"
>
<template #body-cell-group="props">
<q-td :props="props">
<q-select
v-model="props.row._group_id"
dense
outlined
emit-value
map-options
option-value="id"
option-label="title"
:options="groupOptionsWithNone"
:disable="!canUpdate || saving"
style="min-width: 160px"
@update:model-value="markDirty(props.row)"
>
<template #option="scope">
<q-item v-bind="scope.itemProps">
<q-item-section>
<div class="text-body2">{{ scope.opt.title }}</div>
<div v-if="scope.opt.description" class="text-caption text-grey-7">
{{ scope.opt.description }}
</div>
</q-item-section>
</q-item>
</template>
</q-select>
</q-td>
</template>
<template #body-cell-actions="props">
<q-td :props="props" class="text-right">
<span class="text-caption text-grey-7" v-if="props.row._dirty">Değişti</span>
</q-td>
</template>
</q-table>
</q-page>
</template>
<script setup>
import { computed, onMounted, ref } from 'vue'
import { Notify } from 'quasar'
import api from 'src/services/api'
import { usePermissionStore } from 'stores/permissionStore'
const perm = usePermissionStore()
const loading = ref(false)
const saving = ref(false)
const search = ref('')
const groups = ref([])
const rows = ref([])
const selected = ref([])
const canUpdate = computed(() => perm.hasApiPermission('pricing:update'))
const groupOptionsWithNone = computed(() => {
const base = Array.isArray(groups.value) ? groups.value : []
return [{ id: 0, title: '(Seçiniz)', code: '' }, ...base]
})
const selectedDirtyCount = computed(() => {
const list = Array.isArray(selected.value) ? selected.value : []
return list.filter(r => r?._dirty).length
})
const columns = [
{ name: 'brand_code', label: 'Marka Kodu', field: 'brand_code', align: 'left', sortable: true },
{ name: 'brand_name', label: 'Marka Adı', field: 'brand_name', align: 'left', sortable: true },
{ name: 'group', label: 'Grup', field: 'group_name', align: 'left' },
{ name: 'actions', label: '', field: 'actions', align: 'right' }
]
function normalizeRow (r) {
return {
brand_code: String(r?.brand_code ?? '').trim(),
brand_name: String(r?.brand_name ?? '').trim(),
group_id: Number(r?.group_id ?? 0) || 0,
group_name: String(r?.group_name ?? '').trim(),
_group_id: Number(r?.group_id ?? 0) || 0,
_dirty: false
}
}
function markDirty (row) {
row._dirty = true
}
async function loadLookups () {
const res = await api.request({
method: 'GET',
url: '/pricing/brand-classification/lookups',
timeout: 180000
})
groups.value = Array.isArray(res?.data?.groups) ? res.data.groups : []
}
async function reload () {
loading.value = true
try {
await loadLookups()
const res = await api.request({
method: 'GET',
url: '/pricing/brand-classification/brands',
params: search.value ? { q: search.value } : {},
timeout: 180000
})
const data = Array.isArray(res?.data) ? res.data : []
rows.value = data.map(normalizeRow)
selected.value = []
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Liste alınamadı' })
} finally {
loading.value = false
}
}
async function saveSelected () {
const list = Array.isArray(selected.value) ? selected.value : []
const dirty = list.filter(r => r && r._dirty)
if (dirty.length === 0) return
saving.value = true
try {
const payload = {
items: dirty.map(r => ({
brand_code: r.brand_code,
group_id: Number(r._group_id || 0)
}))
}
await api.request({
method: 'POST',
url: '/pricing/brand-classification/brands/group-bulk',
data: payload,
timeout: 180000
})
for (const r of dirty) {
r.group_id = Number(r._group_id || 0)
const selOpt = groupOptionsWithNone.value.find(x => Number(x.id) === r.group_id)
r.group_name = selOpt ? String(selOpt.title || '') : ''
r._dirty = false
}
Notify.create({ type: 'positive', message: `Kaydedildi: ${dirty.length} satır` })
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Kaydedilemedi' })
} finally {
saving.value = false
}
}
onMounted(async () => {
// Initial: ensure data exists. If mk_brands is empty, user can run sync.
await reload()
})
</script>
<style scoped>
.mk-table :deep(th) {
white-space: nowrap;
}
</style>

View File

@@ -0,0 +1,892 @@
<template>
<q-page class="q-pa-xs pricing-rules-page">
<div class="top-bar row items-center justify-between q-mb-xs">
<div>
<div class="text-subtitle1 text-weight-bold">Fiyat Carpani Kurallari</div>
<div class="text-caption text-grey-7">
MSSQL urun kombinasyonlari ve bu kombinasyonlara bagli para birimi bazli fiyat kurallari.
</div>
</div>
<q-btn
flat
color="primary"
icon="refresh"
label="Yenile"
:loading="loading"
@click="loadRows"
/>
</div>
<div class="action-bar row items-center justify-between q-mb-xs">
<div class="text-caption text-grey-8">
{{ filteredRows.length }} / {{ rows.length }} kombinasyon gosteriliyor. Degistirilen satirlar otomatik secilir.
</div>
<div class="row items-center q-gutter-xs">
<q-btn
flat
color="primary"
icon="filter_alt_off"
label="Filtreleri Temizle"
:disable="!hasAnyFilter"
@click="clearAllFilters"
/>
<q-btn
color="primary"
unelevated
icon="save"
:disable="!canUpdate || selectedDirtyCount === 0 || saving"
:loading="saving"
:label="`Kaydet (${selectedDirtyCount})`"
@click="saveSelected"
/>
</div>
</div>
<div class="table-wrap" :style="{ '--sticky-scroll-comp': `${stickyScrollComp}px` }">
<q-table
flat
dense
row-key="_row_key"
:rows="filteredRows"
:columns="columns"
:loading="loading"
:rows-per-page-options="[0]"
v-model:pagination="tablePagination"
binary-state-sort
hide-bottom
:table-style="tableStyle"
class="pane-table rules-table"
>
<template #no-data>
<div class="full-width row flex-center q-pa-lg text-grey-7">
Parametre cache'i henuz dolmadi veya aktif filtrelerle eslesen kayit bulunamadi.
</div>
</template>
<template #header="props">
<q-tr :props="props" class="header-row-fixed">
<q-th
v-for="col in props.cols"
:key="col.name"
:props="props"
:class="[col.headerClasses, { 'sticky-col': isStickyCol(col.name), 'sticky-boundary': isStickyBoundary(col.name) }]"
:style="getHeaderCellStyle(col)"
>
<q-checkbox
v-if="col.name === 'select'"
dense
:model-value="allSelectedVisible"
:indeterminate="someSelectedVisible && !allSelectedVisible"
@update:model-value="toggleSelectAllVisible"
/>
<div v-else class="header-with-filter">
<span>{{ col.label }}</span>
<q-btn
v-if="isHeaderFilterField(col.field)"
dense
flat
round
size="8px"
icon="filter_alt"
:color="hasFilter(col.field) ? 'primary' : 'grey-7'"
class="header-filter-btn"
@click.stop
>
<q-badge
v-if="hasFilter(col.field)"
floating
rounded
color="primary"
:label="getFilterBadgeValue(col.field)"
/>
<q-menu anchor="bottom right" self="top right" @click.stop>
<div v-if="isMultiSelectFilterField(col.field)" class="excel-filter-menu">
<q-input
v-model="columnFilterSearch[col.field]"
dense
outlined
clearable
debounce="150"
placeholder="Ara"
/>
<div class="excel-filter-actions row items-center justify-between q-mt-xs">
<q-btn flat dense size="sm" color="primary" label="Tumunu Sec" @click="selectAllColumnFilterOptions(col.field)" />
<q-btn flat dense size="sm" color="grey-8" label="Temizle" @click="clearColumnFilter(col.field)" />
</div>
<q-virtual-scroll
v-if="getFilterOptionsForField(col.field).length > 0"
class="excel-filter-options"
:items="getFilterOptionsForField(col.field)"
:virtual-scroll-item-size="32"
separator
>
<template #default="{ item: option }">
<q-item
dense
clickable
class="excel-filter-option"
@click="toggleColumnFilterValue(col.field, option.value)"
>
<q-item-section avatar>
<q-checkbox
dense
:model-value="isColumnFilterValueSelected(col.field, option.value)"
@click.stop
@update:model-value="toggleColumnFilterValue(col.field, option.value)"
/>
</q-item-section>
<q-item-section>
<q-item-label>{{ option.label }}</q-item-label>
</q-item-section>
</q-item>
</template>
</q-virtual-scroll>
<div v-else class="excel-filter-empty">Sonuc yok</div>
</div>
<div v-else-if="isNumberRangeFilterField(col.field)" class="excel-filter-menu">
<div class="range-filter-grid">
<q-input
dense
outlined
type="number"
label="Minimum"
:model-value="numberRangeFilters[col.field]?.min"
@update:model-value="(value) => updateNumberRangeFilter(col.field, 'min', value)"
/>
<q-input
dense
outlined
type="number"
label="Maksimum"
:model-value="numberRangeFilters[col.field]?.max"
@update:model-value="(value) => updateNumberRangeFilter(col.field, 'max', value)"
/>
</div>
<div class="excel-filter-actions row items-center justify-end q-mt-xs">
<q-btn flat dense size="sm" color="grey-8" label="Temizle" @click="clearRangeFilter(col.field)" />
</div>
</div>
</q-menu>
</q-btn>
<span v-else class="header-filter-ghost" />
</div>
</q-th>
</q-tr>
</template>
<template #body="props">
<q-tr :props="props">
<q-td
v-for="col in props.cols"
:key="col.name"
:props="props"
:class="[col.classes, { 'sticky-col': isStickyCol(col.name), 'sticky-boundary': isStickyBoundary(col.name) }]"
:style="getBodyCellStyle(col)"
>
<q-checkbox
v-if="col.name === 'select'"
dense
:model-value="isRowSelected(props.row)"
@update:model-value="(value) => setRowSelected(props.row, value)"
/>
<q-badge v-else-if="col.name === 'has_rule'" :color="props.row.has_rule ? 'positive' : 'grey-6'">
{{ props.row.has_rule ? 'Tanimli' : 'Yeni' }}
</q-badge>
<q-toggle
v-else-if="col.name === 'is_active'"
v-model="props.row.is_active"
dense
@update:model-value="() => markDirty(props.row)"
/>
<input
v-else-if="numericFields.has(col.name)"
class="native-cell-input text-right"
inputmode="decimal"
:value="props.row[col.field]"
@input="(event) => updateNumber(props.row, col.field, event.target.value)"
>
<span v-else class="cell-text" :title="String(col.value ?? '')">
{{ col.value }}
</span>
</q-td>
</q-tr>
</template>
</q-table>
</div>
</q-page>
</template>
<script setup>
import { computed, onBeforeUnmount, onMounted, ref } from 'vue'
import { Notify } from 'quasar'
import api from 'src/services/api'
import { usePermissionStore } from 'stores/permissionStore'
const perm = usePermissionStore()
const canUpdate = computed(() => perm.hasApiPermission('pricing:update'))
const loading = ref(false)
const saving = ref(false)
const rows = ref([])
const selected = ref([])
const tablePagination = ref({ rowsPerPage: 0, sortBy: 'urun_ilk_grubu', descending: false })
let emptyRetryTimer = null
const numericFields = new Set([
'try_base', 'try1', 'try2', 'try3', 'try4', 'try5', 'try6', 'try_step',
'usd_base', 'usd1', 'usd2', 'usd3', 'usd4', 'usd5', 'usd6', 'usd_step',
'eur_base', 'eur1', 'eur2', 'eur3', 'eur4', 'eur5', 'eur6', 'eur_step'
])
const multiFilterFields = [
'has_rule', 'is_active', 'askili_yan', 'kategori', 'urun_ilk_grubu', 'urun_ana_grubu',
'urun_alt_grubu', 'icerik', 'marka', 'brand_code', 'brand_group'
]
const multiSelectFilterFieldSet = new Set(multiFilterFields)
const numberRangeFilterFieldSet = new Set(numericFields)
const headerFilterFieldSet = new Set([...multiFilterFields, ...numericFields])
const columnFilters = ref(Object.fromEntries(multiFilterFields.map(field => [field, []])))
const columnFilterSearch = ref(Object.fromEntries(multiFilterFields.map(field => [field, ''])))
const numberRangeFilters = ref(Object.fromEntries([...numericFields].map(field => [field, { min: '', max: '' }])))
function col (name, label, field, width, extra = {}) {
const size = `width:${width}px;min-width:${width}px;max-width:${width}px;`
return {
name,
label,
field,
sortable: name !== 'select',
align: 'left',
style: size,
headerStyle: size,
...extra
}
}
const columns = [
col('select', '', 'select', 34, { sortable: false, classes: 'selection-col', headerClasses: 'selection-col' }),
col('has_rule', 'DURUM', 'has_rule', 62, { classes: 'ps-col', headerClasses: 'ps-col' }),
col('is_active', 'AKTIF', 'is_active', 48, { classes: 'ps-col', headerClasses: 'ps-col' }),
col('askili_yan', 'ASKILI YAN', 'askili_yan', 86, { classes: 'ps-col', headerClasses: 'ps-col' }),
col('kategori', 'KATEGORI', 'kategori', 92, { classes: 'ps-col', headerClasses: 'ps-col' }),
col('urun_ilk_grubu', 'URUN ILK GRUBU', 'urun_ilk_grubu', 100, { classes: 'ps-col', headerClasses: 'ps-col' }),
col('urun_ana_grubu', 'URUN ANA GRUBU', 'urun_ana_grubu', 110, { classes: 'ps-col', headerClasses: 'ps-col' }),
col('urun_alt_grubu', 'URUN ALT GRUBU', 'urun_alt_grubu', 110, { classes: 'ps-col', headerClasses: 'ps-col' }),
col('icerik', 'ICERIK', 'icerik', 90, { classes: 'ps-col', headerClasses: 'ps-col' }),
col('marka', 'MARKA', 'marka', 100, { classes: 'ps-col', headerClasses: 'ps-col' }),
col('brand_code', 'BRAND CODE', 'brand_code', 78, { classes: 'ps-col', headerClasses: 'ps-col' }),
col('brand_group', 'MARKA GRUBU', 'brand_group', 88, { classes: 'ps-col', headerClasses: 'ps-col' }),
col('try_step', 'TRY YUVARLAMA', 'try_step', 84, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
col('try_base', 'TRY TABAN', 'try_base', 70, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
col('try1', 'TRY 1', 'try1', 62, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
col('try2', 'TRY 2', 'try2', 62, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
col('try3', 'TRY 3', 'try3', 62, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
col('try4', 'TRY 4', 'try4', 62, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
col('try5', 'TRY 5', 'try5', 62, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
col('try6', 'TRY 6', 'try6', 62, { align: 'right', classes: 'try-col', headerClasses: 'try-col' }),
col('usd_step', 'USD YUVARLAMA', 'usd_step', 84, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
col('usd_base', 'USD TABAN', 'usd_base', 70, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
col('usd1', 'USD 1', 'usd1', 62, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
col('usd2', 'USD 2', 'usd2', 62, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
col('usd3', 'USD 3', 'usd3', 62, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
col('usd4', 'USD 4', 'usd4', 62, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
col('usd5', 'USD 5', 'usd5', 62, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
col('usd6', 'USD 6', 'usd6', 62, { align: 'right', classes: 'usd-col', headerClasses: 'usd-col' }),
col('eur_step', 'EUR YUVARLAMA', 'eur_step', 84, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
col('eur_base', 'EUR TABAN', 'eur_base', 70, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
col('eur1', 'EUR 1', 'eur1', 62, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
col('eur2', 'EUR 2', 'eur2', 62, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
col('eur3', 'EUR 3', 'eur3', 62, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
col('eur4', 'EUR 4', 'eur4', 62, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
col('eur5', 'EUR 5', 'eur5', 62, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' }),
col('eur6', 'EUR 6', 'eur6', 62, { align: 'right', classes: 'eur-col', headerClasses: 'eur-col' })
]
const stickyColumnNames = [
'select', 'has_rule', 'is_active', 'askili_yan', 'kategori', 'urun_ilk_grubu',
'urun_ana_grubu', 'urun_alt_grubu', 'icerik', 'marka', 'brand_code', 'brand_group'
]
const stickyBoundaryColumnName = 'brand_group'
const stickyColumnNameSet = new Set(stickyColumnNames)
const stickyLeftMap = computed(() => {
const map = {}
let left = 0
for (const colName of stickyColumnNames) {
const column = columns.find(item => item.name === colName)
if (!column) continue
map[colName] = left
left += extractWidth(column.style)
}
return map
})
const stickyScrollComp = computed(() => {
const boundary = columns.find(item => item.name === stickyBoundaryColumnName)
return (stickyLeftMap.value[stickyBoundaryColumnName] || 0) + extractWidth(boundary?.style)
})
const tableMinWidth = computed(() => columns.reduce((sum, column) => sum + extractWidth(column.style), 0))
const tableStyle = computed(() => ({
width: `${tableMinWidth.value}px`,
minWidth: `${tableMinWidth.value}px`,
tableLayout: 'fixed'
}))
function filterDisplayValue (row, field) {
if (field === 'has_rule') return row?.has_rule ? 'Tanimli' : 'Yeni'
if (field === 'is_active') return row?.is_active ? 'Aktif' : 'Pasif'
return String(row?.[field] ?? '').trim()
}
const multiFilterOptionMap = computed(() => {
const map = {}
for (const field of multiFilterFields) {
const uniq = new Set()
for (const row of rows.value) {
const value = filterDisplayValue(row, field)
if (value) uniq.add(value)
}
map[field] = [...uniq]
.sort((a, b) => a.localeCompare(b, 'tr'))
.map(value => ({ label: value, value }))
}
return map
})
const filteredFilterOptionMap = computed(() => {
const map = {}
for (const field of multiFilterFields) {
const search = String(columnFilterSearch.value[field] || '').trim().toLocaleLowerCase('tr')
const options = multiFilterOptionMap.value[field] || []
map[field] = search
? options.filter(option => option.label.toLocaleLowerCase('tr').includes(search))
: options
}
return map
})
const filteredRows = computed(() => {
return rows.value.filter(row => {
for (const field of multiFilterFields) {
const selectedValues = columnFilters.value[field] || []
if (selectedValues.length > 0 && !selectedValues.includes(filterDisplayValue(row, field))) return false
}
for (const field of numericFields) {
const value = Number(row?.[field] ?? 0)
const min = parseNullableNumber(numberRangeFilters.value[field]?.min)
const max = parseNullableNumber(numberRangeFilters.value[field]?.max)
if (min !== null && value < min) return false
if (max !== null && value > max) return false
}
return true
})
})
const visibleRowKeys = computed(() => filteredRows.value.map(row => row._row_key))
const selectedVisibleCount = computed(() => visibleRowKeys.value.filter(key => selected.value.some(row => row._row_key === key)).length)
const allSelectedVisible = computed(() => visibleRowKeys.value.length > 0 && selectedVisibleCount.value === visibleRowKeys.value.length)
const someSelectedVisible = computed(() => selectedVisibleCount.value > 0)
const selectedDirtyCount = computed(() => selected.value.filter(row => row?._dirty).length)
const hasAnyFilter = computed(() => {
return [...headerFilterFieldSet].some(field => hasFilter(field))
})
function finiteNumber (value, fallback = 0) {
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : fallback
}
function parseNullableNumber (value) {
if (value === null || value === undefined || String(value).trim() === '') return null
const parsed = Number(value)
return Number.isFinite(parsed) ? parsed : null
}
function normalizeWorksheetRow (source) {
const rule = source?.rule || {}
const row = {
pricing_parameter_id: Number(source?.pricing_parameter_id || 0),
_row_key: String(source?.scope_key || source?.pricing_parameter_id || ''),
has_rule: Boolean(source?.has_rule),
id: String(rule?.id || ''),
is_active: rule?.is_active !== false,
askili_yan: String(source?.askili_yan || ''),
kategori: String(source?.kategori || ''),
urun_ilk_grubu: String(source?.urun_ilk_grubu || ''),
urun_ana_grubu: String(source?.urun_ana_grubu || ''),
urun_alt_grubu: String(source?.urun_alt_grubu || ''),
icerik: String(source?.icerik || ''),
marka: String(source?.marka || ''),
brand_code: String(source?.brand_code || ''),
brand_group: String(source?.brand_group || ''),
_dirty: false
}
for (const key of numericFields) {
row[key] = row.has_rule ? finiteNumber(rule?.[key], 0) : ''
}
return row
}
function extractWidth (style) {
const match = String(style || '').match(/width:(\d+)px/)
return match ? Number(match[1]) : 0
}
function isStickyCol (colName) {
return stickyColumnNameSet.has(colName)
}
function isStickyBoundary (colName) {
return colName === stickyBoundaryColumnName
}
function getHeaderCellStyle (column) {
if (!isStickyCol(column.name)) return undefined
return { left: `${stickyLeftMap.value[column.name] || 0}px`, zIndex: 35 }
}
function getBodyCellStyle (column) {
if (!isStickyCol(column.name)) return undefined
return { left: `${stickyLeftMap.value[column.name] || 0}px`, zIndex: 12 }
}
function isRowSelected (row) {
return selected.value.some(item => item._row_key === row._row_key)
}
function setRowSelected (row, value) {
if (value) {
if (!isRowSelected(row)) selected.value = [...selected.value, row]
return
}
selected.value = selected.value.filter(item => item._row_key !== row._row_key)
}
function toggleSelectAllVisible (value) {
const keys = new Set(visibleRowKeys.value)
const remaining = selected.value.filter(row => !keys.has(row._row_key))
selected.value = value ? [...remaining, ...filteredRows.value] : remaining
}
function selectDirtyRow (row) {
setRowSelected(row, true)
}
function markDirty (row) {
row._dirty = true
selectDirtyRow(row)
}
function updateNumber (row, field, value) {
row[field] = String(value ?? '').trim() === '' ? '' : finiteNumber(value, 0)
markDirty(row)
}
function isHeaderFilterField (field) {
return headerFilterFieldSet.has(field)
}
function isMultiSelectFilterField (field) {
return multiSelectFilterFieldSet.has(field)
}
function isNumberRangeFilterField (field) {
return numberRangeFilterFieldSet.has(field)
}
function hasFilter (field) {
if (isMultiSelectFilterField(field)) return (columnFilters.value[field] || []).length > 0
if (isNumberRangeFilterField(field)) {
const filter = numberRangeFilters.value[field]
return String(filter?.min || '').trim() !== '' || String(filter?.max || '').trim() !== ''
}
return false
}
function getFilterBadgeValue (field) {
if (isMultiSelectFilterField(field)) return (columnFilters.value[field] || []).length
if (isNumberRangeFilterField(field)) {
const filter = numberRangeFilters.value[field]
return [filter?.min, filter?.max].filter(value => String(value || '').trim() !== '').length
}
return 0
}
function getFilterOptionsForField (field) {
return filteredFilterOptionMap.value[field] || []
}
function isColumnFilterValueSelected (field, value) {
return (columnFilters.value[field] || []).includes(value)
}
function toggleColumnFilterValue (field, value) {
const current = new Set(columnFilters.value[field] || [])
if (current.has(value)) current.delete(value)
else current.add(value)
columnFilters.value = { ...columnFilters.value, [field]: [...current] }
}
function selectAllColumnFilterOptions (field) {
columnFilters.value = {
...columnFilters.value,
[field]: getFilterOptionsForField(field).map(option => option.value)
}
}
function clearColumnFilter (field) {
columnFilters.value = { ...columnFilters.value, [field]: [] }
}
function updateNumberRangeFilter (field, side, value) {
numberRangeFilters.value = {
...numberRangeFilters.value,
[field]: { ...numberRangeFilters.value[field], [side]: value }
}
}
function clearRangeFilter (field) {
numberRangeFilters.value = {
...numberRangeFilters.value,
[field]: { min: '', max: '' }
}
}
function clearAllFilters () {
columnFilters.value = Object.fromEntries(multiFilterFields.map(field => [field, []]))
columnFilterSearch.value = Object.fromEntries(multiFilterFields.map(field => [field, '']))
numberRangeFilters.value = Object.fromEntries([...numericFields].map(field => [field, { min: '', max: '' }]))
}
async function loadRows () {
if (emptyRetryTimer) {
clearTimeout(emptyRetryTimer)
emptyRetryTimer = null
}
loading.value = true
try {
const res = await api.request({
method: 'GET',
url: '/pricing/pricing-rules/parameters',
timeout: 180000
})
rows.value = (Array.isArray(res?.data) ? res.data : []).map(normalizeWorksheetRow)
selected.value = []
if (rows.value.length === 0) {
emptyRetryTimer = setTimeout(loadRows, 10000)
}
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Kural kombinasyonlari alinamadi' })
} finally {
loading.value = false
}
}
async function saveSelected () {
const dirty = selected.value.filter(row => row?._dirty)
if (dirty.length === 0) return
saving.value = true
try {
const payload = {
items: dirty.map(row => {
const item = {
id: row.id,
pricing_parameter_id: row.pricing_parameter_id,
is_active: Boolean(row.is_active)
}
for (const key of numericFields) item[key] = finiteNumber(row[key], 0)
return item
})
}
await api.request({
method: 'POST',
url: '/pricing/pricing-rules/bulk-save',
data: payload,
timeout: 180000
})
Notify.create({ type: 'positive', message: `Kaydedildi: ${dirty.length}` })
await loadRows()
} catch (err) {
Notify.create({ type: 'negative', message: err?.response?.data || err?.message || 'Kurallar kaydedilemedi' })
} finally {
saving.value = false
}
}
onMounted(loadRows)
onBeforeUnmount(() => {
if (emptyRetryTimer) clearTimeout(emptyRetryTimer)
})
</script>
<style scoped>
.pricing-rules-page {
--rules-row-height: 31px;
--rules-header-height: 72px;
--rules-table-height: calc(100vh - 210px);
min-width: 0;
height: calc(100vh - 120px);
display: flex;
flex-direction: column;
overflow: hidden;
}
.top-bar,
.action-bar {
flex: 0 0 auto;
gap: 8px;
background: #fff;
position: relative;
z-index: 40;
}
.table-wrap {
flex: 1;
min-height: 0;
overflow: hidden;
border: 1px solid rgba(0, 0, 0, 0.12);
border-radius: 4px;
display: flex;
flex-direction: column;
}
.pane-table {
height: 100%;
width: 100%;
}
.rules-table :deep(.q-table__middle) {
height: var(--rules-table-height);
min-height: var(--rules-table-height);
max-height: var(--rules-table-height);
overflow: auto !important;
scrollbar-gutter: stable both-edges;
overscroll-behavior: contain;
}
.rules-table :deep(.q-table) {
width: max-content;
min-width: 100%;
table-layout: fixed;
font-size: 11px;
border-collapse: separate;
border-spacing: 0;
margin-right: var(--sticky-scroll-comp, 0px);
}
.rules-table :deep(.q-table__container) {
border: none !important;
box-shadow: none !important;
background: transparent !important;
height: 100% !important;
}
.rules-table :deep(th),
.rules-table :deep(td) {
box-sizing: border-box;
padding: 0 4px;
overflow: hidden;
vertical-align: middle;
}
.rules-table :deep(td),
.rules-table :deep(.q-table tbody tr) {
height: var(--rules-row-height) !important;
min-height: var(--rules-row-height) !important;
max-height: var(--rules-row-height) !important;
line-height: var(--rules-row-height);
padding: 0 !important;
border-bottom: 1px solid rgba(0, 0, 0, 0.08) !important;
}
.rules-table :deep(th),
.rules-table :deep(.q-table thead tr),
.rules-table :deep(.q-table thead tr.header-row-fixed),
.rules-table :deep(.q-table thead th),
.rules-table :deep(.q-table thead tr.header-row-fixed > th) {
height: var(--rules-header-height) !important;
min-height: var(--rules-header-height) !important;
max-height: var(--rules-header-height) !important;
}
.rules-table :deep(th) {
padding-top: 0;
padding-bottom: 0;
white-space: nowrap;
word-break: normal;
text-overflow: ellipsis;
text-align: center;
font-size: 10px;
font-weight: 800;
line-height: 1.15;
}
.rules-table :deep(.q-table thead th) {
position: sticky;
top: 0;
z-index: 30;
background: #fff;
vertical-align: middle !important;
}
.rules-table :deep(.sticky-col) {
position: sticky !important;
background-clip: padding-box;
}
.rules-table :deep(thead .sticky-col) {
z-index: 35 !important;
}
.rules-table :deep(tbody .sticky-col) {
z-index: 12 !important;
}
.rules-table :deep(.sticky-boundary) {
border-right: 2px solid rgba(25, 118, 210, 0.18) !important;
box-shadow: 8px 0 12px -10px rgba(15, 23, 42, 0.55);
}
.header-with-filter {
display: grid;
grid-template-columns: 1fr 20px;
align-items: center;
column-gap: 4px;
height: 100%;
line-height: 1.25;
overflow: hidden;
}
.header-with-filter > span {
min-width: 0;
width: 100%;
overflow: hidden;
text-align: center;
text-overflow: ellipsis;
white-space: normal;
font-weight: 800;
line-height: 1.15;
display: -webkit-box;
-webkit-line-clamp: 2;
-webkit-box-orient: vertical;
}
.header-filter-btn {
width: 20px;
height: 20px;
min-width: 20px;
justify-self: end;
}
.header-filter-ghost {
opacity: 0;
pointer-events: none;
}
.excel-filter-menu {
min-width: 230px;
padding: 8px;
}
.range-filter-grid {
display: grid;
grid-template-columns: 1fr;
gap: 8px;
}
.excel-filter-actions {
gap: 4px;
}
.excel-filter-options {
max-height: 220px;
margin-top: 8px;
overflow: auto;
border: 1px solid rgba(0, 0, 0, 0.08);
border-radius: 4px;
}
.excel-filter-option {
min-height: 32px;
}
.excel-filter-empty {
padding: 10px 8px;
color: #607d8b;
font-size: 11px;
}
.rules-table :deep(th.ps-col),
.rules-table :deep(td.ps-col) {
background: #fff;
color: var(--q-primary);
font-weight: 700;
}
.rules-table :deep(th.selection-col),
.rules-table :deep(td.selection-col) {
background: #fff;
color: var(--q-primary);
padding-left: 0 !important;
padding-right: 0 !important;
text-align: center !important;
}
.rules-table :deep(.selection-col .q-checkbox__inner) {
color: var(--q-primary);
font-size: 16px;
}
.rules-table :deep(th.usd-col),
.rules-table :deep(td.usd-col) {
background: #ecf9f0;
color: #178a3e;
font-weight: 700;
}
.rules-table :deep(th.eur-col),
.rules-table :deep(td.eur-col) {
background: #fdeeee;
color: #c62828;
font-weight: 700;
}
.rules-table :deep(th.try-col),
.rules-table :deep(td.try-col) {
background: #edf4ff;
color: #1e63c6;
font-weight: 700;
}
.cell-text {
display: block;
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
line-height: 1.1;
padding: 0 4px;
}
.native-cell-input {
width: 100%;
height: 22px;
box-sizing: border-box;
padding: 1px 3px;
border: 1px solid #cfd8dc;
border-radius: 4px;
background: #fff;
font-size: 11px;
margin: 0;
}
.native-cell-input:focus {
outline: none;
border-color: #1976d2;
}
</style>

View File

@@ -2,58 +2,106 @@
<q-page class="q-pa-xs pricing-page">
<div class="top-bar row items-center justify-between q-mb-xs">
<div class="text-subtitle1 text-weight-bold">Urun Fiyatlandirma</div>
<div class="row items-center q-gutter-xs">
<q-btn-dropdown color="secondary" outline icon="view_module" label="Doviz Gorunumu" :auto-close="false">
<q-list dense class="currency-menu-list">
<q-item clickable @click="selectAllCurrencies">
<q-item-section>Tumunu Sec</q-item-section>
</q-item>
<q-item clickable @click="clearAllCurrencies">
<q-item-section>Tumunu Temizle</q-item-section>
</q-item>
<q-separator />
<q-item v-for="option in currencyOptions" :key="option.value" clickable @click="toggleCurrencyRow(option.value)">
<q-item-section avatar>
<q-checkbox
:model-value="isCurrencySelected(option.value)"
dense
@update:model-value="(val) => toggleCurrency(option.value, val)"
@click.stop
/>
</q-item-section>
<q-item-section>{{ option.label }}</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
<q-btn
flat
:color="showSelectedOnly ? 'primary' : 'grey-7'"
:icon="showSelectedOnly ? 'checklist_rtl' : 'list_alt'"
:label="showSelectedOnly ? `Secililer (${selectedRowCount})` : 'Secili Olanlari Getir'"
:disable="!showSelectedOnly && selectedRowCount === 0"
@click="toggleShowSelectedOnly"
/>
<q-btn flat color="grey-7" icon="restart_alt" label="Filtreleri Sifirla" @click="resetAll" />
<q-btn color="primary" icon="refresh" label="Veriyi Yenile" :loading="store.loading" @click="reloadData" />
<q-btn
color="primary"
outline
icon="edit_note"
label="Secili Olanlari Toplu Degistir"
:disable="selectedRowCount === 0"
@click="bulkDialogOpen = true"
/>
<q-pagination
v-model="currentPage"
color="primary"
:max="Math.max(1, store.totalPages || 1)"
:max-pages="8"
boundary-links
direction-links
@update:model-value="onPageChange"
/>
<div class="text-caption text-grey-8">
Sayfa {{ currentPage }} / {{ Math.max(1, store.totalPages || 1) }} - Toplam {{ store.totalCount || 0 }} urun kodu
<div class="top-actions">
<div class="row items-center q-gutter-xs top-actions-row">
<q-select
v-model="topUrunIlkGrubu"
dense
outlined
clearable
emit-value
map-options
:options="topUrunIlkGrubuOptions"
:loading="Boolean(serverFilterLoading.urunIlkGrubu)"
label="Urun Ilk Grubu"
style="min-width: 220px"
@filter="onTopFilterSearchUrunIlkGrubu"
@update:model-value="onTopUrunIlkGrubuChange"
/>
<q-select
v-model="topUrunAnaGrubu"
dense
outlined
clearable
multiple
use-chips
emit-value
map-options
:options="topUrunAnaGrubuOptions"
:loading="Boolean(serverFilterLoading.urunAnaGrubu)"
label="Urun Ana Grubu (max 3)"
style="min-width: 260px"
@filter="onTopFilterSearchUrunAnaGrubu"
@update:model-value="onTopUrunAnaGrubuChange"
/>
<q-btn
color="primary"
icon="filter_alt"
label="Gruplari Getir"
:disable="!canFetchByGroup"
:loading="store.loading"
@click="reloadData({ page: 1 })"
/>
<q-btn
flat
color="grey-7"
icon="restart_alt"
label="Secimleri Sifirla"
@click="resetGroupSelections"
/>
</div>
<div class="row items-center q-gutter-xs top-actions-row">
<q-btn-dropdown color="secondary" outline icon="view_module" label="Doviz Gorunumu" :auto-close="false">
<q-list dense class="currency-menu-list">
<q-item clickable @click="selectAllCurrencies">
<q-item-section>Tumunu Sec</q-item-section>
</q-item>
<q-item clickable @click="clearAllCurrencies">
<q-item-section>Tumunu Temizle</q-item-section>
</q-item>
<q-separator />
<q-item v-for="option in currencyOptions" :key="option.value" clickable @click="toggleCurrencyRow(option.value)">
<q-item-section avatar>
<q-checkbox
:model-value="isCurrencySelected(option.value)"
dense
@update:model-value="(val) => toggleCurrency(option.value, val)"
@click.stop
/>
</q-item-section>
<q-item-section>{{ option.label }}</q-item-section>
</q-item>
</q-list>
</q-btn-dropdown>
<q-btn
flat
:color="showSelectedOnly ? 'primary' : 'grey-7'"
:icon="showSelectedOnly ? 'checklist_rtl' : 'list_alt'"
:label="showSelectedOnly ? `Secililer (${selectedRowCount})` : 'Secili Olanlari Getir'"
:disable="!showSelectedOnly && selectedRowCount === 0"
@click="toggleShowSelectedOnly"
/>
<q-btn
color="primary"
outline
icon="edit_note"
label="Secili Olanlari Toplu Degistir"
:disable="selectedRowCount === 0"
@click="bulkDialogOpen = true"
/>
<q-pagination
v-model="currentPage"
color="primary"
:max="Math.max(1, store.totalPages || 1)"
:max-pages="8"
boundary-links
direction-links
@update:model-value="onPageChange"
/>
<div class="text-caption text-grey-8">
Sayfa {{ currentPage }} / {{ Math.max(1, store.totalPages || 1) }} - Toplam {{ store.totalCount || 0 }} urun kodu
</div>
</div>
</div>
</div>
@@ -106,7 +154,12 @@
<q-badge v-if="hasFilter(col.field)" color="primary" floating rounded>
{{ getFilterBadgeValue(col.field) }}
</q-badge>
<q-menu anchor="bottom right" self="top right" :offset="[0, 4]">
<q-menu
anchor="bottom right"
self="top right"
:offset="[0, 4]"
@before-show="() => onFilterMenuBeforeShow(col.field)"
>
<div v-if="isMultiSelectFilterField(col.field)" class="excel-filter-menu">
<q-input
v-model="columnFilterSearch[col.field]"
@@ -333,6 +386,24 @@
</q-td>
</template>
<template #body-cell-lastCostingDate="props">
<q-td
:props="props"
:class="[
{ 'sticky-col': isStickyCol(props.col.name), 'sticky-boundary': isStickyBoundary(props.col.name) },
{ 'cell-danger': needsCosting(props.row) }
]"
:style="getBodyCellStyle(props.col)"
>
<span :class="['date-cell-text', { 'text-white': needsCosting(props.row) }]">
{{ formatDateDisplay(props.value) }}
</span>
<q-tooltip v-if="needsCosting(props.row)" anchor="top middle" self="bottom middle" :offset="[0, 6]">
Stok girisinden sonra maliyetlendirme yapilmamis. Urun Ilk Grubu: {{ props.row.urunIlkGrubu || '-' }}
</q-tooltip>
</q-td>
</template>
<template #body-cell-lastPricingDate="props">
<q-td
:props="props"
@@ -351,16 +422,9 @@
:class="{ 'sticky-col': isStickyCol(props.col.name), 'sticky-boundary': isStickyBoundary(props.col.name) }"
:style="getBodyCellStyle(props.col)"
>
<select
class="native-cell-select"
:value="props.row.brandGroupSelection"
@change="(e) => onBrandGroupSelectionChange(props.row, e.target.value)"
>
<option value="">Seciniz</option>
<option v-for="opt in brandGroupOptions" :key="opt.value" :value="opt.value">
{{ opt.label }}
</option>
</select>
<span class="cell-text" :title="props.row.brandGroupSelection || ''">
{{ props.row.brandGroupSelection || '' }}
</span>
</q-td>
</template>
@@ -428,9 +492,10 @@
<script setup>
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useProductPricingStore } from 'src/stores/ProductPricingStore'
import api from 'src/services/api'
const store = useProductPricingStore()
const PAGE_LIMIT = 500
const PAGE_LIMIT = 250
const currentPage = ref(1)
let reloadTimer = null
@@ -440,11 +505,7 @@ const multipliers = [1, 1.03, 1.06, 1.09, 1.12, 1.15]
const rowHeight = 31
const headerHeight = 72
const brandGroupOptions = [
{ label: 'MARKA GRUBU A', value: 'MARKA GRUBU A' },
{ label: 'MARKA GRUBU B', value: 'MARKA GRUBU B' },
{ label: 'MARKA GRUBU C', value: 'MARKA GRUBU C' }
]
// Marka grubu artik Marka Siniflandirma modulunden (mk_brandgrp) gelir ve listede sadece goruntulenir.
const currencyOptions = [
{ label: 'USD', value: 'USD' },
@@ -454,7 +515,7 @@ const currencyOptions = [
const multiFilterColumns = [
{ field: 'productCode', label: 'Urun Kodu' },
{ field: 'brandGroupSelection', label: 'Marka Grubu Secimi' },
{ field: 'brandGroupSelection', label: 'Marka Grubu' },
{ field: 'marka', label: 'Marka' },
{ field: 'askiliYan', label: 'Askili Yan' },
{ field: 'kategori', label: 'Kategori' },
@@ -466,7 +527,6 @@ const multiFilterColumns = [
]
const serverBackedMultiFilterFields = new Set([
'productCode',
'brandGroupSelection',
'marka',
'askiliYan',
'kategori',
@@ -526,6 +586,130 @@ const columnFilterSearch = ref({
icerik: '',
karisim: ''
})
const serverFilterOptionMap = ref({})
const serverFilterLoading = ref({})
const serverFilterLastQuery = ref({})
const serverFilterTimers = {}
const topUrunIlkGrubu = ref(null)
const topUrunAnaGrubu = ref([])
const topUrunIlkGrubuOptions = computed(() => serverFilterOptionMap.value.urunIlkGrubu || [])
const topUrunAnaGrubuOptions = computed(() => serverFilterOptionMap.value.urunAnaGrubu || [])
const canFetchByGroup = computed(() => {
return Boolean(String(topUrunIlkGrubu.value || '').trim()) || (topUrunAnaGrubu.value?.length || 0) > 0
})
async function fetchServerFilterOptions (field, { force = false } = {}) {
if (!serverBackedMultiFilterFields.has(field)) return
const q = String(columnFilterSearch.value[field] || '').trim()
const lastQ = String(serverFilterLastQuery.value[field] || '')
const hasCached = Array.isArray(serverFilterOptionMap.value[field]) && serverFilterOptionMap.value[field].length > 0
if (!force && hasCached && q === lastQ) return
if (serverFilterLoading.value[field]) return
serverFilterLoading.value = { ...serverFilterLoading.value, [field]: true }
serverFilterLastQuery.value = { ...serverFilterLastQuery.value, [field]: q }
try {
const params = { field, q, limit: 160 }
// Cascade scope for Urun Ana Grubu options.
if (field === 'urunAnaGrubu') {
const ilk = String(topUrunIlkGrubu.value || '').trim()
if (ilk) params.urun_ilk_grubu = [ilk]
}
const res = await api.get('/pricing/products/options', { params })
const items = Array.isArray(res?.data?.items) ? res.data.items : []
serverFilterOptionMap.value = {
...serverFilterOptionMap.value,
[field]: items.map((x) => ({
label: String(x?.label ?? x?.value ?? '').trim(),
value: String(x?.value ?? x?.label ?? '').trim()
})).filter((x) => x.value)
}
} catch (err) {
console.warn('[product-pricing][ui] filter options error', {
field,
q,
message: String(err?.message || err || 'options failed')
})
serverFilterOptionMap.value = { ...serverFilterOptionMap.value, [field]: [] }
} finally {
serverFilterLoading.value = { ...serverFilterLoading.value, [field]: false }
}
}
function scheduleServerFilterOptionsFetch (field) {
if (!serverBackedMultiFilterFields.has(field)) return
if (serverFilterTimers[field]) clearTimeout(serverFilterTimers[field])
serverFilterTimers[field] = setTimeout(() => {
serverFilterTimers[field] = null
void fetchServerFilterOptions(field)
}, 220)
}
function onFilterMenuBeforeShow (field) {
if (!serverBackedMultiFilterFields.has(field)) return
void fetchServerFilterOptions(field)
}
function onTopFilterSearchUrunIlkGrubu (val, update) {
update(() => {
columnFilterSearch.value = { ...columnFilterSearch.value, urunIlkGrubu: String(val || '') }
scheduleServerFilterOptionsFetch('urunIlkGrubu')
})
}
function onTopFilterSearchUrunAnaGrubu (val, update) {
update(() => {
columnFilterSearch.value = { ...columnFilterSearch.value, urunAnaGrubu: String(val || '') }
scheduleServerFilterOptionsFetch('urunAnaGrubu')
})
}
function applyTopGroupFiltersToColumnFilters () {
// Enforce max 3 selection for Urun Ana Grubu.
const nextAna = Array.isArray(topUrunAnaGrubu.value) ? topUrunAnaGrubu.value.slice(0, 3) : []
if (nextAna.length !== (topUrunAnaGrubu.value || []).length) topUrunAnaGrubu.value = nextAna
const ilk = String(topUrunIlkGrubu.value || '').trim()
columnFilters.value = {
...columnFilters.value,
urunIlkGrubu: ilk ? [ilk] : [],
urunAnaGrubu: nextAna
}
}
function onTopUrunIlkGrubuChange () {
// Cascade: when Ilk Grubu changes, clear Ana Grubu selection and refetch options scoped by Ilk Grubu.
topUrunAnaGrubu.value = []
applyTopGroupFiltersToColumnFilters()
void fetchServerFilterOptions('urunAnaGrubu', { force: true })
}
function onTopUrunAnaGrubuChange () {
applyTopGroupFiltersToColumnFilters()
}
function resetGroupSelections () {
topUrunIlkGrubu.value = null
topUrunAnaGrubu.value = []
applyTopGroupFiltersToColumnFilters()
// Keep other local filters cleared too, so page is "clean render".
store.rows = []
store.error = 'Performans icin once Urun Ilk Grubu veya Urun Ana Grubu secin.'
store.totalCount = 0
store.totalPages = 1
store.page = 1
store.hasMore = false
}
for (const field of Array.from(serverBackedMultiFilterFields)) {
watch(
() => columnFilterSearch.value[field],
() => { scheduleServerFilterOptionsFetch(field) }
)
}
const numberRangeFilters = ref({
stockQty: { min: '', max: '' }
})
@@ -608,6 +792,7 @@ const allColumns = [
col('calcAction', 'HESAPLA', 'calcAction', 72, { align: 'center', classes: 'ps-col' }),
col('stockQty', 'STOK ADET', 'stockQty', 72, { align: 'right', sortable: true, classes: 'ps-col stock-col' }),
col('stockEntryDate', 'STOK GIRIS TARIHI', 'stockEntryDate', 92, { align: 'center', sortable: true, classes: 'ps-col date-col' }),
col('lastCostingDate', 'SON MALIYETLENDIRME', 'lastCostingDate', 110, { align: 'center', sortable: true, classes: 'ps-col date-col' }),
col('lastPricingDate', 'SON FIYATLANDIRMA TARIHI', 'lastPricingDate', 108, { align: 'center', sortable: true, classes: 'ps-col date-col' }),
col('askiliYan', 'ASKILI YAN', 'askiliYan', 54, { sortable: true, classes: 'ps-col' }),
col('kategori', 'KATEGORI', 'kategori', 54, { sortable: true, classes: 'ps-col' }),
@@ -880,6 +1065,9 @@ function clearRangeFilter (field) {
function getFilterOptionsForField (field) {
if (isValueSelectFilterField(field)) return filteredValueFilterOptionMap.value[field] || []
if (serverBackedMultiFilterFields.has(field)) {
return serverFilterOptionMap.value[field] || []
}
return filteredFilterOptionMap.value[field] || []
}
@@ -1025,6 +1213,14 @@ function needsRepricing (row) {
return lastPricingDate < stockEntryDate
}
function needsCosting (row) {
const stockEntryDate = String(row?.stockEntryDate || '').trim()
const lastCostingDate = String(row?.lastCostingDate || '').trim()
if (!stockEntryDate) return false
if (!lastCostingDate) return true
return lastCostingDate < stockEntryDate
}
function recalcByBasePrice (row) {
row.basePriceTry = round2((row.basePriceUsd * usdToTry) + row.expenseForBasePrice)
multipliers.forEach((multiplier, index) => {
@@ -1047,7 +1243,7 @@ function calculateRow (row) {
}
function onBrandGroupSelectionChange (row, val) {
store.updateBrandGroupSelection(row, val)
// no-op (read-only)
}
function isRowSelected (rowKey) {
@@ -1150,12 +1346,20 @@ function clearAllCurrencies () {
}
function onPaginationChange (next) {
const prevSortBy = tablePagination.value.sortBy
const prevDesc = tablePagination.value.descending
tablePagination.value = {
...tablePagination.value,
...(next || {}),
page: 1,
rowsPerPage: 0
}
const nextSortBy = tablePagination.value.sortBy
const nextDesc = tablePagination.value.descending
if (nextSortBy !== prevSortBy || nextDesc !== prevDesc) {
currentPage.value = 1
void reloadData({ page: 1 })
}
}
function buildServerFilters () {
@@ -1182,12 +1386,36 @@ function scheduleReload () {
}
async function fetchChunk ({ page = 1 } = {}) {
const filters = buildServerFilters()
const hasAnyFilter = Object.values(filters).some((v) => Array.isArray(v) && v.length > 0)
const hasPrimaryFilter = (filters.urun_ilk_grubu?.length || 0) > 0 || (filters.urun_ana_grubu?.length || 0) > 0
if (!hasAnyFilter) {
// This endpoint is expensive without filters; require the user to scope down first.
store.rows = []
store.error = 'Liste cok buyuk. Lutfen en az bir filtre secin (or: Urun Ilk Grubu / Urun Ana Grubu / Urun Kodu).'
store.totalCount = 0
store.totalPages = 1
store.page = 1
store.hasMore = false
return 0
}
if (!hasPrimaryFilter) {
store.rows = []
store.error = 'Performans icin once Urun Ilk Grubu veya Urun Ana Grubu secin.'
store.totalCount = 0
store.totalPages = 1
store.page = 1
store.hasMore = false
return 0
}
const result = await store.fetchRows({
limit: PAGE_LIMIT,
page,
append: false,
silent: false,
filters: buildServerFilters()
filters,
sortBy: tablePagination.value.sortBy,
descending: tablePagination.value.descending
})
currentPage.value = Number(result?.page) || page
return Number(result?.fetched) || 0
@@ -1211,9 +1439,10 @@ async function reloadData ({ page = 1 } = {}) {
row_count: Array.isArray(store.rows) ? store.rows.length : 0,
has_error: Boolean(store.error)
})
selectedMap.value = {}
}
// Full "fetch all pages" is intentionally avoided; keep server-side paging for performance.
function onPageChange (page) {
const p = Number(page) > 0 ? Number(page) : 1
if (store.loading) return
@@ -1223,7 +1452,16 @@ function onPageChange (page) {
}
onMounted(async () => {
await reloadData({ page: currentPage.value })
// Prefetch a couple of common filters so the first open is not empty.
void fetchServerFilterOptions('urunIlkGrubu')
void fetchServerFilterOptions('urunAnaGrubu')
// Do not auto-fetch listing on mount; user must scope by group first.
store.rows = []
store.error = 'Performans icin once Urun Ilk Grubu veya Urun Ana Grubu secin.'
store.totalCount = 0
store.totalPages = 1
store.page = 1
store.hasMore = false
})
onBeforeUnmount(() => {
@@ -1233,11 +1471,7 @@ onBeforeUnmount(() => {
}
})
watch(
[columnFilters],
() => { scheduleReload() },
{ deep: true }
)
// NOTE: Listing fetch is intentionally manual via "Gruplari Getir" for performance.
</script>
<style scoped>
@@ -1256,6 +1490,18 @@ watch(
min-width: 170px;
}
.top-actions {
display: flex;
flex-direction: column;
align-items: flex-end;
gap: 6px;
}
.top-actions-row {
flex-wrap: wrap;
justify-content: flex-end;
}
.table-wrap {
flex: 1;
min-height: 0;
@@ -1489,6 +1735,10 @@ watch(
color: #c62828;
}
.cell-danger {
background: #c62828 !important;
}
.pricing-table :deep(th.selection-col),
.pricing-table :deep(td.selection-col) {
background: #fff;

View File

@@ -124,9 +124,21 @@
:rows-per-page-options="[0]"
hide-bottom
>
<template #body-cell="props">
<q-td :props="props" :class="props.col.classes">
<template v-if="props.col.name === 'open'">
<template #body="props">
<q-tr
:id="rowDomId(props.row)"
:props="props"
class="rdp-data-row"
@mouseenter="showMembersPopup(props.row)"
@mouseleave="scheduleHideMembersPopup"
>
<q-td
v-for="col in props.cols"
:key="col.name"
:props="props"
:class="col.classes"
>
<template v-if="col.name === 'open'">
<div class="text-center">
<q-btn
icon="open_in_new"
@@ -141,10 +153,10 @@
</div>
</template>
<template v-else-if="isPermissionColumn(props.col.name)">
<template v-else-if="isPermissionColumn(col.name)">
<div class="text-center">
<q-checkbox
:model-value="Boolean(props.value)"
:model-value="Boolean(col.value)"
disable
dense
/>
@@ -152,12 +164,43 @@
</template>
<template v-else>
{{ props.value }}
{{ col.value }}
</template>
</q-td>
</q-tr>
</template>
</q-table>
<q-menu
v-model="membersPopupOpen"
:target="membersPopupTarget"
no-parent-event
anchor="center right"
self="center left"
:offset="[8, 0]"
class="rdp-members-popup"
@mouseenter="cancelHideMembersPopup"
@mouseleave="scheduleHideMembersPopup"
>
<div class="rdp-members-popup__header">
<div class="text-weight-bold">{{ hoveredRow?.role_title }}</div>
<div class="text-caption text-grey-7">{{ hoveredRow?.department_title }}</div>
</div>
<q-separator />
<q-list v-if="hoveredMembers.length" dense class="rdp-members-popup__list">
<q-item v-for="member in hoveredMembers" :key="member.id">
<q-item-section avatar class="rdp-member-id">{{ member.id }}</q-item-section>
<q-item-section>
<q-item-label>{{ member.full_name || member.username }}</q-item-label>
<q-item-label caption>{{ member.username }}</q-item-label>
</q-item-section>
</q-item>
</q-list>
<div v-else class="q-pa-sm text-caption text-grey-7">
Bu grupta aktif kullanici bulunmuyor.
</div>
</q-menu>
<q-banner v-if="store.error" class="bg-red text-white q-mt-sm">
Hata: {{ store.error }}
</q-banner>
@@ -171,7 +214,7 @@
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { useQuasar } from 'quasar'
import { usePermission } from 'src/composables/usePermission'
@@ -187,6 +230,10 @@ const selectedModules = ref([])
const selectedActionsByModule = ref({})
const activeModuleCode = ref('')
const allowEmptySelection = ref(false)
const membersPopupOpen = ref(false)
const membersPopupTarget = ref(false)
const hoveredRow = ref(null)
let membersPopupHideTimer = null
const actionLabelMap = {
update: 'Güncelleme',
@@ -428,6 +475,37 @@ const tableRows = computed(() =>
}))
)
const hoveredMembers = computed(() => hoveredRow.value?.members || [])
function rowDomId (row) {
const roleID = String(row?.role_id || '0').replace(/[^a-zA-Z0-9_-]/g, '-')
const departmentCode = String(row?.department_code || '').replace(/[^a-zA-Z0-9_-]/g, '-')
return `rdp-row-${roleID}-${departmentCode}`
}
function cancelHideMembersPopup () {
if (!membersPopupHideTimer) return
clearTimeout(membersPopupHideTimer)
membersPopupHideTimer = null
}
function showMembersPopup (row) {
cancelHideMembersPopup()
hoveredRow.value = row
membersPopupTarget.value = `#${rowDomId(row)}`
membersPopupOpen.value = true
}
function scheduleHideMembersPopup () {
cancelHideMembersPopup()
membersPopupHideTimer = setTimeout(() => {
membersPopupOpen.value = false
hoveredRow.value = null
membersPopupTarget.value = false
membersPopupHideTimer = null
}, 180)
}
function isPermissionColumn (name) {
return String(name || '').startsWith('perm_')
}
@@ -472,6 +550,10 @@ onMounted(async () => {
syncSelections()
}
})
onBeforeUnmount(() => {
cancelHideMembersPopup()
})
</script>
<style scoped>
@@ -571,6 +653,30 @@ onMounted(async () => {
padding: 3px 6px;
}
.rdp-data-row {
cursor: default;
}
.rdp-members-popup {
min-width: 280px;
max-width: 380px;
}
.rdp-members-popup__header {
padding: 10px 12px 8px;
}
.rdp-members-popup__list {
max-height: 360px;
overflow: auto;
}
.rdp-member-id {
min-width: 48px;
color: #1976d2;
font-weight: 700;
}
.rdp-table :deep(.q-checkbox__inner) {
pointer-events: none;
}

View File

@@ -86,6 +86,63 @@
</div>
<div
v-if="lookupsLoaded && roleId && deptCode"
class="group-members-toolbar"
>
<div class="group-members-toolbar__members">
<div class="text-caption text-weight-bold">
Grup Kullanicilari ({{ members.length }})
</div>
<div v-if="membersLoading" class="q-ml-sm">
<q-spinner color="primary" size="18px" />
</div>
<div v-else-if="members.length" class="group-members-toolbar__chips">
<q-chip
v-for="member in members"
:key="member.id"
dense
square
color="blue-1"
text-color="primary"
>
{{ member.id }} - {{ member.full_name || member.username }}
<q-tooltip>{{ member.username }}</q-tooltip>
</q-chip>
</div>
<div v-else class="text-caption text-grey-7 q-ml-sm">
Bu grupta aktif kullanici bulunmuyor.
</div>
</div>
<div class="group-members-toolbar__add">
<q-select
v-model="memberUserId"
:options="filteredUserOptions"
option-value="id"
option-label="title"
emit-value
map-options
dense
outlined
clearable
use-input
input-debounce="150"
label="Kullanici ekle"
class="group-members-toolbar__select"
@filter="filterUsers"
/>
<q-btn
color="primary"
icon="person_add"
label="Ekle"
:disable="!memberUserId || addingMember"
:loading="addingMember"
@click="addMember"
/>
</div>
</div>
</div>
@@ -184,7 +241,7 @@
<script setup>
import { ref, onMounted, watch } from 'vue'
import { computed, ref, onMounted, watch } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { Notify } from 'quasar'
import api from 'src/services/api'
@@ -201,13 +258,19 @@ const router = useRouter()
const roles = ref([])
const departments = ref([])
const users = ref([])
const filteredUserOptions = ref([])
const members = ref([])
const roleId = ref(null)
const deptCode = ref(null)
const memberUserId = ref(null)
const rows = ref([])
const loading = ref(false)
const membersLoading = ref(false)
const addingMember = ref(false)
const dirty = ref(false)
const lookupsLoaded = ref(false)
@@ -274,15 +337,18 @@ function applyRouteSelection () {
async function loadLookups () {
const [r, d, m] = await Promise.all([
const [r, d, m, u] = await Promise.all([
api.get('/lookups/roles-perm'),
api.get('/lookups/departments-perm'),
api.get('/lookups/modules')
api.get('/lookups/modules'),
api.get('/lookups/users-perm')
])
roles.value = r.data || []
departments.value = d.data || []
modules.value = m.data || []
users.value = u.data || []
filteredUserOptions.value = [...users.value]
lookupsLoaded.value = true
}
@@ -312,7 +378,10 @@ function initMatrix () {
async function loadMatrix () {
if (!roleId.value || !deptCode.value) return
if (!roleId.value || !deptCode.value) {
members.value = []
return
}
if (matrixLoading) return
matrixLoading = true
@@ -326,9 +395,10 @@ async function loadMatrix () {
initMatrix()
const res = await api.get(
`/roles/${roleId.value}/departments/${deptCode.value}/permissions`
)
const [res] = await Promise.all([
api.get(`/roles/${roleId.value}/departments/${deptCode.value}/permissions`),
loadMembers()
])
const list = Array.isArray(res.data) ? res.data : []
@@ -384,6 +454,70 @@ async function loadMatrix () {
}
}
const availableUserOptions = computed(() => {
const memberIDs = new Set(members.value.map(member => Number(member.id)))
return users.value.filter(user => !memberIDs.has(Number(user.id)))
})
function filterUsers (value, update) {
update(() => {
const needle = String(value || '').trim().toLocaleLowerCase('tr')
filteredUserOptions.value = needle
? availableUserOptions.value.filter(user => String(user.title || '').toLocaleLowerCase('tr').includes(needle))
: [...availableUserOptions.value]
})
}
async function loadMembers () {
if (!roleId.value || !deptCode.value) {
members.value = []
return
}
membersLoading.value = true
try {
const res = await api.get(
`/roles/${roleId.value}/departments/${encodeURIComponent(deptCode.value)}/members`
)
members.value = Array.isArray(res.data) ? res.data : []
filteredUserOptions.value = [...availableUserOptions.value]
} catch (err) {
console.error('GROUP MEMBERS LOAD ERROR:', err)
members.value = []
Notify.create({
type: 'negative',
message: 'Grup kullanicilari yuklenemedi'
})
} finally {
membersLoading.value = false
}
}
async function addMember () {
if (!roleId.value || !deptCode.value || !memberUserId.value) return
addingMember.value = true
try {
const res = await api.post(
`/roles/${roleId.value}/departments/${encodeURIComponent(deptCode.value)}/members`,
{ user_id: Number(memberUserId.value) }
)
members.value = Array.isArray(res.data) ? res.data : []
memberUserId.value = null
filteredUserOptions.value = [...availableUserOptions.value]
Notify.create({
type: 'positive',
message: 'Kullanici gruba eklendi'
})
} catch (err) {
console.error('GROUP MEMBER ADD ERROR:', err)
Notify.create({
type: 'negative',
message: 'Kullanici gruba eklenemedi'
})
} finally {
addingMember.value = false
}
}
/* ================= SAVE ================= */
@@ -482,3 +616,53 @@ watch(
)
</script>
<style scoped>
.group-members-toolbar {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 8px 12px;
background: #fff;
border-top: 1px solid #e0e0e0;
border-bottom: 1px solid #e0e0e0;
}
.group-members-toolbar__members {
min-width: 0;
display: flex;
align-items: center;
flex: 1 1 auto;
}
.group-members-toolbar__chips {
min-width: 0;
display: flex;
gap: 4px;
overflow-x: auto;
padding-left: 8px;
}
.group-members-toolbar__add {
display: flex;
align-items: center;
gap: 8px;
flex: 0 0 auto;
}
.group-members-toolbar__select {
width: 300px;
}
@media (max-width: 1100px) {
.group-members-toolbar {
align-items: stretch;
flex-direction: column;
}
.group-members-toolbar__select {
width: min(100%, 420px);
}
}
</style>

View File

@@ -341,53 +341,94 @@ const routes = [
},
/* ================= 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: 'order:view' }
meta: { permission: 'pricing:view' }
},
{
path: 'pricing/production-product-costing',
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: 'order:view' }
meta: { permission: 'costing:view' }
},
{
path: 'pricing/production-product-costing/has-cost',
path: 'costing/production-product-costing/has-cost',
name: 'production-product-costing-has-cost',
component: () => import('pages/ProductionProductCostingHasCost.vue'),
meta: { permission: 'order:view' }
meta: { permission: 'costing:view' }
},
{
path: 'pricing/production-product-costing/has-cost/history',
path: 'costing/production-product-costing/has-cost/history',
name: 'production-product-costing-has-cost-history',
component: () => import('pages/ProductionProductCostingHasCostHistory.vue'),
meta: { permission: 'order:view' }
meta: { permission: 'costing:view' }
},
{
path: 'pricing/production-product-costing/has-cost/detail',
path: 'costing/production-product-costing/has-cost/detail',
name: 'production-product-costing-has-cost-detail',
component: () => import('pages/ProductionProductCostingHasCostDetail.vue'),
meta: { permission: 'order:view' }
meta: { permission: 'costing:view' }
},
{
path: 'pricing/production-product-costing/no-cost',
path: 'costing/production-product-costing/no-cost',
name: 'production-product-costing-no-cost',
component: () => import('pages/ProductionProductCostingNoCost.vue'),
meta: { permission: 'order:view' }
meta: { permission: 'costing:view' }
},
{
path: 'pricing/production-product-costing/maliyet-parca-eslestirme',
path: 'costing/production-product-costing/maliyet-parca-eslestirme',
name: 'production-product-costing-maliyet-parca-eslestirme',
component: () => import('pages/ProductionProductCostingMTBolumMapping.vue'),
meta: { permission: 'order:view' }
meta: { permission: 'costing:view' }
},
{
path: 'pricing/production-product-costing/default-quantities',
path: 'costing/production-product-costing/default-quantities',
name: 'production-product-costing-default-quantities',
component: () => import('pages/ProductionProductCostingDefaultQuantities.vue'),
meta: { permission: 'order:view' }
meta: { permission: 'costing:view' }
},

View File

@@ -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
@@ -170,10 +185,13 @@ export const useProductPricingStore = defineStore('product-pricing-store', {
timeout: 180000
})
const totalCount = Number(res?.headers?.['x-total-count'] || 0)
const totalPages = Math.max(1, Number(res?.headers?.['x-total-pages'] || 1))
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
@@ -254,10 +279,14 @@ export const useProductPricingStore = defineStore('product-pricing-store', {
})
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))
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)

View File

@@ -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) {