Merge remote-tracking branch 'origin/master'
This commit is contained in:
51
svc/brand_sync_scheduler.go
Normal file
51
svc/brand_sync_scheduler.go
Normal 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")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
149
svc/main.go
149
svc/main.go
@@ -6,6 +6,7 @@ import (
|
|||||||
"bssapp-backend/internal/mailer"
|
"bssapp-backend/internal/mailer"
|
||||||
"bssapp-backend/middlewares"
|
"bssapp-backend/middlewares"
|
||||||
"bssapp-backend/permissions"
|
"bssapp-backend/permissions"
|
||||||
|
"bssapp-backend/queries"
|
||||||
"bssapp-backend/repository"
|
"bssapp-backend/repository"
|
||||||
"bssapp-backend/routes"
|
"bssapp-backend/routes"
|
||||||
"bssapp-backend/utils"
|
"bssapp-backend/utils"
|
||||||
@@ -109,6 +110,12 @@ func autoRegisterRouteV3(
|
|||||||
|
|
||||||
// 2) MODULE LOOKUP AUTO SEED (permission ekranları için)
|
// 2) MODULE LOOKUP AUTO SEED (permission ekranları için)
|
||||||
moduleLabel := strings.TrimSpace(strings.ReplaceAll(module, "_", " "))
|
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 == "" {
|
if moduleLabel == "" {
|
||||||
moduleLabel = module
|
moduleLabel = module
|
||||||
}
|
}
|
||||||
@@ -460,6 +467,16 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
|
|||||||
"system", "update",
|
"system", "update",
|
||||||
wrapV3(http.HandlerFunc(rdHandler.Save)),
|
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
|
// USERS
|
||||||
@@ -770,167 +787,217 @@ func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router
|
|||||||
)
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/products", "GET",
|
"/api/pricing/products", "GET",
|
||||||
"order", "view",
|
"pricing", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.GetProductPricingListHandler)),
|
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,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/production-product-costing/no-cost-products", "GET",
|
"/api/pricing/production-product-costing/no-cost-products", "GET",
|
||||||
"order", "view",
|
"costing", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.GetProductionNoCostProductsHandler)),
|
wrapV3(http.HandlerFunc(routes.GetProductionNoCostProductsHandler)),
|
||||||
)
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/production-product-costing/has-cost-products", "GET",
|
"/api/pricing/production-product-costing/has-cost-products", "GET",
|
||||||
"order", "view",
|
"costing", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.GetProductionHasCostProductsHandler)),
|
wrapV3(http.HandlerFunc(routes.GetProductionHasCostProductsHandler)),
|
||||||
)
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/production-product-costing/has-cost-history", "GET",
|
"/api/pricing/production-product-costing/has-cost-history", "GET",
|
||||||
"order", "view",
|
"costing", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.GetProductionHasCostHistoryHandler)),
|
wrapV3(http.HandlerFunc(routes.GetProductionHasCostHistoryHandler)),
|
||||||
)
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/production-product-costing/has-cost-detail-groups", "GET",
|
"/api/pricing/production-product-costing/has-cost-detail-groups", "GET",
|
||||||
"order", "view",
|
"costing", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.GetProductionHasCostDetailGroupsHandler)),
|
wrapV3(http.HandlerFunc(routes.GetProductionHasCostDetailGroupsHandler)),
|
||||||
)
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/production-product-costing/has-cost-detail-header", "GET",
|
"/api/pricing/production-product-costing/has-cost-detail-header", "GET",
|
||||||
"order", "view",
|
"costing", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.GetProductionHasCostDetailHeaderHandler)),
|
wrapV3(http.HandlerFunc(routes.GetProductionHasCostDetailHeaderHandler)),
|
||||||
)
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/production-product-costing/production-types", "GET",
|
"/api/pricing/production-product-costing/production-types", "GET",
|
||||||
"order", "view",
|
"costing", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.GetProductionTypesHandler)),
|
wrapV3(http.HandlerFunc(routes.GetProductionTypesHandler)),
|
||||||
)
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/production-product-costing/detail-editor-options", "GET",
|
"/api/pricing/production-product-costing/detail-editor-options", "GET",
|
||||||
"order", "view",
|
"costing", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.GetProductionHasCostDetailEditorOptionsHandler)),
|
wrapV3(http.HandlerFunc(routes.GetProductionHasCostDetailEditorOptionsHandler)),
|
||||||
)
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/production-product-costing/has-cost-detail-exchange-rates", "GET",
|
"/api/pricing/production-product-costing/has-cost-detail-exchange-rates", "GET",
|
||||||
"order", "view",
|
"costing", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.GetProductionHasCostDetailExchangeRatesHandler)),
|
wrapV3(http.HandlerFunc(routes.GetProductionHasCostDetailExchangeRatesHandler)),
|
||||||
)
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/production-product-costing/has-cost-detail-line-history", "GET",
|
"/api/pricing/production-product-costing/has-cost-detail-line-history", "GET",
|
||||||
"order", "view",
|
"costing", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.GetProductionHasCostDetailLineHistoryHandler)),
|
wrapV3(http.HandlerFunc(routes.GetProductionHasCostDetailLineHistoryHandler)),
|
||||||
)
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/production-product-costing/has-cost-detail-similar-history", "GET",
|
"/api/pricing/production-product-costing/has-cost-detail-similar-history", "GET",
|
||||||
"order", "view",
|
"costing", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.GetProductionHasCostDetailSimilarHistoryHandler)),
|
wrapV3(http.HandlerFunc(routes.GetProductionHasCostDetailSimilarHistoryHandler)),
|
||||||
)
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/production-product-costing/has-cost-detail-bulk-prices", "POST",
|
"/api/pricing/production-product-costing/has-cost-detail-bulk-prices", "POST",
|
||||||
"order", "view",
|
"costing", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.PostProductionHasCostDetailBulkPricesHandler)),
|
wrapV3(http.HandlerFunc(routes.PostProductionHasCostDetailBulkPricesHandler)),
|
||||||
)
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/production-product-costing/has-cost-detail/last-detail", "POST",
|
"/api/pricing/production-product-costing/has-cost-detail/last-detail", "POST",
|
||||||
"order", "view",
|
"costing", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.PostProductionProductCostingHasCostDetailLastDetailHandler)),
|
wrapV3(http.HandlerFunc(routes.PostProductionProductCostingHasCostDetailLastDetailHandler)),
|
||||||
)
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/production-product-costing/options/hammadde-by-nos", "POST",
|
"/api/pricing/production-product-costing/options/hammadde-by-nos", "POST",
|
||||||
"order", "view",
|
"costing", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.PostProductionProductCostingOptionsHammaddeByNosHandler)),
|
wrapV3(http.HandlerFunc(routes.PostProductionProductCostingOptionsHammaddeByNosHandler)),
|
||||||
)
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/production-product-costing/onml/save", "POST",
|
"/api/pricing/production-product-costing/onml/save", "POST",
|
||||||
"order", "view",
|
"costing", "view",
|
||||||
wrapV3(routes.PostProductionProductCostingOnMLSaveHandlerWithMailer(ml)),
|
wrapV3(routes.PostProductionProductCostingOnMLSaveHandlerWithMailer(ml)),
|
||||||
)
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/production-product-costing/onml/pdf", "GET",
|
"/api/pricing/production-product-costing/onml/pdf", "GET",
|
||||||
"order", "view",
|
"costing", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.GetProductionProductCostingOnMLPDFHandler)),
|
wrapV3(http.HandlerFunc(routes.GetProductionProductCostingOnMLPDFHandler)),
|
||||||
)
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/production-product-costing/onml/delete", "POST",
|
"/api/pricing/production-product-costing/onml/delete", "POST",
|
||||||
"order", "view",
|
"costing", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.PostProductionProductCostingOnMLDeleteHandler)),
|
wrapV3(http.HandlerFunc(routes.PostProductionProductCostingOnMLDeleteHandler)),
|
||||||
)
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/production-product-costing/default-quantities", "GET",
|
"/api/pricing/production-product-costing/default-quantities", "GET",
|
||||||
"order", "view",
|
"costing", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.GetProductionProductCostingDefaultQuantitiesHandler)),
|
wrapV3(http.HandlerFunc(routes.GetProductionProductCostingDefaultQuantitiesHandler)),
|
||||||
)
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/production-product-costing/default-quantities/upsert", "POST",
|
"/api/pricing/production-product-costing/default-quantities/upsert", "POST",
|
||||||
"order", "view",
|
"costing", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.PostProductionProductCostingDefaultQuantitiesUpsertHandler)),
|
wrapV3(http.HandlerFunc(routes.PostProductionProductCostingDefaultQuantitiesUpsertHandler)),
|
||||||
)
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/production-product-costing/default-quantities/update-bulk", "POST",
|
"/api/pricing/production-product-costing/default-quantities/update-bulk", "POST",
|
||||||
"order", "view",
|
"costing", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.PostProductionProductCostingDefaultQuantitiesBulkUpdateHandler)),
|
wrapV3(http.HandlerFunc(routes.PostProductionProductCostingDefaultQuantitiesBulkUpdateHandler)),
|
||||||
)
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/production-product-costing/default-quantities/calc-avg", "POST",
|
"/api/pricing/production-product-costing/default-quantities/calc-avg", "POST",
|
||||||
"order", "view",
|
"costing", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.PostProductionProductCostingDefaultQuantitiesCalcAvgHandler)),
|
wrapV3(http.HandlerFunc(routes.PostProductionProductCostingDefaultQuantitiesCalcAvgHandler)),
|
||||||
)
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/production-product-costing/default-quantities/lookup", "POST",
|
"/api/pricing/production-product-costing/default-quantities/lookup", "POST",
|
||||||
"order", "view",
|
"costing", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.PostProductionProductCostingDefaultQuantitiesLookupHandler)),
|
wrapV3(http.HandlerFunc(routes.PostProductionProductCostingDefaultQuantitiesLookupHandler)),
|
||||||
)
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/production-product-costing/default-quantities/refresh", "POST",
|
"/api/pricing/production-product-costing/default-quantities/refresh", "POST",
|
||||||
"order", "view",
|
"costing", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.PostProductionProductCostingDefaultQuantitiesRefreshHandler)),
|
wrapV3(http.HandlerFunc(routes.PostProductionProductCostingDefaultQuantitiesRefreshHandler)),
|
||||||
)
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/production-product-costing/tbstok/exists-bulk", "POST",
|
"/api/pricing/production-product-costing/tbstok/exists-bulk", "POST",
|
||||||
"order", "view",
|
"costing", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.PostProductionProductCostingTbStokExistsBulkHandler)),
|
wrapV3(http.HandlerFunc(routes.PostProductionProductCostingTbStokExistsBulkHandler)),
|
||||||
)
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/production-product-costing/last10-warnings", "GET",
|
"/api/pricing/production-product-costing/last10-warnings", "GET",
|
||||||
"order", "view",
|
"costing", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.GetProductionProductCostingLast10WarningsHandler)),
|
wrapV3(http.HandlerFunc(routes.GetProductionProductCostingLast10WarningsHandler)),
|
||||||
)
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/production-product-costing/options/urun-ana-grup", "GET",
|
"/api/pricing/production-product-costing/options/urun-ana-grup", "GET",
|
||||||
"order", "view",
|
"costing", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.GetProductionProductCostingUrunAnaGrupOptionsHandler)),
|
wrapV3(http.HandlerFunc(routes.GetProductionProductCostingUrunAnaGrupOptionsHandler)),
|
||||||
)
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/production-product-costing/options/urun-alt-grup", "GET",
|
"/api/pricing/production-product-costing/options/urun-alt-grup", "GET",
|
||||||
"order", "view",
|
"costing", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.GetProductionProductCostingUrunAltGrupOptionsHandler)),
|
wrapV3(http.HandlerFunc(routes.GetProductionProductCostingUrunAltGrupOptionsHandler)),
|
||||||
)
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/production-product-costing/options/urun-ana-alt-combos", "GET",
|
"/api/pricing/production-product-costing/options/urun-ana-alt-combos", "GET",
|
||||||
"order", "view",
|
"costing", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.GetProductionProductCostingUrunAnaAltCombosHandler)),
|
wrapV3(http.HandlerFunc(routes.GetProductionProductCostingUrunAnaAltCombosHandler)),
|
||||||
)
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/production-product-costing/options/mtbolum", "GET",
|
"/api/pricing/production-product-costing/options/mtbolum", "GET",
|
||||||
"order", "view",
|
"costing", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.GetProductionProductCostingMTBolumOptionsHandler)),
|
wrapV3(http.HandlerFunc(routes.GetProductionProductCostingMTBolumOptionsHandler)),
|
||||||
)
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/production-product-costing/maliyet-parca-eslestirme", "GET",
|
"/api/pricing/production-product-costing/maliyet-parca-eslestirme", "GET",
|
||||||
"order", "view",
|
"costing", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.GetProductionProductCostingParcaMappingsHandler)),
|
wrapV3(http.HandlerFunc(routes.GetProductionProductCostingParcaMappingsHandler)),
|
||||||
)
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/production-product-costing/maliyet-parca-eslestirme", "DELETE",
|
"/api/pricing/production-product-costing/maliyet-parca-eslestirme", "DELETE",
|
||||||
"order", "view",
|
"costing", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.DeleteProductionProductCostingParcaMappingHandler)),
|
wrapV3(http.HandlerFunc(routes.DeleteProductionProductCostingParcaMappingHandler)),
|
||||||
)
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/production-product-costing/maliyet-parca-eslestirme/upsert", "POST",
|
"/api/pricing/production-product-costing/maliyet-parca-eslestirme/upsert", "POST",
|
||||||
"order", "view",
|
"costing", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.PostProductionProductCostingParcaMappingUpsertHandler)),
|
wrapV3(http.HandlerFunc(routes.PostProductionProductCostingParcaMappingUpsertHandler)),
|
||||||
)
|
)
|
||||||
bindV3(r, pgDB,
|
bindV3(r, pgDB,
|
||||||
"/api/pricing/production-product-costing/maliyet-parca-eslestirme/set-active", "POST",
|
"/api/pricing/production-product-costing/maliyet-parca-eslestirme/set-active", "POST",
|
||||||
"order", "view",
|
"costing", "view",
|
||||||
wrapV3(http.HandlerFunc(routes.PostProductionProductCostingParcaMappingSetActiveHandler)),
|
wrapV3(http.HandlerFunc(routes.PostProductionProductCostingParcaMappingSetActiveHandler)),
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -1062,6 +1129,20 @@ func main() {
|
|||||||
// -------------------------------------------------------
|
// -------------------------------------------------------
|
||||||
routes.EnsureTranslationPerfIndexes(pgDB)
|
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
|
// ✉️ MAILER INIT
|
||||||
// -------------------------------------------------------
|
// -------------------------------------------------------
|
||||||
@@ -1081,6 +1162,8 @@ func main() {
|
|||||||
// -------------------------------------------------------
|
// -------------------------------------------------------
|
||||||
router := InitRoutes(pgDB, db.MssqlDB, graphMailer)
|
router := InitRoutes(pgDB, db.MssqlDB, graphMailer)
|
||||||
startTranslationSyncScheduler(pgDB, db.MssqlDB)
|
startTranslationSyncScheduler(pgDB, db.MssqlDB)
|
||||||
|
startBrandSyncScheduler(pgDB, db.MssqlDB)
|
||||||
|
startPricingParameterSyncScheduler(pgDB, db.MssqlDB)
|
||||||
|
|
||||||
handler := enableCORS(
|
handler := enableCORS(
|
||||||
middlewares.GlobalAuthMiddleware(
|
middlewares.GlobalAuthMiddleware(
|
||||||
|
|||||||
@@ -5,7 +5,28 @@ type ProductPricing struct {
|
|||||||
CostPrice float64 `json:"CostPrice"`
|
CostPrice float64 `json:"CostPrice"`
|
||||||
StockQty float64 `json:"StockQty"`
|
StockQty float64 `json:"StockQty"`
|
||||||
StockEntryDate string `json:"StockEntryDate"`
|
StockEntryDate string `json:"StockEntryDate"`
|
||||||
|
LastCostingDate string `json:"LastCostingDate"`
|
||||||
LastPricingDate string `json:"LastPricingDate"`
|
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"`
|
AskiliYan string `json:"AskiliYan"`
|
||||||
Kategori string `json:"Kategori"`
|
Kategori string `json:"Kategori"`
|
||||||
UrunIlkGrubu string `json:"UrunIlkGrubu"`
|
UrunIlkGrubu string `json:"UrunIlkGrubu"`
|
||||||
@@ -14,5 +35,6 @@ type ProductPricing struct {
|
|||||||
Icerik string `json:"Icerik"`
|
Icerik string `json:"Icerik"`
|
||||||
Karisim string `json:"Karisim"`
|
Karisim string `json:"Karisim"`
|
||||||
Marka string `json:"Marka"`
|
Marka string `json:"Marka"`
|
||||||
|
BrandCode string `json:"BrandCode"`
|
||||||
BrandGroupSec string `json:"BrandGroupSec"`
|
BrandGroupSec string `json:"BrandGroupSec"`
|
||||||
}
|
}
|
||||||
|
|||||||
60
svc/pricing_parameter_sync_scheduler.go
Normal file
60
svc/pricing_parameter_sync_scheduler.go
Normal 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")
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
}
|
||||||
191
svc/queries/brand_classification.go
Normal file
191
svc/queries/brand_classification.go
Normal 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
117
svc/queries/brand_sync.go
Normal 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
|
||||||
|
}
|
||||||
@@ -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)
|
// LIST (role+department sets with summary)
|
||||||
const ListRoleDepartmentPermissionSets = `
|
const ListRoleDepartmentPermissionSets = `
|
||||||
WITH role_dept AS (
|
WITH role_dept AS (
|
||||||
@@ -88,7 +110,37 @@ SELECT
|
|||||||
AND pa.department_code = b.department_code
|
AND pa.department_code = b.department_code
|
||||||
),
|
),
|
||||||
'{}'::jsonb
|
'{}'::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
|
FROM base b
|
||||||
ORDER BY
|
ORDER BY
|
||||||
b.role_title,
|
b.role_title,
|
||||||
|
|||||||
712
svc/queries/pricing_parameters.go
Normal file
712
svc/queries/pricing_parameters.go
Normal 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
|
||||||
|
}
|
||||||
513
svc/queries/pricing_rules.go
Normal file
513
svc/queries/pricing_rules.go
Normal 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()
|
||||||
|
}
|
||||||
@@ -10,6 +10,8 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/lib/pq"
|
||||||
)
|
)
|
||||||
|
|
||||||
type ProductPricingFilters struct {
|
type ProductPricingFilters struct {
|
||||||
@@ -34,7 +36,7 @@ type ProductPricingPage struct {
|
|||||||
Limit int
|
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{
|
result := ProductPricingPage{
|
||||||
Rows: []models.ProductPricing{},
|
Rows: []models.ProductPricing{},
|
||||||
TotalCount: 0,
|
TotalCount: 0,
|
||||||
@@ -114,34 +116,53 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro
|
|||||||
}
|
}
|
||||||
whereSQL := strings.Join(whereParts, " AND ")
|
whereSQL := strings.Join(whereParts, " AND ")
|
||||||
|
|
||||||
countQuery := `
|
if includeTotal {
|
||||||
SELECT COUNT(DISTINCT LTRIM(RTRIM(ProductCode)))
|
countQuery := `
|
||||||
FROM ProductFilterWithDescription('TR')
|
SELECT COUNT(DISTINCT LTRIM(RTRIM(ProductCode)))
|
||||||
WHERE ` + whereSQL + `;
|
FROM ProductFilterWithDescription('TR')
|
||||||
`
|
WHERE ` + whereSQL + `;
|
||||||
var totalCount int
|
`
|
||||||
if err := db.MssqlDB.QueryRowContext(ctx, countQuery, args...).Scan(&totalCount); err != nil {
|
var totalCount int
|
||||||
return result, err
|
if err := db.MssqlDB.QueryRowContext(ctx, countQuery, args...).Scan(&totalCount); err != nil {
|
||||||
}
|
return result, err
|
||||||
result.TotalCount = totalCount
|
}
|
||||||
if totalCount == 0 {
|
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.TotalPages = 0
|
||||||
result.Page = 1
|
result.Page = page
|
||||||
return result, nil
|
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).
|
// 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 := `
|
productQuery := `
|
||||||
IF OBJECT_ID('tempdb..#req_codes') IS NOT NULL DROP TABLE #req_codes;
|
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;
|
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.UrunAltGrubu) AS UrunAltGrubu,
|
||||||
MAX(f.Icerik) AS Icerik,
|
MAX(f.Icerik) AS Icerik,
|
||||||
MAX(f.Karisim) AS Karisim,
|
MAX(f.Karisim) AS Karisim,
|
||||||
MAX(f.Marka) AS Marka
|
MAX(f.Marka) AS Marka,
|
||||||
|
MAX(f.BrandCode) AS BrandCode
|
||||||
INTO #req_codes
|
INTO #req_codes
|
||||||
FROM (
|
FROM (
|
||||||
SELECT
|
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(ProductAtt02Desc)), '') AS UrunAltGrubu,
|
||||||
COALESCE(LTRIM(RTRIM(ProductAtt41Desc)), '') AS Icerik,
|
COALESCE(LTRIM(RTRIM(ProductAtt41Desc)), '') AS Icerik,
|
||||||
COALESCE(LTRIM(RTRIM(ProductAtt29Desc)), '') AS Karisim,
|
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')
|
FROM ProductFilterWithDescription('TR')
|
||||||
WHERE ` + whereSQL + `
|
WHERE ` + whereSQL + `
|
||||||
) f
|
) f
|
||||||
@@ -200,12 +223,13 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro
|
|||||||
rc.UrunAltGrubu,
|
rc.UrunAltGrubu,
|
||||||
rc.Icerik,
|
rc.Icerik,
|
||||||
rc.Karisim,
|
rc.Karisim,
|
||||||
rc.Marka
|
rc.Marka,
|
||||||
|
rc.BrandCode
|
||||||
FROM #req_codes rc
|
FROM #req_codes rc
|
||||||
LEFT JOIN #stock_base sb
|
LEFT JOIN #stock_base sb
|
||||||
ON sb.ItemCode = rc.ProductCode
|
ON sb.ItemCode = rc.ProductCode
|
||||||
ORDER BY
|
ORDER BY
|
||||||
CAST(ROUND(ISNULL(sb.InventoryQty1, 0), 2) AS DECIMAL(18, 2)) DESC,
|
` + orderExpr + ` ` + orderDir + `,
|
||||||
rc.ProductCode ASC
|
rc.ProductCode ASC
|
||||||
OFFSET ` + strconv.Itoa(offset) + ` ROWS
|
OFFSET ` + strconv.Itoa(offset) + ` ROWS
|
||||||
FETCH NEXT ` + strconv.Itoa(limit) + ` ROWS ONLY;
|
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.Icerik,
|
||||||
&item.Karisim,
|
&item.Karisim,
|
||||||
&item.Marka,
|
&item.Marka,
|
||||||
|
&item.BrandCode,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return result, err
|
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)
|
FROM (VALUES ` + strings.Join(valueRows, ",") + `) v(ProductCode)
|
||||||
WHERE LEN(LTRIM(RTRIM(v.ProductCode))) > 0
|
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 (
|
latest_base_price AS (
|
||||||
SELECT
|
SELECT
|
||||||
LTRIM(RTRIM(b.ItemCode)) AS ItemCode,
|
LTRIM(RTRIM(b.ItemCode)) AS ItemCode,
|
||||||
@@ -365,6 +423,8 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro
|
|||||||
SELECT
|
SELECT
|
||||||
rc.ProductCode,
|
rc.ProductCode,
|
||||||
COALESCE(lp.CostPrice, 0) AS CostPrice,
|
COALESCE(lp.CostPrice, 0) AS CostPrice,
|
||||||
|
COALESCE(bp.BasePriceUsd, 0) AS BasePriceUsd,
|
||||||
|
COALESCE(bp.BasePriceTry, 0) AS BasePriceTry,
|
||||||
CAST(ROUND(
|
CAST(ROUND(
|
||||||
ISNULL(sb.InventoryQty1, 0)
|
ISNULL(sb.InventoryQty1, 0)
|
||||||
- ISNULL(pb.PickingQty1, 0)
|
- ISNULL(pb.PickingQty1, 0)
|
||||||
@@ -372,11 +432,14 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro
|
|||||||
- ISNULL(db.DispOrderQty1, 0)
|
- ISNULL(db.DispOrderQty1, 0)
|
||||||
, 2) AS DECIMAL(18, 2)) AS StockQty,
|
, 2) AS DECIMAL(18, 2)) AS StockQty,
|
||||||
COALESCE(se.StockEntryDate, '') AS StockEntryDate,
|
COALESCE(se.StockEntryDate, '') AS StockEntryDate,
|
||||||
|
'' AS LastCostingDate,
|
||||||
COALESCE(lp.LastPricingDate, '') AS LastPricingDate
|
COALESCE(lp.LastPricingDate, '') AS LastPricingDate
|
||||||
FROM req_codes rc
|
FROM req_codes rc
|
||||||
LEFT JOIN latest_base_price lp
|
LEFT JOIN latest_base_price lp
|
||||||
ON lp.ItemCode = rc.ProductCode
|
ON lp.ItemCode = rc.ProductCode
|
||||||
AND lp.rn = 1
|
AND lp.rn = 1
|
||||||
|
LEFT JOIN base_prices bp
|
||||||
|
ON bp.ItemCode = rc.ProductCode
|
||||||
LEFT JOIN stock_entry_dates se
|
LEFT JOIN stock_entry_dates se
|
||||||
ON se.ItemCode = rc.ProductCode
|
ON se.ItemCode = rc.ProductCode
|
||||||
LEFT JOIN stock_base sb
|
LEFT JOIN stock_base sb
|
||||||
@@ -397,8 +460,11 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro
|
|||||||
|
|
||||||
type metrics struct {
|
type metrics struct {
|
||||||
CostPrice float64
|
CostPrice float64
|
||||||
|
BasePriceUsd float64
|
||||||
|
BasePriceTry float64
|
||||||
StockQty float64
|
StockQty float64
|
||||||
StockEntryDate string
|
StockEntryDate string
|
||||||
|
LastCostingDate string
|
||||||
LastPricingDate string
|
LastPricingDate string
|
||||||
}
|
}
|
||||||
metricsByCode := make(map[string]metrics, len(out))
|
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(
|
if err := metricsRows.Scan(
|
||||||
&code,
|
&code,
|
||||||
&m.CostPrice,
|
&m.CostPrice,
|
||||||
|
&m.BasePriceUsd,
|
||||||
|
&m.BasePriceTry,
|
||||||
&m.StockQty,
|
&m.StockQty,
|
||||||
&m.StockEntryDate,
|
&m.StockEntryDate,
|
||||||
|
&m.LastCostingDate,
|
||||||
&m.LastPricingDate,
|
&m.LastPricingDate,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
return result, err
|
return result, err
|
||||||
@@ -425,12 +494,222 @@ func GetProductPricingPage(ctx context.Context, page int, limit int, filters Pro
|
|||||||
for i := range out {
|
for i := range out {
|
||||||
if m, ok := metricsByCode[strings.TrimSpace(out[i].ProductCode)]; ok {
|
if m, ok := metricsByCode[strings.TrimSpace(out[i].ProductCode)]; ok {
|
||||||
out[i].CostPrice = m.CostPrice
|
out[i].CostPrice = m.CostPrice
|
||||||
|
out[i].BasePriceUsd = m.BasePriceUsd
|
||||||
|
out[i].BasePriceTry = m.BasePriceTry
|
||||||
out[i].StockQty = m.StockQty
|
out[i].StockQty = m.StockQty
|
||||||
out[i].StockEntryDate = m.StockEntryDate
|
out[i].StockEntryDate = m.StockEntryDate
|
||||||
|
out[i].LastCostingDate = m.LastCostingDate
|
||||||
out[i].LastPricingDate = m.LastPricingDate
|
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
|
result.Rows = out
|
||||||
return result, nil
|
return result, nil
|
||||||
}
|
}
|
||||||
|
|||||||
123
svc/queries/product_pricing_options.go
Normal file
123
svc/queries/product_pricing_options.go
Normal 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()
|
||||||
|
}
|
||||||
218
svc/routes/brand_classification.go
Normal file
218
svc/routes/brand_classification.go
Normal 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
163
svc/routes/pricing_rules.go
Normal 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
|
||||||
|
}
|
||||||
@@ -55,8 +55,30 @@ func GetProductPricingListHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
Karisim: splitCSVParam(r.URL.Query().Get("karisim")),
|
Karisim: splitCSVParam(r.URL.Query().Get("karisim")),
|
||||||
Marka: splitCSVParam(r.URL.Query().Get("marka")),
|
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 err != nil {
|
||||||
if isPricingTimeoutLike(err, ctx.Err()) {
|
if isPricingTimeoutLike(err, ctx.Err()) {
|
||||||
log.Printf(
|
log.Printf(
|
||||||
@@ -101,6 +123,94 @@ func GetProductPricingListHandler(w http.ResponseWriter, r *http.Request) {
|
|||||||
_ = json.NewEncoder(w).Encode(pageResult.Rows)
|
_ = 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 {
|
func buildPricingTraceID(r *http.Request) string {
|
||||||
if r != nil {
|
if r != nil {
|
||||||
if id := strings.TrimSpace(r.Header.Get("X-Request-ID")); id != "" {
|
if id := strings.TrimSpace(r.Header.Get("X-Request-ID")); id != "" {
|
||||||
|
|||||||
@@ -27,11 +27,22 @@ type Row struct {
|
|||||||
}
|
}
|
||||||
|
|
||||||
type RoleDeptPermissionSummary struct {
|
type RoleDeptPermissionSummary struct {
|
||||||
RoleID int `json:"role_id"`
|
RoleID int `json:"role_id"`
|
||||||
RoleTitle string `json:"role_title"`
|
RoleTitle string `json:"role_title"`
|
||||||
DepartmentCode string `json:"department_code"`
|
DepartmentCode string `json:"department_code"`
|
||||||
DepartmentTitle string `json:"department_title"`
|
DepartmentTitle string `json:"department_title"`
|
||||||
ModuleFlags map[string]bool `json:"module_flags"`
|
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 {
|
type ModuleLookupOption struct {
|
||||||
@@ -132,12 +143,14 @@ func (h *RoleDepartmentPermissionHandler) List(w http.ResponseWriter, r *http.Re
|
|||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var item RoleDeptPermissionSummary
|
var item RoleDeptPermissionSummary
|
||||||
var rawFlags []byte
|
var rawFlags []byte
|
||||||
|
var rawMembers []byte
|
||||||
if err := rows.Scan(
|
if err := rows.Scan(
|
||||||
&item.RoleID,
|
&item.RoleID,
|
||||||
&item.RoleTitle,
|
&item.RoleTitle,
|
||||||
&item.DepartmentCode,
|
&item.DepartmentCode,
|
||||||
&item.DepartmentTitle,
|
&item.DepartmentTitle,
|
||||||
&rawFlags,
|
&rawFlags,
|
||||||
|
&rawMembers,
|
||||||
); err != nil {
|
); err != nil {
|
||||||
http.Error(w, "scan error", http.StatusInternalServerError)
|
http.Error(w, "scan error", http.StatusInternalServerError)
|
||||||
return
|
return
|
||||||
@@ -150,6 +163,13 @@ func (h *RoleDepartmentPermissionHandler) List(w http.ResponseWriter, r *http.Re
|
|||||||
return
|
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)
|
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")
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||||
_ = json.NewEncoder(w).Encode(map[string]bool{"success": true})
|
_ = 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 {
|
func GetModuleLookupRoute(db *sql.DB) http.HandlerFunc {
|
||||||
|
|
||||||
return func(w http.ResponseWriter, r *http.Request) {
|
return func(w http.ResponseWriter, r *http.Request) {
|
||||||
|
|||||||
@@ -326,29 +326,47 @@ const menuItems = [
|
|||||||
},
|
},
|
||||||
|
|
||||||
{
|
{
|
||||||
label: 'Fiyatlandırma/Maliyetlendirme',
|
label: 'Ürün Maliyetlendirme',
|
||||||
icon: 'request_quote',
|
icon: 'request_quote',
|
||||||
|
|
||||||
children: [
|
children: [
|
||||||
{
|
|
||||||
label: 'Ürün Fiyatlandırma',
|
|
||||||
to: '/app/pricing/product-pricing',
|
|
||||||
permission: 'order:view'
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
label: "Üretim'den Ürün Maliyetlendirme",
|
label: "Üretim'den Ürün Maliyetlendirme",
|
||||||
to: '/app/pricing/production-product-costing',
|
to: '/app/costing/production-product-costing',
|
||||||
permission: 'order:view'
|
permission: 'costing:view'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Maliyet Parça Eşleştirme',
|
label: 'Maliyet Parça Eşleştirme',
|
||||||
to: '/app/pricing/production-product-costing/maliyet-parca-eslestirme',
|
to: '/app/costing/production-product-costing/maliyet-parca-eslestirme',
|
||||||
permission: 'order:view'
|
permission: 'costing:view'
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
label: 'Maliyet Varsayilan Miktarlar',
|
label: 'Maliyet Varsayilan Miktarlar',
|
||||||
to: '/app/pricing/production-product-costing/default-quantities',
|
to: '/app/costing/production-product-costing/default-quantities',
|
||||||
permission: 'order:view'
|
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'
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
|||||||
214
ui/src/pages/BrandClassification.vue
Normal file
214
ui/src/pages/BrandClassification.vue
Normal 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>
|
||||||
892
ui/src/pages/PricingRules.vue
Normal file
892
ui/src/pages/PricingRules.vue
Normal 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>
|
||||||
@@ -2,58 +2,106 @@
|
|||||||
<q-page class="q-pa-xs pricing-page">
|
<q-page class="q-pa-xs pricing-page">
|
||||||
<div class="top-bar row items-center justify-between q-mb-xs">
|
<div class="top-bar row items-center justify-between q-mb-xs">
|
||||||
<div class="text-subtitle1 text-weight-bold">Urun Fiyatlandirma</div>
|
<div class="text-subtitle1 text-weight-bold">Urun Fiyatlandirma</div>
|
||||||
<div class="row items-center q-gutter-xs">
|
<div class="top-actions">
|
||||||
<q-btn-dropdown color="secondary" outline icon="view_module" label="Doviz Gorunumu" :auto-close="false">
|
<div class="row items-center q-gutter-xs top-actions-row">
|
||||||
<q-list dense class="currency-menu-list">
|
<q-select
|
||||||
<q-item clickable @click="selectAllCurrencies">
|
v-model="topUrunIlkGrubu"
|
||||||
<q-item-section>Tumunu Sec</q-item-section>
|
dense
|
||||||
</q-item>
|
outlined
|
||||||
<q-item clickable @click="clearAllCurrencies">
|
clearable
|
||||||
<q-item-section>Tumunu Temizle</q-item-section>
|
emit-value
|
||||||
</q-item>
|
map-options
|
||||||
<q-separator />
|
:options="topUrunIlkGrubuOptions"
|
||||||
<q-item v-for="option in currencyOptions" :key="option.value" clickable @click="toggleCurrencyRow(option.value)">
|
:loading="Boolean(serverFilterLoading.urunIlkGrubu)"
|
||||||
<q-item-section avatar>
|
label="Urun Ilk Grubu"
|
||||||
<q-checkbox
|
style="min-width: 220px"
|
||||||
:model-value="isCurrencySelected(option.value)"
|
@filter="onTopFilterSearchUrunIlkGrubu"
|
||||||
dense
|
@update:model-value="onTopUrunIlkGrubuChange"
|
||||||
@update:model-value="(val) => toggleCurrency(option.value, val)"
|
/>
|
||||||
@click.stop
|
<q-select
|
||||||
/>
|
v-model="topUrunAnaGrubu"
|
||||||
</q-item-section>
|
dense
|
||||||
<q-item-section>{{ option.label }}</q-item-section>
|
outlined
|
||||||
</q-item>
|
clearable
|
||||||
</q-list>
|
multiple
|
||||||
</q-btn-dropdown>
|
use-chips
|
||||||
<q-btn
|
emit-value
|
||||||
flat
|
map-options
|
||||||
:color="showSelectedOnly ? 'primary' : 'grey-7'"
|
:options="topUrunAnaGrubuOptions"
|
||||||
:icon="showSelectedOnly ? 'checklist_rtl' : 'list_alt'"
|
:loading="Boolean(serverFilterLoading.urunAnaGrubu)"
|
||||||
:label="showSelectedOnly ? `Secililer (${selectedRowCount})` : 'Secili Olanlari Getir'"
|
label="Urun Ana Grubu (max 3)"
|
||||||
:disable="!showSelectedOnly && selectedRowCount === 0"
|
style="min-width: 260px"
|
||||||
@click="toggleShowSelectedOnly"
|
@filter="onTopFilterSearchUrunAnaGrubu"
|
||||||
/>
|
@update:model-value="onTopUrunAnaGrubuChange"
|
||||||
<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
|
||||||
<q-btn
|
color="primary"
|
||||||
color="primary"
|
icon="filter_alt"
|
||||||
outline
|
label="Gruplari Getir"
|
||||||
icon="edit_note"
|
:disable="!canFetchByGroup"
|
||||||
label="Secili Olanlari Toplu Degistir"
|
:loading="store.loading"
|
||||||
:disable="selectedRowCount === 0"
|
@click="reloadData({ page: 1 })"
|
||||||
@click="bulkDialogOpen = true"
|
/>
|
||||||
/>
|
<q-btn
|
||||||
<q-pagination
|
flat
|
||||||
v-model="currentPage"
|
color="grey-7"
|
||||||
color="primary"
|
icon="restart_alt"
|
||||||
:max="Math.max(1, store.totalPages || 1)"
|
label="Secimleri Sifirla"
|
||||||
:max-pages="8"
|
@click="resetGroupSelections"
|
||||||
boundary-links
|
/>
|
||||||
direction-links
|
</div>
|
||||||
@update:model-value="onPageChange"
|
|
||||||
/>
|
<div class="row items-center q-gutter-xs top-actions-row">
|
||||||
<div class="text-caption text-grey-8">
|
<q-btn-dropdown color="secondary" outline icon="view_module" label="Doviz Gorunumu" :auto-close="false">
|
||||||
Sayfa {{ currentPage }} / {{ Math.max(1, store.totalPages || 1) }} - Toplam {{ store.totalCount || 0 }} urun kodu
|
<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>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -106,7 +154,12 @@
|
|||||||
<q-badge v-if="hasFilter(col.field)" color="primary" floating rounded>
|
<q-badge v-if="hasFilter(col.field)" color="primary" floating rounded>
|
||||||
{{ getFilterBadgeValue(col.field) }}
|
{{ getFilterBadgeValue(col.field) }}
|
||||||
</q-badge>
|
</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">
|
<div v-if="isMultiSelectFilterField(col.field)" class="excel-filter-menu">
|
||||||
<q-input
|
<q-input
|
||||||
v-model="columnFilterSearch[col.field]"
|
v-model="columnFilterSearch[col.field]"
|
||||||
@@ -333,6 +386,24 @@
|
|||||||
</q-td>
|
</q-td>
|
||||||
</template>
|
</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">
|
<template #body-cell-lastPricingDate="props">
|
||||||
<q-td
|
<q-td
|
||||||
:props="props"
|
:props="props"
|
||||||
@@ -351,16 +422,9 @@
|
|||||||
:class="{ 'sticky-col': isStickyCol(props.col.name), 'sticky-boundary': isStickyBoundary(props.col.name) }"
|
:class="{ 'sticky-col': isStickyCol(props.col.name), 'sticky-boundary': isStickyBoundary(props.col.name) }"
|
||||||
:style="getBodyCellStyle(props.col)"
|
:style="getBodyCellStyle(props.col)"
|
||||||
>
|
>
|
||||||
<select
|
<span class="cell-text" :title="props.row.brandGroupSelection || ''">
|
||||||
class="native-cell-select"
|
{{ props.row.brandGroupSelection || '' }}
|
||||||
:value="props.row.brandGroupSelection"
|
</span>
|
||||||
@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>
|
|
||||||
</q-td>
|
</q-td>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -428,9 +492,10 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { useProductPricingStore } from 'src/stores/ProductPricingStore'
|
import { useProductPricingStore } from 'src/stores/ProductPricingStore'
|
||||||
|
import api from 'src/services/api'
|
||||||
|
|
||||||
const store = useProductPricingStore()
|
const store = useProductPricingStore()
|
||||||
const PAGE_LIMIT = 500
|
const PAGE_LIMIT = 250
|
||||||
const currentPage = ref(1)
|
const currentPage = ref(1)
|
||||||
let reloadTimer = null
|
let reloadTimer = null
|
||||||
|
|
||||||
@@ -440,11 +505,7 @@ const multipliers = [1, 1.03, 1.06, 1.09, 1.12, 1.15]
|
|||||||
const rowHeight = 31
|
const rowHeight = 31
|
||||||
const headerHeight = 72
|
const headerHeight = 72
|
||||||
|
|
||||||
const brandGroupOptions = [
|
// Marka grubu artik Marka Siniflandirma modulunden (mk_brandgrp) gelir ve listede sadece goruntulenir.
|
||||||
{ label: 'MARKA GRUBU A', value: 'MARKA GRUBU A' },
|
|
||||||
{ label: 'MARKA GRUBU B', value: 'MARKA GRUBU B' },
|
|
||||||
{ label: 'MARKA GRUBU C', value: 'MARKA GRUBU C' }
|
|
||||||
]
|
|
||||||
|
|
||||||
const currencyOptions = [
|
const currencyOptions = [
|
||||||
{ label: 'USD', value: 'USD' },
|
{ label: 'USD', value: 'USD' },
|
||||||
@@ -454,7 +515,7 @@ const currencyOptions = [
|
|||||||
|
|
||||||
const multiFilterColumns = [
|
const multiFilterColumns = [
|
||||||
{ field: 'productCode', label: 'Urun Kodu' },
|
{ field: 'productCode', label: 'Urun Kodu' },
|
||||||
{ field: 'brandGroupSelection', label: 'Marka Grubu Secimi' },
|
{ field: 'brandGroupSelection', label: 'Marka Grubu' },
|
||||||
{ field: 'marka', label: 'Marka' },
|
{ field: 'marka', label: 'Marka' },
|
||||||
{ field: 'askiliYan', label: 'Askili Yan' },
|
{ field: 'askiliYan', label: 'Askili Yan' },
|
||||||
{ field: 'kategori', label: 'Kategori' },
|
{ field: 'kategori', label: 'Kategori' },
|
||||||
@@ -466,7 +527,6 @@ const multiFilterColumns = [
|
|||||||
]
|
]
|
||||||
const serverBackedMultiFilterFields = new Set([
|
const serverBackedMultiFilterFields = new Set([
|
||||||
'productCode',
|
'productCode',
|
||||||
'brandGroupSelection',
|
|
||||||
'marka',
|
'marka',
|
||||||
'askiliYan',
|
'askiliYan',
|
||||||
'kategori',
|
'kategori',
|
||||||
@@ -526,6 +586,130 @@ const columnFilterSearch = ref({
|
|||||||
icerik: '',
|
icerik: '',
|
||||||
karisim: ''
|
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({
|
const numberRangeFilters = ref({
|
||||||
stockQty: { min: '', max: '' }
|
stockQty: { min: '', max: '' }
|
||||||
})
|
})
|
||||||
@@ -608,6 +792,7 @@ const allColumns = [
|
|||||||
col('calcAction', 'HESAPLA', 'calcAction', 72, { align: 'center', classes: 'ps-col' }),
|
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('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('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('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('askiliYan', 'ASKILI YAN', 'askiliYan', 54, { sortable: true, classes: 'ps-col' }),
|
||||||
col('kategori', 'KATEGORI', 'kategori', 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) {
|
function getFilterOptionsForField (field) {
|
||||||
if (isValueSelectFilterField(field)) return filteredValueFilterOptionMap.value[field] || []
|
if (isValueSelectFilterField(field)) return filteredValueFilterOptionMap.value[field] || []
|
||||||
|
if (serverBackedMultiFilterFields.has(field)) {
|
||||||
|
return serverFilterOptionMap.value[field] || []
|
||||||
|
}
|
||||||
return filteredFilterOptionMap.value[field] || []
|
return filteredFilterOptionMap.value[field] || []
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1025,6 +1213,14 @@ function needsRepricing (row) {
|
|||||||
return lastPricingDate < stockEntryDate
|
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) {
|
function recalcByBasePrice (row) {
|
||||||
row.basePriceTry = round2((row.basePriceUsd * usdToTry) + row.expenseForBasePrice)
|
row.basePriceTry = round2((row.basePriceUsd * usdToTry) + row.expenseForBasePrice)
|
||||||
multipliers.forEach((multiplier, index) => {
|
multipliers.forEach((multiplier, index) => {
|
||||||
@@ -1047,7 +1243,7 @@ function calculateRow (row) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onBrandGroupSelectionChange (row, val) {
|
function onBrandGroupSelectionChange (row, val) {
|
||||||
store.updateBrandGroupSelection(row, val)
|
// no-op (read-only)
|
||||||
}
|
}
|
||||||
|
|
||||||
function isRowSelected (rowKey) {
|
function isRowSelected (rowKey) {
|
||||||
@@ -1150,12 +1346,20 @@ function clearAllCurrencies () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function onPaginationChange (next) {
|
function onPaginationChange (next) {
|
||||||
|
const prevSortBy = tablePagination.value.sortBy
|
||||||
|
const prevDesc = tablePagination.value.descending
|
||||||
tablePagination.value = {
|
tablePagination.value = {
|
||||||
...tablePagination.value,
|
...tablePagination.value,
|
||||||
...(next || {}),
|
...(next || {}),
|
||||||
page: 1,
|
page: 1,
|
||||||
rowsPerPage: 0
|
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 () {
|
function buildServerFilters () {
|
||||||
@@ -1182,12 +1386,36 @@ function scheduleReload () {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async function fetchChunk ({ page = 1 } = {}) {
|
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({
|
const result = await store.fetchRows({
|
||||||
limit: PAGE_LIMIT,
|
limit: PAGE_LIMIT,
|
||||||
page,
|
page,
|
||||||
append: false,
|
append: false,
|
||||||
silent: false,
|
silent: false,
|
||||||
filters: buildServerFilters()
|
filters,
|
||||||
|
sortBy: tablePagination.value.sortBy,
|
||||||
|
descending: tablePagination.value.descending
|
||||||
})
|
})
|
||||||
currentPage.value = Number(result?.page) || page
|
currentPage.value = Number(result?.page) || page
|
||||||
return Number(result?.fetched) || 0
|
return Number(result?.fetched) || 0
|
||||||
@@ -1211,9 +1439,10 @@ async function reloadData ({ page = 1 } = {}) {
|
|||||||
row_count: Array.isArray(store.rows) ? store.rows.length : 0,
|
row_count: Array.isArray(store.rows) ? store.rows.length : 0,
|
||||||
has_error: Boolean(store.error)
|
has_error: Boolean(store.error)
|
||||||
})
|
})
|
||||||
selectedMap.value = {}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Full "fetch all pages" is intentionally avoided; keep server-side paging for performance.
|
||||||
|
|
||||||
function onPageChange (page) {
|
function onPageChange (page) {
|
||||||
const p = Number(page) > 0 ? Number(page) : 1
|
const p = Number(page) > 0 ? Number(page) : 1
|
||||||
if (store.loading) return
|
if (store.loading) return
|
||||||
@@ -1223,7 +1452,16 @@ function onPageChange (page) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(async () => {
|
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(() => {
|
onBeforeUnmount(() => {
|
||||||
@@ -1233,11 +1471,7 @@ onBeforeUnmount(() => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(
|
// NOTE: Listing fetch is intentionally manual via "Gruplari Getir" for performance.
|
||||||
[columnFilters],
|
|
||||||
() => { scheduleReload() },
|
|
||||||
{ deep: true }
|
|
||||||
)
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -1256,6 +1490,18 @@ watch(
|
|||||||
min-width: 170px;
|
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 {
|
.table-wrap {
|
||||||
flex: 1;
|
flex: 1;
|
||||||
min-height: 0;
|
min-height: 0;
|
||||||
@@ -1489,6 +1735,10 @@ watch(
|
|||||||
color: #c62828;
|
color: #c62828;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.cell-danger {
|
||||||
|
background: #c62828 !important;
|
||||||
|
}
|
||||||
|
|
||||||
.pricing-table :deep(th.selection-col),
|
.pricing-table :deep(th.selection-col),
|
||||||
.pricing-table :deep(td.selection-col) {
|
.pricing-table :deep(td.selection-col) {
|
||||||
background: #fff;
|
background: #fff;
|
||||||
|
|||||||
@@ -124,9 +124,21 @@
|
|||||||
:rows-per-page-options="[0]"
|
:rows-per-page-options="[0]"
|
||||||
hide-bottom
|
hide-bottom
|
||||||
>
|
>
|
||||||
<template #body-cell="props">
|
<template #body="props">
|
||||||
<q-td :props="props" :class="props.col.classes">
|
<q-tr
|
||||||
<template v-if="props.col.name === 'open'">
|
: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">
|
<div class="text-center">
|
||||||
<q-btn
|
<q-btn
|
||||||
icon="open_in_new"
|
icon="open_in_new"
|
||||||
@@ -141,10 +153,10 @@
|
|||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else-if="isPermissionColumn(props.col.name)">
|
<template v-else-if="isPermissionColumn(col.name)">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<q-checkbox
|
<q-checkbox
|
||||||
:model-value="Boolean(props.value)"
|
:model-value="Boolean(col.value)"
|
||||||
disable
|
disable
|
||||||
dense
|
dense
|
||||||
/>
|
/>
|
||||||
@@ -152,12 +164,43 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<template v-else>
|
<template v-else>
|
||||||
{{ props.value }}
|
{{ col.value }}
|
||||||
</template>
|
</template>
|
||||||
</q-td>
|
</q-td>
|
||||||
|
</q-tr>
|
||||||
</template>
|
</template>
|
||||||
</q-table>
|
</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">
|
<q-banner v-if="store.error" class="bg-red text-white q-mt-sm">
|
||||||
Hata: {{ store.error }}
|
Hata: {{ store.error }}
|
||||||
</q-banner>
|
</q-banner>
|
||||||
@@ -171,7 +214,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { computed, onMounted, ref, watch } from 'vue'
|
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useQuasar } from 'quasar'
|
import { useQuasar } from 'quasar'
|
||||||
import { usePermission } from 'src/composables/usePermission'
|
import { usePermission } from 'src/composables/usePermission'
|
||||||
@@ -187,6 +230,10 @@ const selectedModules = ref([])
|
|||||||
const selectedActionsByModule = ref({})
|
const selectedActionsByModule = ref({})
|
||||||
const activeModuleCode = ref('')
|
const activeModuleCode = ref('')
|
||||||
const allowEmptySelection = ref(false)
|
const allowEmptySelection = ref(false)
|
||||||
|
const membersPopupOpen = ref(false)
|
||||||
|
const membersPopupTarget = ref(false)
|
||||||
|
const hoveredRow = ref(null)
|
||||||
|
let membersPopupHideTimer = null
|
||||||
|
|
||||||
const actionLabelMap = {
|
const actionLabelMap = {
|
||||||
update: 'Güncelleme',
|
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) {
|
function isPermissionColumn (name) {
|
||||||
return String(name || '').startsWith('perm_')
|
return String(name || '').startsWith('perm_')
|
||||||
}
|
}
|
||||||
@@ -472,6 +550,10 @@ onMounted(async () => {
|
|||||||
syncSelections()
|
syncSelections()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onBeforeUnmount(() => {
|
||||||
|
cancelHideMembersPopup()
|
||||||
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -571,6 +653,30 @@ onMounted(async () => {
|
|||||||
padding: 3px 6px;
|
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) {
|
.rdp-table :deep(.q-checkbox__inner) {
|
||||||
pointer-events: none;
|
pointer-events: none;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -86,6 +86,63 @@
|
|||||||
|
|
||||||
</div>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
|
||||||
@@ -184,7 +241,7 @@
|
|||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
|
|
||||||
import { ref, onMounted, watch } from 'vue'
|
import { computed, ref, onMounted, watch } from 'vue'
|
||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { Notify } from 'quasar'
|
import { Notify } from 'quasar'
|
||||||
import api from 'src/services/api'
|
import api from 'src/services/api'
|
||||||
@@ -201,13 +258,19 @@ const router = useRouter()
|
|||||||
|
|
||||||
const roles = ref([])
|
const roles = ref([])
|
||||||
const departments = ref([])
|
const departments = ref([])
|
||||||
|
const users = ref([])
|
||||||
|
const filteredUserOptions = ref([])
|
||||||
|
const members = ref([])
|
||||||
|
|
||||||
const roleId = ref(null)
|
const roleId = ref(null)
|
||||||
const deptCode = ref(null)
|
const deptCode = ref(null)
|
||||||
|
const memberUserId = ref(null)
|
||||||
|
|
||||||
const rows = ref([])
|
const rows = ref([])
|
||||||
|
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
|
const membersLoading = ref(false)
|
||||||
|
const addingMember = ref(false)
|
||||||
const dirty = ref(false)
|
const dirty = ref(false)
|
||||||
const lookupsLoaded = ref(false)
|
const lookupsLoaded = ref(false)
|
||||||
|
|
||||||
@@ -274,15 +337,18 @@ function applyRouteSelection () {
|
|||||||
|
|
||||||
async function loadLookups () {
|
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/roles-perm'),
|
||||||
api.get('/lookups/departments-perm'),
|
api.get('/lookups/departments-perm'),
|
||||||
api.get('/lookups/modules')
|
api.get('/lookups/modules'),
|
||||||
|
api.get('/lookups/users-perm')
|
||||||
])
|
])
|
||||||
|
|
||||||
roles.value = r.data || []
|
roles.value = r.data || []
|
||||||
departments.value = d.data || []
|
departments.value = d.data || []
|
||||||
modules.value = m.data || []
|
modules.value = m.data || []
|
||||||
|
users.value = u.data || []
|
||||||
|
filteredUserOptions.value = [...users.value]
|
||||||
|
|
||||||
lookupsLoaded.value = true
|
lookupsLoaded.value = true
|
||||||
}
|
}
|
||||||
@@ -312,7 +378,10 @@ function initMatrix () {
|
|||||||
|
|
||||||
async function loadMatrix () {
|
async function loadMatrix () {
|
||||||
|
|
||||||
if (!roleId.value || !deptCode.value) return
|
if (!roleId.value || !deptCode.value) {
|
||||||
|
members.value = []
|
||||||
|
return
|
||||||
|
}
|
||||||
if (matrixLoading) return
|
if (matrixLoading) return
|
||||||
|
|
||||||
matrixLoading = true
|
matrixLoading = true
|
||||||
@@ -326,9 +395,10 @@ async function loadMatrix () {
|
|||||||
|
|
||||||
initMatrix()
|
initMatrix()
|
||||||
|
|
||||||
const res = await api.get(
|
const [res] = await Promise.all([
|
||||||
`/roles/${roleId.value}/departments/${deptCode.value}/permissions`
|
api.get(`/roles/${roleId.value}/departments/${deptCode.value}/permissions`),
|
||||||
)
|
loadMembers()
|
||||||
|
])
|
||||||
|
|
||||||
const list = Array.isArray(res.data) ? res.data : []
|
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 ================= */
|
/* ================= SAVE ================= */
|
||||||
|
|
||||||
@@ -482,3 +616,53 @@ watch(
|
|||||||
)
|
)
|
||||||
|
|
||||||
</script>
|
</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>
|
||||||
|
|||||||
@@ -341,53 +341,94 @@ const routes = [
|
|||||||
},
|
},
|
||||||
|
|
||||||
/* ================= PRICING ================= */
|
/* ================= 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',
|
path: 'pricing/product-pricing',
|
||||||
name: 'product-pricing',
|
name: 'product-pricing',
|
||||||
component: () => import('pages/ProductPricing.vue'),
|
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',
|
name: 'production-product-costing',
|
||||||
component: () => import('pages/ProductionProductCosting.vue'),
|
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',
|
name: 'production-product-costing-has-cost',
|
||||||
component: () => import('pages/ProductionProductCostingHasCost.vue'),
|
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',
|
name: 'production-product-costing-has-cost-history',
|
||||||
component: () => import('pages/ProductionProductCostingHasCostHistory.vue'),
|
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',
|
name: 'production-product-costing-has-cost-detail',
|
||||||
component: () => import('pages/ProductionProductCostingHasCostDetail.vue'),
|
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',
|
name: 'production-product-costing-no-cost',
|
||||||
component: () => import('pages/ProductionProductCostingNoCost.vue'),
|
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',
|
name: 'production-product-costing-maliyet-parca-eslestirme',
|
||||||
component: () => import('pages/ProductionProductCostingMTBolumMapping.vue'),
|
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',
|
name: 'production-product-costing-default-quantities',
|
||||||
component: () => import('pages/ProductionProductCostingDefaultQuantities.vue'),
|
component: () => import('pages/ProductionProductCostingDefaultQuantities.vue'),
|
||||||
meta: { permission: 'order:view' }
|
meta: { permission: 'costing:view' }
|
||||||
},
|
},
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -42,6 +42,7 @@ function mapRow (raw, index, baseIndex = 0) {
|
|||||||
productCode: toText(raw?.ProductCode),
|
productCode: toText(raw?.ProductCode),
|
||||||
stockQty: toNumber(raw?.StockQty),
|
stockQty: toNumber(raw?.StockQty),
|
||||||
stockEntryDate: toText(raw?.StockEntryDate),
|
stockEntryDate: toText(raw?.StockEntryDate),
|
||||||
|
lastCostingDate: toText(raw?.LastCostingDate),
|
||||||
lastPricingDate: toText(raw?.LastPricingDate),
|
lastPricingDate: toText(raw?.LastPricingDate),
|
||||||
askiliYan: toText(raw?.AskiliYan),
|
askiliYan: toText(raw?.AskiliYan),
|
||||||
kategori: toText(raw?.Kategori),
|
kategori: toText(raw?.Kategori),
|
||||||
@@ -54,26 +55,26 @@ function mapRow (raw, index, baseIndex = 0) {
|
|||||||
brandGroupSelection: toText(raw?.BrandGroupSec),
|
brandGroupSelection: toText(raw?.BrandGroupSec),
|
||||||
costPrice: toNumber(raw?.CostPrice),
|
costPrice: toNumber(raw?.CostPrice),
|
||||||
expenseForBasePrice: 0,
|
expenseForBasePrice: 0,
|
||||||
basePriceUsd: 0,
|
basePriceUsd: toNumber(raw?.BasePriceUsd),
|
||||||
basePriceTry: 0,
|
basePriceTry: toNumber(raw?.BasePriceTry),
|
||||||
usd1: 0,
|
usd1: toNumber(raw?.USD1),
|
||||||
usd2: 0,
|
usd2: toNumber(raw?.USD2),
|
||||||
usd3: 0,
|
usd3: toNumber(raw?.USD3),
|
||||||
usd4: 0,
|
usd4: toNumber(raw?.USD4),
|
||||||
usd5: 0,
|
usd5: toNumber(raw?.USD5),
|
||||||
usd6: 0,
|
usd6: toNumber(raw?.USD6),
|
||||||
eur1: 0,
|
eur1: toNumber(raw?.EUR1),
|
||||||
eur2: 0,
|
eur2: toNumber(raw?.EUR2),
|
||||||
eur3: 0,
|
eur3: toNumber(raw?.EUR3),
|
||||||
eur4: 0,
|
eur4: toNumber(raw?.EUR4),
|
||||||
eur5: 0,
|
eur5: toNumber(raw?.EUR5),
|
||||||
eur6: 0,
|
eur6: toNumber(raw?.EUR6),
|
||||||
try1: 0,
|
try1: toNumber(raw?.TRY1),
|
||||||
try2: 0,
|
try2: toNumber(raw?.TRY2),
|
||||||
try3: 0,
|
try3: toNumber(raw?.TRY3),
|
||||||
try4: 0,
|
try4: toNumber(raw?.TRY4),
|
||||||
try5: 0,
|
try5: toNumber(raw?.TRY5),
|
||||||
try6: 0
|
try6: toNumber(raw?.TRY6)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -95,11 +96,18 @@ function normalizeFilters (filters = {}) {
|
|||||||
return out
|
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) {
|
function makeCacheKey (limit, page, filters) {
|
||||||
return JSON.stringify({
|
return JSON.stringify({
|
||||||
limit: Number(limit) || 500,
|
limit: Number(limit) || 500,
|
||||||
page: Number(page) || 1,
|
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 limit = Number(options?.limit) > 0 ? Number(options.limit) : 500
|
||||||
const page = Number(options?.page) > 0 ? Number(options.page) : 1
|
const page = Number(options?.page) > 0 ? Number(options.page) : 1
|
||||||
const filters = normalizeFilters(options?.filters || {})
|
const filters = normalizeFilters(options?.filters || {})
|
||||||
|
const sortBy = toText(options?.sortBy)
|
||||||
|
const descending = Boolean(options?.descending)
|
||||||
const key = makeCacheKey(limit, page, filters)
|
const key = makeCacheKey(limit, page, filters)
|
||||||
if (this.pageCache[key]) return
|
if (this.pageCache[key]) return
|
||||||
if (this.prefetchInFlight[key]) {
|
if (this.prefetchInFlight[key]) {
|
||||||
@@ -153,7 +163,12 @@ export const useProductPricingStore = defineStore('product-pricing-store', {
|
|||||||
}
|
}
|
||||||
const run = async () => {
|
const run = async () => {
|
||||||
try {
|
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)) {
|
for (const k of Object.keys(filters)) {
|
||||||
if (k === 'q') {
|
if (k === 'q') {
|
||||||
params.q = filters.q
|
params.q = filters.q
|
||||||
@@ -170,10 +185,13 @@ export const useProductPricingStore = defineStore('product-pricing-store', {
|
|||||||
timeout: 180000
|
timeout: 180000
|
||||||
})
|
})
|
||||||
const totalCount = Number(res?.headers?.['x-total-count'] || 0)
|
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 currentPage = Math.max(1, Number(res?.headers?.['x-page'] || page))
|
||||||
const data = Array.isArray(res?.data) ? res.data : []
|
const data = Array.isArray(res?.data) ? res.data : []
|
||||||
const mapped = data.map((x, i) => mapRow(x, i, 0))
|
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, {
|
this.cachePut(key, {
|
||||||
rows: mapped,
|
rows: mapped,
|
||||||
totalCount: Number.isFinite(totalCount) ? totalCount : 0,
|
totalCount: Number.isFinite(totalCount) ? totalCount : 0,
|
||||||
@@ -202,6 +220,8 @@ export const useProductPricingStore = defineStore('product-pricing-store', {
|
|||||||
const append = Boolean(options?.append)
|
const append = Boolean(options?.append)
|
||||||
const baseIndex = append ? this.rows.length : 0
|
const baseIndex = append ? this.rows.length : 0
|
||||||
const filters = normalizeFilters(options?.filters || {})
|
const filters = normalizeFilters(options?.filters || {})
|
||||||
|
const sortBy = toText(options?.sortBy)
|
||||||
|
const descending = Boolean(options?.descending)
|
||||||
const cacheKey = makeCacheKey(limit, page, filters)
|
const cacheKey = makeCacheKey(limit, page, filters)
|
||||||
const startedAt = Date.now()
|
const startedAt = Date.now()
|
||||||
console.info('[product-pricing][frontend] request:start', {
|
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)) {
|
for (const key of Object.keys(filters)) {
|
||||||
if (key === 'q') {
|
if (key === 'q') {
|
||||||
params.q = filters.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 traceId = res?.headers?.['x-trace-id'] || null
|
||||||
const totalCount = Number(res?.headers?.['x-total-count'] || 0)
|
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 currentPage = Math.max(1, Number(res?.headers?.['x-page'] || page))
|
||||||
const data = Array.isArray(res?.data) ? res.data : []
|
const data = Array.isArray(res?.data) ? res.data : []
|
||||||
const mapped = data.map((x, i) => mapRow(x, i, baseIndex))
|
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 = {
|
const payload = {
|
||||||
rows: mapped,
|
rows: mapped,
|
||||||
totalCount: Number.isFinite(totalCount) ? totalCount : 0,
|
totalCount: Number.isFinite(totalCount) ? totalCount : 0,
|
||||||
@@ -265,7 +294,15 @@ export const useProductPricingStore = defineStore('product-pricing-store', {
|
|||||||
page: Number.isFinite(currentPage) ? currentPage : page
|
page: Number.isFinite(currentPage) ? currentPage : page
|
||||||
}
|
}
|
||||||
this.cachePut(cacheKey, payload)
|
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.
|
// Background prefetch for next page to reduce perceived wait on page change.
|
||||||
if (this.page < this.totalPages) {
|
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) {
|
updateCell (row, field, val) {
|
||||||
if (!row || !field) return
|
if (!row || !field) return
|
||||||
row[field] = toNumber(val)
|
row[field] = toNumber(val)
|
||||||
|
|||||||
@@ -94,7 +94,14 @@ export const useRoleDeptPermissionListStore = defineStore('roleDeptPermissionLis
|
|||||||
role_title: r.role_title || '',
|
role_title: r.role_title || '',
|
||||||
department_code: r.department_code || '',
|
department_code: r.department_code || '',
|
||||||
department_title: r.department_title || '',
|
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) {
|
} catch (err) {
|
||||||
|
|||||||
Reference in New Issue
Block a user