Merge remote-tracking branch 'origin/master'

This commit is contained in:
M_Kececi
2026-04-22 11:19:36 +03:00
parent e6ae925f1c
commit d2387bc221
10 changed files with 855 additions and 543 deletions

View File

@@ -6,35 +6,209 @@ import (
"context"
"database/sql"
"fmt"
"math"
"strconv"
"strings"
"time"
)
func GetProductPricingList(ctx context.Context, limit int, afterProductCode string) ([]models.ProductPricing, error) {
type ProductPricingFilters struct {
Search string
ProductCode []string
BrandGroup []string
AskiliYan []string
Kategori []string
UrunIlkGrubu []string
UrunAnaGrubu []string
UrunAltGrubu []string
Icerik []string
Karisim []string
Marka []string
}
type ProductPricingPage struct {
Rows []models.ProductPricing
TotalCount int
TotalPages int
Page int
Limit int
}
func GetProductPricingPage(ctx context.Context, page int, limit int, filters ProductPricingFilters) (ProductPricingPage, error) {
result := ProductPricingPage{
Rows: []models.ProductPricing{},
TotalCount: 0,
TotalPages: 0,
Page: page,
Limit: limit,
}
if page <= 0 {
page = 1
}
if limit <= 0 {
limit = 500
}
afterProductCode = strings.TrimSpace(afterProductCode)
offset := (page - 1) * limit
paramIndex := 1
args := make([]any, 0, 64)
nextParam := func() string {
name := "@p" + strconv.Itoa(paramIndex)
paramIndex++
return name
}
whereParts := []string{
"ProductAtt42 IN ('SERI', 'AKSESUAR')",
"IsBlocked = 0",
"LEN(LTRIM(RTRIM(ProductCode))) = 13",
}
addInFilter := func(expr string, values []string) {
clean := make([]string, 0, len(values))
for _, v := range values {
v = strings.TrimSpace(v)
if v == "" {
continue
}
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 ")+")")
}
brandGroupExpr := `CASE ABS(CHECKSUM(LTRIM(RTRIM(ProductCode)))) % 3
WHEN 0 THEN 'MARKA GRUBU A'
WHEN 1 THEN 'MARKA GRUBU B'
ELSE 'MARKA GRUBU C'
END`
addInFilter("LTRIM(RTRIM(ProductCode))", filters.ProductCode)
addInFilter(brandGroupExpr, filters.BrandGroup)
addInFilter("COALESCE(LTRIM(RTRIM(ProductAtt45Desc)), '')", filters.AskiliYan)
addInFilter("COALESCE(LTRIM(RTRIM(ProductAtt44Desc)), '')", filters.Kategori)
addInFilter("COALESCE(LTRIM(RTRIM(ProductAtt42Desc)), '')", filters.UrunIlkGrubu)
addInFilter("COALESCE(LTRIM(RTRIM(ProductAtt01Desc)), '')", filters.UrunAnaGrubu)
addInFilter("COALESCE(LTRIM(RTRIM(ProductAtt02Desc)), '')", filters.UrunAltGrubu)
addInFilter("COALESCE(LTRIM(RTRIM(ProductAtt41Desc)), '')", filters.Icerik)
addInFilter("COALESCE(LTRIM(RTRIM(ProductAtt29Desc)), '')", filters.Karisim)
addInFilter("COALESCE(LTRIM(RTRIM(ProductAtt10Desc)), '')", filters.Marka)
if q := strings.TrimSpace(filters.Search); q != "" {
p := nextParam()
args = append(args, "%"+q+"%")
whereParts = append(whereParts, "("+strings.Join([]string{
"LTRIM(RTRIM(ProductCode)) LIKE " + p,
"COALESCE(LTRIM(RTRIM(ProductAtt45Desc)), '') LIKE " + p,
"COALESCE(LTRIM(RTRIM(ProductAtt44Desc)), '') LIKE " + p,
"COALESCE(LTRIM(RTRIM(ProductAtt42Desc)), '') LIKE " + p,
"COALESCE(LTRIM(RTRIM(ProductAtt01Desc)), '') LIKE " + p,
"COALESCE(LTRIM(RTRIM(ProductAtt02Desc)), '') LIKE " + p,
"COALESCE(LTRIM(RTRIM(ProductAtt41Desc)), '') LIKE " + p,
"COALESCE(LTRIM(RTRIM(ProductAtt29Desc)), '') LIKE " + p,
"COALESCE(LTRIM(RTRIM(ProductAtt10Desc)), '') LIKE " + p,
}, " OR ")+")")
}
whereSQL := strings.Join(whereParts, " AND ")
countQuery := `
SELECT COUNT(DISTINCT LTRIM(RTRIM(ProductCode)))
FROM ProductFilterWithDescription('TR')
WHERE ` + whereSQL + `;
`
var totalCount int
if err := db.MssqlDB.QueryRowContext(ctx, countQuery, args...).Scan(&totalCount); err != nil {
return result, err
}
result.TotalCount = totalCount
if totalCount == 0 {
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
// Stage 1: fetch only paged products first (fast path).
productQuery := `
SELECT TOP (` + strconv.Itoa(limit) + `)
LTRIM(RTRIM(ProductCode)) AS ProductCode,
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(ProductAtt29Desc)), '') AS Karisim,
COALESCE(LTRIM(RTRIM(ProductAtt10Desc)), '') AS Marka
FROM ProductFilterWithDescription('TR')
WHERE ProductAtt42 IN ('SERI', 'AKSESUAR')
AND IsBlocked = 0
AND LEN(LTRIM(RTRIM(ProductCode))) = 13
AND (@p1 = '' OR LTRIM(RTRIM(ProductCode)) > @p1)
ORDER BY LTRIM(RTRIM(ProductCode));
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;
SELECT
f.ProductCode,
MAX(f.BrandGroupSec) AS BrandGroupSec,
MAX(f.AskiliYan) AS AskiliYan,
MAX(f.Kategori) AS Kategori,
MAX(f.UrunIlkGrubu) AS UrunIlkGrubu,
MAX(f.UrunAnaGrubu) AS UrunAnaGrubu,
MAX(f.UrunAltGrubu) AS UrunAltGrubu,
MAX(f.Icerik) AS Icerik,
MAX(f.Karisim) AS Karisim,
MAX(f.Marka) AS Marka
INTO #req_codes
FROM (
SELECT
LTRIM(RTRIM(ProductCode)) AS ProductCode,
` + brandGroupExpr + ` AS BrandGroupSec,
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(ProductAtt29Desc)), '') AS Karisim,
COALESCE(LTRIM(RTRIM(ProductAtt10Desc)), '') AS Marka
FROM ProductFilterWithDescription('TR')
WHERE ` + whereSQL + `
) f
GROUP BY f.ProductCode;
CREATE CLUSTERED INDEX IX_req_codes_ProductCode ON #req_codes(ProductCode);
SELECT
LTRIM(RTRIM(s.ItemCode)) AS ItemCode,
SUM(s.In_Qty1 - s.Out_Qty1) AS InventoryQty1
INTO #stock_base
FROM trStock s WITH(NOLOCK)
INNER JOIN #req_codes rc
ON rc.ProductCode = LTRIM(RTRIM(s.ItemCode))
WHERE s.ItemTypeCode = 1
AND LEN(LTRIM(RTRIM(s.ItemCode))) = 13
GROUP BY LTRIM(RTRIM(s.ItemCode));
CREATE CLUSTERED INDEX IX_stock_base_ItemCode ON #stock_base(ItemCode);
SELECT
rc.ProductCode,
rc.BrandGroupSec,
rc.AskiliYan,
rc.Kategori,
rc.UrunIlkGrubu,
rc.UrunAnaGrubu,
rc.UrunAltGrubu,
rc.Icerik,
rc.Karisim,
rc.Marka
FROM #req_codes rc
LEFT JOIN #stock_base sb
ON sb.ItemCode = rc.ProductCode
ORDER BY
CAST(ROUND(ISNULL(sb.InventoryQty1, 0), 2) AS DECIMAL(18, 2)) DESC,
rc.ProductCode ASC
OFFSET ` + strconv.Itoa(offset) + ` ROWS
FETCH NEXT ` + strconv.Itoa(limit) + ` ROWS ONLY;
`
var (
@@ -43,7 +217,7 @@ func GetProductPricingList(ctx context.Context, limit int, afterProductCode stri
)
for attempt := 1; attempt <= 3; attempt++ {
var err error
rows, err = db.MssqlDB.QueryContext(ctx, productQuery, afterProductCode)
rows, err = db.MssqlDB.QueryContext(ctx, productQuery, args...)
if err == nil {
rowsErr = nil
break
@@ -60,7 +234,7 @@ func GetProductPricingList(ctx context.Context, limit int, afterProductCode stri
}
}
if rowsErr != nil {
return nil, rowsErr
return result, rowsErr
}
defer rows.Close()
@@ -69,6 +243,7 @@ func GetProductPricingList(ctx context.Context, limit int, afterProductCode stri
var item models.ProductPricing
if err := rows.Scan(
&item.ProductCode,
&item.BrandGroupSec,
&item.AskiliYan,
&item.Kategori,
&item.UrunIlkGrubu,
@@ -78,15 +253,16 @@ func GetProductPricingList(ctx context.Context, limit int, afterProductCode stri
&item.Karisim,
&item.Marka,
); err != nil {
return nil, err
return result, err
}
out = append(out, item)
}
if err := rows.Err(); err != nil {
return nil, err
return result, err
}
if len(out) == 0 {
return out, nil
result.Rows = out
return result, nil
}
// Stage 2: fetch metrics only for paged product codes.
@@ -134,6 +310,7 @@ func GetProductPricingList(ctx context.Context, limit int, afterProductCode stri
WHERE s.ItemTypeCode = 1
AND LEN(LTRIM(RTRIM(s.ItemCode))) = 13
AND s.In_Qty1 > 0
AND LTRIM(RTRIM(s.InnerProcessCode)) = 'OP'
AND LTRIM(RTRIM(s.WarehouseCode)) IN (
'1-0-14','1-0-10','1-0-8','1-2-5','1-2-4','1-0-12','100','1-0-28',
'1-0-24','1-2-6','1-1-14','1-0-2','1-0-52','1-1-2','1-0-21','1-1-3',
@@ -214,7 +391,7 @@ func GetProductPricingList(ctx context.Context, limit int, afterProductCode stri
metricsRows, err := db.MssqlDB.QueryContext(ctx, metricsQuery, metricArgs...)
if err != nil {
return nil, fmt.Errorf("metrics query failed: %w", err)
return result, fmt.Errorf("metrics query failed: %w", err)
}
defer metricsRows.Close()
@@ -237,12 +414,12 @@ func GetProductPricingList(ctx context.Context, limit int, afterProductCode stri
&m.StockEntryDate,
&m.LastPricingDate,
); err != nil {
return nil, err
return result, err
}
metricsByCode[strings.TrimSpace(code)] = m
}
if err := metricsRows.Err(); err != nil {
return nil, err
return result, err
}
for i := range out {
@@ -254,7 +431,8 @@ func GetProductPricingList(ctx context.Context, limit int, afterProductCode stri
}
}
return out, nil
result.Rows = out
return result, nil
}
func isTransientMSSQLNetworkError(err error) bool {

View File

@@ -18,6 +18,7 @@ import (
"time"
"github.com/gorilla/mux"
"github.com/lib/pq"
"golang.org/x/crypto/bcrypt"
)
@@ -497,25 +498,65 @@ func UserCreateRoute(db *sql.DB) http.HandlerFunc {
// ROLES
for _, role := range payload.Roles {
_, _ = tx.Exec(queries.InsertUserRole, newID, role)
role = strings.TrimSpace(role)
if role == "" {
continue
}
if _, err := tx.Exec(queries.InsertUserRole, newID, role); err != nil {
log.Printf("USER ROLE INSERT ERROR user_id=%d role=%q err=%v", newID, role, err)
http.Error(w, "Rol eklenemedi", http.StatusInternalServerError)
return
}
}
// DEPARTMENTS
for _, d := range payload.Departments {
_, _ = tx.Exec(queries.InsertUserDepartment, newID, d.Code)
code := strings.TrimSpace(d.Code)
if code == "" {
continue
}
if _, err := tx.Exec(queries.InsertUserDepartment, newID, code); err != nil {
log.Printf("USER DEPARTMENT INSERT ERROR user_id=%d department=%q err=%v", newID, code, err)
http.Error(w, "Departman eklenemedi", http.StatusInternalServerError)
return
}
}
// PIYASALAR
for _, p := range payload.Piyasalar {
_, _ = tx.Exec(queries.InsertUserPiyasa, newID, p.Code)
code := strings.TrimSpace(p.Code)
if code == "" {
continue
}
if _, err := tx.Exec(queries.InsertUserPiyasa, newID, code); err != nil {
log.Printf("USER PIYASA INSERT ERROR user_id=%d piyasa=%q err=%v", newID, code, err)
http.Error(w, "Piyasa eklenemedi", http.StatusInternalServerError)
return
}
}
// NEBIM
for _, n := range payload.NebimUsers {
_, _ = tx.Exec(queries.InsertUserNebim, newID, n.Username)
username := strings.TrimSpace(n.Username)
if username == "" {
continue
}
if _, err := tx.Exec(queries.InsertUserNebim, newID, username); err != nil {
log.Printf("USER NEBIM INSERT ERROR user_id=%d username=%q err=%v", newID, username, err)
http.Error(w, "Nebim kullanıcısı eklenemedi", http.StatusInternalServerError)
return
}
}
if err := tx.Commit(); err != nil {
if pe, ok := err.(*pq.Error); ok {
log.Printf(
"USER CREATE COMMIT ERROR user_id=%d code=%s detail=%s constraint=%s table=%s err=%v",
newID, pe.Code, pe.Detail, pe.Constraint, pe.Table, err,
)
} else {
log.Printf("USER CREATE COMMIT ERROR user_id=%d err=%v", newID, err)
}
http.Error(w, "Commit başarısız", http.StatusInternalServerError)
return
}

View File

@@ -32,13 +32,31 @@ func GetProductPricingListHandler(w http.ResponseWriter, r *http.Request) {
limit := 500
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 10000 {
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 500 {
limit = parsed
}
}
afterProductCode := strings.TrimSpace(r.URL.Query().Get("after_product_code"))
page := 1
if raw := strings.TrimSpace(r.URL.Query().Get("page")); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 {
page = parsed
}
}
filters := queries.ProductPricingFilters{
Search: strings.TrimSpace(r.URL.Query().Get("q")),
ProductCode: splitCSVParam(r.URL.Query().Get("product_code")),
BrandGroup: splitCSVParam(r.URL.Query().Get("brand_group_selection")),
AskiliYan: splitCSVParam(r.URL.Query().Get("askili_yan")),
Kategori: splitCSVParam(r.URL.Query().Get("kategori")),
UrunIlkGrubu: splitCSVParam(r.URL.Query().Get("urun_ilk_grubu")),
UrunAnaGrubu: splitCSVParam(r.URL.Query().Get("urun_ana_grubu")),
UrunAltGrubu: splitCSVParam(r.URL.Query().Get("urun_alt_grubu")),
Icerik: splitCSVParam(r.URL.Query().Get("icerik")),
Karisim: splitCSVParam(r.URL.Query().Get("karisim")),
Marka: splitCSVParam(r.URL.Query().Get("marka")),
}
rows, err := queries.GetProductPricingList(ctx, limit+1, afterProductCode)
pageResult, err := queries.GetProductPricingPage(ctx, page, limit, filters)
if err != nil {
if isPricingTimeoutLike(err, ctx.Err()) {
log.Printf(
@@ -63,38 +81,24 @@ func GetProductPricingListHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Urun fiyatlandirma listesi alinamadi: "+err.Error(), http.StatusInternalServerError)
return
}
hasMore := len(rows) > limit
if hasMore {
rows = rows[:limit]
}
nextCursor := ""
if hasMore && len(rows) > 0 {
nextCursor = strings.TrimSpace(rows[len(rows)-1].ProductCode)
}
log.Printf(
"[ProductPricing] trace=%s success user=%s id=%d limit=%d after=%q count=%d has_more=%t next=%q duration_ms=%d",
"[ProductPricing] trace=%s success user=%s id=%d page=%d limit=%d count=%d total=%d total_pages=%d duration_ms=%d",
traceID,
claims.Username,
claims.ID,
pageResult.Page,
limit,
afterProductCode,
len(rows),
hasMore,
nextCursor,
len(pageResult.Rows),
pageResult.TotalCount,
pageResult.TotalPages,
time.Since(started).Milliseconds(),
)
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if hasMore {
w.Header().Set("X-Has-More", "true")
} else {
w.Header().Set("X-Has-More", "false")
}
if nextCursor != "" {
w.Header().Set("X-Next-Cursor", nextCursor)
}
_ = json.NewEncoder(w).Encode(rows)
w.Header().Set("X-Total-Count", strconv.Itoa(pageResult.TotalCount))
w.Header().Set("X-Total-Pages", strconv.Itoa(pageResult.TotalPages))
w.Header().Set("X-Page", strconv.Itoa(pageResult.Page))
_ = json.NewEncoder(w).Encode(pageResult.Rows)
}
func buildPricingTraceID(r *http.Request) string {
@@ -124,3 +128,20 @@ func isPricingTimeoutLike(err error, ctxErr error) bool {
strings.Contains(e, "no connection could be made") ||
strings.Contains(e, "failed to respond")
}
func splitCSVParam(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 == "" {
continue
}
out = append(out, p)
}
return out
}

View File

@@ -1,75 +0,0 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config file > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import { Quasar } from 'quasar'
import { markRaw } from 'vue'
import RootComponent from 'app/src/App.vue'
import createStore from 'app/src/stores/index'
import createRouter from 'app/src/router/index'
export default async function (createAppFn, quasarUserOptions) {
// Create the app instance.
// Here we inject into it the Quasar UI, the router & possibly the store.
const app = createAppFn(RootComponent)
app.use(Quasar, quasarUserOptions)
const store = typeof createStore === 'function'
? await createStore({})
: createStore
app.use(store)
const router = markRaw(
typeof createRouter === 'function'
? await createRouter({store})
: createRouter
)
// make router instance available in store
store.use(({ store }) => { store.router = router })
// Expose the app, the router and the store.
// Note that we are not mounting the app here, since bootstrapping will be
// different depending on whether we are in a browser or on the server.
return {
app,
store,
router
}
}

View File

@@ -1,158 +0,0 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config file > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import { createApp } from 'vue'
import '@quasar/extras/roboto-font/roboto-font.css'
import '@quasar/extras/material-icons/material-icons.css'
// We load Quasar stylesheet file
import 'quasar/dist/quasar.sass'
import 'src/css/app.css'
import createQuasarApp from './app.js'
import quasarUserOptions from './quasar-user-options.js'
const publicPath = `/`
async function start ({
app,
router
, store
}, bootFiles) {
let hasRedirected = false
const getRedirectUrl = url => {
try { return router.resolve(url).href }
catch (err) {}
return Object(url) === url
? null
: url
}
const redirect = url => {
hasRedirected = true
if (typeof url === 'string' && /^https?:\/\//.test(url)) {
window.location.href = url
return
}
const href = getRedirectUrl(url)
// continue if we didn't fail to resolve the url
if (href !== null) {
window.location.href = href
window.location.reload()
}
}
const urlPath = window.location.href.replace(window.location.origin, '')
for (let i = 0; hasRedirected === false && i < bootFiles.length; i++) {
try {
await bootFiles[i]({
app,
router,
store,
ssrContext: null,
redirect,
urlPath,
publicPath
})
}
catch (err) {
if (err && err.url) {
redirect(err.url)
return
}
console.error('[Quasar] boot error:', err)
return
}
}
if (hasRedirected === true) return
app.use(router)
app.mount('#q-app')
}
createQuasarApp(createApp, quasarUserOptions)
.then(app => {
// eventually remove this when Cordova/Capacitor/Electron support becomes old
const [ method, mapFn ] = Promise.allSettled !== void 0
? [
'allSettled',
bootFiles => bootFiles.map(result => {
if (result.status === 'rejected') {
console.error('[Quasar] boot error:', result.reason)
return
}
return result.value.default
})
]
: [
'all',
bootFiles => bootFiles.map(entry => entry.default)
]
return Promise[ method ]([
import(/* webpackMode: "eager" */ 'boot/dayjs'),
import(/* webpackMode: "eager" */ 'boot/locale'),
import(/* webpackMode: "eager" */ 'boot/resizeObserverGuard')
]).then(bootFiles => {
const boot = mapFn(bootFiles).filter(entry => typeof entry === 'function')
start(app, boot)
})
})

View File

@@ -1,116 +0,0 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config file > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import App from 'app/src/App.vue'
let appPrefetch = typeof App.preFetch === 'function'
? App.preFetch
: (
// Class components return the component options (and the preFetch hook) inside __c property
App.__c !== void 0 && typeof App.__c.preFetch === 'function'
? App.__c.preFetch
: false
)
function getMatchedComponents (to, router) {
const route = to
? (to.matched ? to : router.resolve(to).route)
: router.currentRoute.value
if (!route) { return [] }
const matched = route.matched.filter(m => m.components !== void 0)
if (matched.length === 0) { return [] }
return Array.prototype.concat.apply([], matched.map(m => {
return Object.keys(m.components).map(key => {
const comp = m.components[key]
return {
path: m.path,
c: comp
}
})
}))
}
export function addPreFetchHooks ({ router, store, publicPath }) {
// Add router hook for handling preFetch.
// Doing it after initial route is resolved so that we don't double-fetch
// the data that we already have. Using router.beforeResolve() so that all
// async components are resolved.
router.beforeResolve((to, from, next) => {
const
urlPath = window.location.href.replace(window.location.origin, ''),
matched = getMatchedComponents(to, router),
prevMatched = getMatchedComponents(from, router)
let diffed = false
const preFetchList = matched
.filter((m, i) => {
return diffed || (diffed = (
!prevMatched[i] ||
prevMatched[i].c !== m.c ||
m.path.indexOf('/:') > -1 // does it has params?
))
})
.filter(m => m.c !== void 0 && (
typeof m.c.preFetch === 'function'
// Class components return the component options (and the preFetch hook) inside __c property
|| (m.c.__c !== void 0 && typeof m.c.__c.preFetch === 'function')
))
.map(m => m.c.__c !== void 0 ? m.c.__c.preFetch : m.c.preFetch)
if (appPrefetch !== false) {
preFetchList.unshift(appPrefetch)
appPrefetch = false
}
if (preFetchList.length === 0) {
return next()
}
let hasRedirected = false
const redirect = url => {
hasRedirected = true
next(url)
}
const proceed = () => {
if (hasRedirected === false) { next() }
}
preFetchList.reduce(
(promise, preFetch) => promise.then(() => hasRedirected === false && preFetch({
store,
currentRoute: to,
previousRoute: from,
redirect,
urlPath,
publicPath
})),
Promise.resolve()
)
.then(proceed)
.catch(e => {
console.error(e)
proceed()
})
})
}

View File

@@ -1,23 +0,0 @@
/* eslint-disable */
/**
* THIS FILE IS GENERATED AUTOMATICALLY.
* DO NOT EDIT.
*
* You are probably looking on adding startup/initialization code.
* Use "quasar new boot <name>" and add it there.
* One boot file per concern. Then reference the file(s) in quasar.config file > boot:
* boot: ['file', ...] // do not add ".js" extension to it.
*
* Boot files are your "main.js"
**/
import lang from 'quasar/lang/tr.js'
import {Loading,Dialog,Notify} from 'quasar'
export default { config: {"notify":{"position":"top","timeout":2500}},lang,plugins: {Loading,Dialog,Notify} }

View File

@@ -35,6 +35,26 @@
/>
<q-btn flat color="grey-7" icon="restart_alt" label="Filtreleri Sifirla" @click="resetAll" />
<q-btn color="primary" icon="refresh" label="Veriyi Yenile" :loading="store.loading" @click="reloadData" />
<q-btn
color="primary"
outline
icon="edit_note"
label="Secili Olanlari Toplu Degistir"
:disable="selectedRowCount === 0"
@click="bulkDialogOpen = true"
/>
<q-pagination
v-model="currentPage"
color="primary"
:max="Math.max(1, store.totalPages || 1)"
:max-pages="8"
boundary-links
direction-links
@update:model-value="onPageChange"
/>
<div class="text-caption text-grey-8">
Sayfa {{ currentPage }} / {{ Math.max(1, store.totalPages || 1) }} - Toplam {{ store.totalCount || 0 }} urun kodu
</div>
</div>
</div>
@@ -44,19 +64,15 @@
class="pane-table pricing-table"
flat
dense
row-key="id"
row-key="productCode"
:rows="filteredRows"
:columns="visibleColumns"
:loading="store.loading"
virtual-scroll
:virtual-scroll-item-size="rowHeight"
:virtual-scroll-sticky-size-start="headerHeight"
:virtual-scroll-slice-size="36"
:loading="tableLoading"
:rows-per-page-options="[0]"
v-model:pagination="tablePagination"
:pagination="tablePagination"
hide-bottom
:table-style="tableStyle"
@virtual-scroll="onTableVirtualScroll"
@update:pagination="onPaginationChange"
>
<template #header="props">
<q-tr :props="props" class="header-row-fixed">
@@ -139,6 +155,54 @@
Sonuc yok
</div>
</div>
<div v-else-if="isValueSelectFilterField(col.field)" class="excel-filter-menu">
<q-input
v-model="valueFilterSearch[col.field]"
dense
outlined
clearable
use-input
class="excel-filter-select"
placeholder="Deger ara"
/>
<div class="excel-filter-actions row items-center justify-between q-pt-xs">
<q-btn flat dense size="sm" label="Tumunu Sec" @click="selectAllColumnFilterOptions(col.field)" />
<q-btn flat dense size="sm" 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
:key="`${col.field}-${option.value}`"
dense
clickable
class="excel-filter-option"
@click="toggleColumnFilterValue(col.field, option.value)"
>
<q-item-section avatar>
<q-checkbox
dense
size="sm"
:model-value="isColumnFilterValueSelected(col.field, option.value)"
@update:model-value="() => toggleColumnFilterValue(col.field, option.value)"
@click.stop
/>
</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
@@ -216,8 +280,25 @@
<q-checkbox
size="sm"
color="primary"
:model-value="!!selectedMap[props.row.id]"
@update:model-value="(val) => toggleRowSelection(props.row.id, val)"
:model-value="isRowSelected(props.row.productCode)"
@update:model-value="(val) => onRowCheckboxChange(props.row, val)"
@click.stop
/>
</q-td>
</template>
<template #body-cell-calcAction="props">
<q-td
:props="props"
:class="{ 'sticky-col': isStickyCol(props.col.name), 'sticky-boundary': isStickyBoundary(props.col.name) }"
:style="getBodyCellStyle(props.col)"
>
<q-btn
dense
size="sm"
color="primary"
label="Hesapla"
@click="calculateRow(props.row)"
/>
</q-td>
</template>
@@ -306,17 +387,52 @@
<q-banner v-if="store.error" class="bg-red text-white q-mt-xs">
Hata: {{ store.error }}
</q-banner>
<q-dialog v-model="bulkDialogOpen">
<q-card style="min-width: 420px; max-width: 95vw;">
<q-card-section class="text-subtitle1 text-weight-bold">
Secili Olanlari Toplu Degistir
</q-card-section>
<q-card-section class="q-gutter-sm">
<q-select
v-model="bulkField"
:options="bulkFieldOptions"
option-value="value"
option-label="label"
emit-value
map-options
dense
outlined
label="Alan"
/>
<q-input
v-model="bulkValue"
dense
outlined
label="Deger"
inputmode="decimal"
/>
<div class="text-caption text-grey-8">
Uygulanacak satir sayisi: {{ selectedRowCount }}
</div>
</q-card-section>
<q-card-actions align="right">
<q-btn flat label="Iptal" v-close-popup />
<q-btn color="primary" label="Uygula" @click="applyBulkUpdate" />
</q-card-actions>
</q-card>
</q-dialog>
</q-page>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useProductPricingStore } from 'src/stores/ProductPricingStore'
const store = useProductPricingStore()
const FETCH_LIMIT = 500
const nextCursor = ref('')
const loadingMore = ref(false)
const PAGE_LIMIT = 500
const currentPage = ref(1)
let reloadTimer = null
const usdToTry = 38.25
const eurToTry = 41.6
@@ -338,6 +454,8 @@ const currencyOptions = [
const multiFilterColumns = [
{ field: 'productCode', label: 'Urun Kodu' },
{ field: 'brandGroupSelection', label: 'Marka Grubu Secimi' },
{ field: 'marka', label: 'Marka' },
{ field: 'askiliYan', label: 'Askili Yan' },
{ field: 'kategori', label: 'Kategori' },
{ field: 'urunIlkGrubu', label: 'Urun Ilk Grubu' },
@@ -346,10 +464,48 @@ const multiFilterColumns = [
{ field: 'icerik', label: 'Icerik' },
{ field: 'karisim', label: 'Karisim' }
]
const serverBackedMultiFilterFields = new Set([
'productCode',
'brandGroupSelection',
'marka',
'askiliYan',
'kategori',
'urunIlkGrubu',
'urunAnaGrubu',
'urunAltGrubu',
'icerik',
'karisim'
])
const numberRangeFilterFields = ['stockQty']
const dateRangeFilterFields = ['stockEntryDate', 'lastPricingDate']
const valueFilterFields = [
'costPrice',
'expenseForBasePrice',
'basePriceUsd',
'basePriceTry',
'usd1',
'usd2',
'usd3',
'usd4',
'usd5',
'usd6',
'eur1',
'eur2',
'eur3',
'eur4',
'eur5',
'eur6',
'try1',
'try2',
'try3',
'try4',
'try5',
'try6'
]
const columnFilters = ref({
productCode: [],
brandGroupSelection: [],
marka: [],
askiliYan: [],
kategori: [],
urunIlkGrubu: [],
@@ -360,6 +516,8 @@ const columnFilters = ref({
})
const columnFilterSearch = ref({
productCode: '',
brandGroupSelection: '',
marka: '',
askiliYan: '',
kategori: '',
urunIlkGrubu: '',
@@ -375,23 +533,30 @@ const dateRangeFilters = ref({
stockEntryDate: { from: '', to: '' },
lastPricingDate: { from: '', to: '' }
})
const valueFilters = ref(Object.fromEntries(valueFilterFields.map((field) => [field, []])))
const valueFilterSearch = ref(Object.fromEntries(valueFilterFields.map((field) => [field, ''])))
const multiSelectFilterFieldSet = new Set(multiFilterColumns.map((x) => x.field))
const numberRangeFilterFieldSet = new Set(numberRangeFilterFields)
const dateRangeFilterFieldSet = new Set(dateRangeFilterFields)
const valueSelectFilterFieldSet = new Set(valueFilterFields)
const headerFilterFieldSet = new Set([
...multiFilterColumns.map((x) => x.field),
...numberRangeFilterFields,
...dateRangeFilterFields
...dateRangeFilterFields,
...valueFilterFields
])
const mainTableRef = ref(null)
const tablePagination = ref({
page: 1,
page: 1, // server-side paging var; q-table local paging kapali
rowsPerPage: 0,
sortBy: 'productCode',
descending: false
sortBy: 'stockQty',
descending: true
})
const selectedMap = ref({})
const bulkDialogOpen = ref(false)
const bulkField = ref('expenseForBasePrice')
const bulkValue = ref('')
const selectedCurrencies = ref(['USD', 'EUR', 'TRY'])
const showSelectedOnly = ref(false)
@@ -437,7 +602,10 @@ function col (name, label, field, width, extra = {}) {
const allColumns = [
col('select', '', 'select', 40, { align: 'center', classes: 'text-center selection-col' }),
col('brandGroupSelection', 'MARKA GRUBU SECIMI', 'brandGroupSelection', 76),
col('marka', 'MARKA', 'marka', 54, { sortable: true, classes: 'ps-col' }),
col('productCode', 'URUN KODU', 'productCode', 108, { sortable: true, classes: 'ps-col product-code-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('stockEntryDate', 'STOK GIRIS TARIHI', 'stockEntryDate', 92, { 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' }),
@@ -448,8 +616,6 @@ const allColumns = [
col('urunAltGrubu', 'URUN ALT GRUBU', 'urunAltGrubu', 66, { sortable: true, classes: 'ps-col' }),
col('icerik', 'ICERIK', 'icerik', 62, { sortable: true, classes: 'ps-col' }),
col('karisim', 'KARISIM', 'karisim', 62, { sortable: true, classes: 'ps-col' }),
col('marka', 'MARKA', 'marka', 54, { sortable: true, classes: 'ps-col' }),
col('brandGroupSelection', 'MARKA GRUBU SECIMI', 'brandGroupSelection', 76),
col('costPrice', 'MALIYET FIYATI', 'costPrice', 74, { align: 'right', sortable: true, classes: 'usd-col' }),
col('expenseForBasePrice', 'TABAN FIYAT MASRAF', 'expenseForBasePrice', 86, { align: 'right', classes: 'usd-col' }),
col('basePriceUsd', 'TABAN USD', 'basePriceUsd', 74, { align: 'right', classes: 'usd-col' }),
@@ -476,7 +642,10 @@ const allColumns = [
const stickyColumnNames = [
'select',
'brandGroupSelection',
'marka',
'productCode',
'calcAction',
'stockQty',
'stockEntryDate',
'lastPricingDate',
@@ -487,8 +656,6 @@ const stickyColumnNames = [
'urunAltGrubu',
'icerik',
'karisim',
'marka',
'brandGroupSelection',
'costPrice',
'expenseForBasePrice',
'basePriceUsd',
@@ -531,6 +698,17 @@ const tableStyle = computed(() => ({
}))
const rows = computed(() => store.rows || [])
const tableLoading = computed(() => Boolean(store.loading) && rows.value.length === 0)
const bulkFieldOptions = computed(() => {
return editableColumns
.map((name) => {
const colDef = allColumns.find((c) => c.field === name)
return {
value: name,
label: colDef?.label || name
}
})
})
const multiFilterOptionMap = computed(() => {
const map = {}
multiFilterColumns.forEach(({ field }) => {
@@ -556,13 +734,52 @@ const filteredFilterOptionMap = computed(() => {
})
return map
})
const valueFilterOptionMap = computed(() => {
const map = {}
valueFilterFields.forEach((field) => {
const uniq = new Set()
rows.value.forEach((row) => {
uniq.add(toValueFilterKey(row?.[field]))
})
map[field] = Array.from(uniq)
.sort((a, b) => Number(a) - Number(b))
.map((v) => ({ label: formatPrice(v), value: v }))
})
return map
})
const filteredValueFilterOptionMap = computed(() => {
const map = {}
valueFilterFields.forEach((field) => {
const search = String(valueFilterSearch.value[field] || '').trim().toLocaleLowerCase('tr')
const options = valueFilterOptionMap.value[field] || []
map[field] = search
? options.filter((option) => option.label.toLocaleLowerCase('tr').includes(search))
: options
})
return map
})
function rowSelectionKey (row) {
const code = String(row?.productCode ?? '').trim()
if (code) return code
return String(row?.id ?? '')
}
const filteredRows = computed(() => {
return rows.value.filter((row) => {
if (showSelectedOnly.value && !selectedMap.value[row.id]) return false
for (const mf of multiFilterColumns) {
const selected = columnFilters.value[mf.field] || []
if (selected.length > 0 && !selected.includes(String(row?.[mf.field] ?? '').trim())) return false
if (showSelectedOnly.value && !selectedMap.value[rowSelectionKey(row)]) return false
for (const { field } of multiFilterColumns) {
// Server-backed filters already reload full dataset (all pages) from backend.
// Keep only non-server multi filters (e.g. brandGroupSelection) as local page filter.
if (serverBackedMultiFilterFields.has(field)) continue
const selected = columnFilters.value[field] || []
if (selected.length <= 0) continue
const rowVal = String(row?.[field] ?? '').trim()
if (!selected.includes(rowVal)) return false
}
for (const field of valueFilterFields) {
const selected = valueFilters.value[field] || []
if (selected.length > 0 && !selected.includes(toValueFilterKey(row?.[field]))) return false
}
const stockQtyMin = parseNullableNumber(numberRangeFilters.value.stockQty?.min)
const stockQtyMax = parseNullableNumber(numberRangeFilters.value.stockQty?.max)
@@ -575,12 +792,11 @@ const filteredRows = computed(() => {
})
})
const visibleRowIds = computed(() => filteredRows.value.map((row) => row.id))
const visibleRowIds = computed(() => filteredRows.value.map((row) => rowSelectionKey(row)))
const selectedRowCount = computed(() => Object.values(selectedMap.value).filter(Boolean).length)
const selectedVisibleCount = computed(() => visibleRowIds.value.filter((id) => !!selectedMap.value[id]).length)
const allSelectedVisible = computed(() => visibleRowIds.value.length > 0 && selectedVisibleCount.value === visibleRowIds.value.length)
const someSelectedVisible = computed(() => selectedVisibleCount.value > 0)
const hasMoreRows = computed(() => Boolean(store.hasMore))
function isHeaderFilterField (field) {
return headerFilterFieldSet.has(field)
@@ -598,8 +814,13 @@ function isDateRangeFilterField (field) {
return dateRangeFilterFieldSet.has(field)
}
function isValueSelectFilterField (field) {
return valueSelectFilterFieldSet.has(field)
}
function hasFilter (field) {
if (isMultiSelectFilterField(field)) return (columnFilters.value[field] || []).length > 0
if (isValueSelectFilterField(field)) return (valueFilters.value[field] || []).length > 0
if (isNumberRangeFilterField(field)) {
const filter = numberRangeFilters.value[field]
return !!String(filter?.min || '').trim() || !!String(filter?.max || '').trim()
@@ -613,6 +834,7 @@ function hasFilter (field) {
function getFilterBadgeValue (field) {
if (isMultiSelectFilterField(field)) return (columnFilters.value[field] || []).length
if (isValueSelectFilterField(field)) return (valueFilters.value[field] || []).length
if (isNumberRangeFilterField(field)) {
const filter = numberRangeFilters.value[field]
return [filter?.min, filter?.max].filter((x) => String(x || '').trim()).length
@@ -625,10 +847,18 @@ function getFilterBadgeValue (field) {
}
function clearColumnFilter (field) {
if (!isMultiSelectFilterField(field)) return
columnFilters.value = {
...columnFilters.value,
[field]: []
if (isMultiSelectFilterField(field)) {
columnFilters.value = {
...columnFilters.value,
[field]: []
}
return
}
if (isValueSelectFilterField(field)) {
valueFilters.value = {
...valueFilters.value,
[field]: []
}
}
}
@@ -649,17 +879,27 @@ function clearRangeFilter (field) {
}
function getFilterOptionsForField (field) {
if (isValueSelectFilterField(field)) return filteredValueFilterOptionMap.value[field] || []
return filteredFilterOptionMap.value[field] || []
}
function isColumnFilterValueSelected (field, value) {
if (isValueSelectFilterField(field)) return (valueFilters.value[field] || []).includes(value)
return (columnFilters.value[field] || []).includes(value)
}
function toggleColumnFilterValue (field, value) {
const current = new Set(columnFilters.value[field] || [])
const target = isValueSelectFilterField(field) ? valueFilters.value : columnFilters.value
const current = new Set(target[field] || [])
if (current.has(value)) current.delete(value)
else current.add(value)
if (isValueSelectFilterField(field)) {
valueFilters.value = {
...valueFilters.value,
[field]: Array.from(current)
}
return
}
columnFilters.value = {
...columnFilters.value,
[field]: Array.from(current)
@@ -668,6 +908,13 @@ function toggleColumnFilterValue (field, value) {
function selectAllColumnFilterOptions (field) {
const options = getFilterOptionsForField(field)
if (isValueSelectFilterField(field)) {
valueFilters.value = {
...valueFilters.value,
[field]: options.map((option) => option.value)
}
return
}
columnFilters.value = {
...columnFilters.value,
[field]: options.map((option) => option.value)
@@ -701,6 +948,10 @@ function round2 (value) {
return Number(Number(value || 0).toFixed(2))
}
function toValueFilterKey (value) {
return round2(parseNumber(value)).toFixed(2)
}
function parseNumber (val) {
if (typeof val === 'number') return Number.isFinite(val) ? val : 0
const text = String(val ?? '').trim().replace(/\s/g, '')
@@ -789,12 +1040,27 @@ function onEditableCellChange (row, field, val) {
if (field === 'expenseForBasePrice' || field === 'basePriceUsd') recalcByBasePrice(row)
}
function calculateRow (row) {
if (!row) return
recalcByBasePrice(row)
toggleRowSelection(rowSelectionKey(row), true)
}
function onBrandGroupSelectionChange (row, val) {
store.updateBrandGroupSelection(row, val)
}
function toggleRowSelection (rowId, val) {
selectedMap.value = { ...selectedMap.value, [rowId]: !!val }
function isRowSelected (rowKey) {
return !!selectedMap.value[rowKey]
}
function onRowCheckboxChange (row, val) {
if (!row) return
toggleRowSelection(rowSelectionKey(row), val)
}
function toggleRowSelection (rowKey, val) {
selectedMap.value = { ...selectedMap.value, [rowKey]: !!val }
}
function toggleSelectAllVisible (val) {
@@ -803,9 +1069,25 @@ function toggleSelectAllVisible (val) {
selectedMap.value = next
}
function applyBulkUpdate () {
const field = String(bulkField.value || '').trim()
if (!field || !editableColumnSet.has(field)) return
const parsed = parseNumber(bulkValue.value)
rows.value.forEach((row) => {
if (!isRowSelected(rowSelectionKey(row))) return
store.updateCell(row, field, parsed)
if (field === 'expenseForBasePrice' || field === 'basePriceUsd') {
recalcByBasePrice(row)
}
})
bulkDialogOpen.value = false
}
function resetAll () {
columnFilters.value = {
productCode: [],
brandGroupSelection: [],
marka: [],
askiliYan: [],
kategori: [],
urunIlkGrubu: [],
@@ -816,6 +1098,8 @@ function resetAll () {
}
columnFilterSearch.value = {
productCode: '',
brandGroupSelection: '',
marka: '',
askiliYan: '',
kategori: '',
urunIlkGrubu: '',
@@ -824,6 +1108,8 @@ function resetAll () {
icerik: '',
karisim: ''
}
valueFilters.value = Object.fromEntries(valueFilterFields.map((field) => [field, []]))
valueFilterSearch.value = Object.fromEntries(valueFilterFields.map((field) => [field, '']))
numberRangeFilters.value = {
stockQty: { min: '', max: '' }
}
@@ -863,53 +1149,57 @@ function clearAllCurrencies () {
selectedCurrencies.value = []
}
async function fetchChunk ({ reset = false } = {}) {
const afterProductCode = reset ? '' : nextCursor.value
function onPaginationChange (next) {
tablePagination.value = {
...tablePagination.value,
...(next || {}),
page: 1,
rowsPerPage: 0
}
}
function buildServerFilters () {
return {
product_code: columnFilters.value.productCode || [],
brand_group_selection: columnFilters.value.brandGroupSelection || [],
marka: columnFilters.value.marka || [],
askili_yan: columnFilters.value.askiliYan || [],
kategori: columnFilters.value.kategori || [],
urun_ilk_grubu: columnFilters.value.urunIlkGrubu || [],
urun_ana_grubu: columnFilters.value.urunAnaGrubu || [],
urun_alt_grubu: columnFilters.value.urunAltGrubu || [],
icerik: columnFilters.value.icerik || [],
karisim: columnFilters.value.karisim || []
}
}
function scheduleReload () {
if (reloadTimer) clearTimeout(reloadTimer)
reloadTimer = setTimeout(() => {
reloadTimer = null
void reloadData({ page: 1 })
}, 180)
}
async function fetchChunk ({ page = 1 } = {}) {
const result = await store.fetchRows({
limit: FETCH_LIMIT,
afterProductCode,
append: !reset
limit: PAGE_LIMIT,
page,
append: false,
silent: false,
filters: buildServerFilters()
})
const fetched = Number(result?.fetched) || 0
nextCursor.value = String(result?.nextCursor || '')
return fetched
currentPage.value = Number(result?.page) || page
return Number(result?.fetched) || 0
}
async function loadMoreRows () {
if (loadingMore.value || store.loading || !hasMoreRows.value) return
loadingMore.value = true
try {
await fetchChunk({ reset: false })
} finally {
loadingMore.value = false
}
}
function onTableVirtualScroll (details) {
const to = Number(details?.to || 0)
if (!Number.isFinite(to)) return
if (to >= filteredRows.value.length - 25) {
void loadMoreRows()
}
}
async function ensureEnoughVisibleRows (minRows = 80, maxBatches = 4) {
let guard = 0
while (hasMoreRows.value && filteredRows.value.length < minRows && guard < maxBatches) {
await loadMoreRows()
guard++
}
}
async function reloadData () {
async function reloadData ({ page = 1 } = {}) {
const startedAt = Date.now()
console.info('[product-pricing][ui] reload:start', {
at: new Date(startedAt).toISOString()
})
try {
nextCursor.value = ''
await fetchChunk({ reset: true })
await ensureEnoughVisibleRows(120, 6)
await fetchChunk({ page })
} catch (err) {
console.error('[product-pricing][ui] reload:error', {
duration_ms: Date.now() - startedAt,
@@ -924,20 +1214,28 @@ async function reloadData () {
selectedMap.value = {}
}
function onPageChange (page) {
const p = Number(page) > 0 ? Number(page) : 1
if (store.loading) return
if (p === currentPage.value && p === (store.page || 1)) return
currentPage.value = p
void reloadData({ page: p })
}
onMounted(async () => {
await reloadData()
await reloadData({ page: currentPage.value })
})
onBeforeUnmount(() => {
if (reloadTimer) {
clearTimeout(reloadTimer)
reloadTimer = null
}
})
watch(
[
columnFilters,
numberRangeFilters,
dateRangeFilters,
showSelectedOnly,
() => tablePagination.value.sortBy,
() => tablePagination.value.descending
],
() => { void ensureEnoughVisibleRows(80, 4) },
[columnFilters],
() => { scheduleReload() },
{ deep: true }
)
</script>

View File

@@ -77,33 +77,175 @@ function mapRow (raw, index, baseIndex = 0) {
}
}
function cloneRows (rows = []) {
return rows.map((r) => ({ ...r }))
}
function normalizeFilterList (list) {
if (!Array.isArray(list)) return []
return list.map((x) => toText(x)).filter(Boolean).sort()
}
function normalizeFilters (filters = {}) {
const keys = ['product_code', 'brand_group_selection', 'askili_yan', 'kategori', 'urun_ilk_grubu', 'urun_ana_grubu', 'urun_alt_grubu', 'icerik', 'karisim', 'marka']
const out = {}
for (const key of keys) out[key] = normalizeFilterList(filters[key])
const q = toText(filters.q)
if (q) out.q = q
return out
}
function makeCacheKey (limit, page, filters) {
return JSON.stringify({
limit: Number(limit) || 500,
page: Number(page) || 1,
filters: normalizeFilters(filters)
})
}
export const useProductPricingStore = defineStore('product-pricing-store', {
state: () => ({
rows: [],
loading: false,
error: '',
hasMore: true
hasMore: true,
page: 1,
totalPages: 1,
totalCount: 0,
pageCache: {},
cacheOrder: [],
prefetchInFlight: {}
}),
actions: {
async fetchRows (options = {}) {
this.loading = true
this.error = ''
cachePut (key, value) {
this.pageCache[key] = value
this.cacheOrder = this.cacheOrder.filter((x) => x !== key)
this.cacheOrder.push(key)
while (this.cacheOrder.length > 24) {
const oldest = this.cacheOrder.shift()
if (oldest) delete this.pageCache[oldest]
}
},
cacheGet (key) {
return this.pageCache[key] || null
},
applyPageResult (payload = {}, requestedPage = 1) {
const data = Array.isArray(payload?.rows) ? payload.rows : []
this.rows = cloneRows(data)
this.totalCount = Number.isFinite(payload?.totalCount) ? payload.totalCount : 0
this.totalPages = Math.max(1, Number(payload?.totalPages || 1))
this.page = Math.max(1, Number(payload?.page || requestedPage))
this.hasMore = this.page < this.totalPages
},
async prefetchPage (options = {}) {
const limit = Number(options?.limit) > 0 ? Number(options.limit) : 500
const afterProductCode = toText(options?.afterProductCode)
const page = Number(options?.page) > 0 ? Number(options.page) : 1
const filters = normalizeFilters(options?.filters || {})
const key = makeCacheKey(limit, page, filters)
if (this.pageCache[key]) return
if (this.prefetchInFlight[key]) {
await this.prefetchInFlight[key]
return
}
const run = async () => {
try {
const params = { limit, page }
for (const k of Object.keys(filters)) {
if (k === 'q') {
params.q = filters.q
continue
}
if (Array.isArray(filters[k]) && filters[k].length > 0) {
params[k] = filters[k].join(',')
}
}
const res = await api.request({
method: 'GET',
url: '/pricing/products',
params,
timeout: 180000
})
const totalCount = Number(res?.headers?.['x-total-count'] || 0)
const totalPages = Math.max(1, Number(res?.headers?.['x-total-pages'] || 1))
const currentPage = Math.max(1, Number(res?.headers?.['x-page'] || page))
const data = Array.isArray(res?.data) ? res.data : []
const mapped = data.map((x, i) => mapRow(x, i, 0))
this.cachePut(key, {
rows: mapped,
totalCount: Number.isFinite(totalCount) ? totalCount : 0,
totalPages: Number.isFinite(totalPages) ? totalPages : 1,
page: currentPage
})
} catch {
}
}
this.prefetchInFlight[key] = run()
try {
await this.prefetchInFlight[key]
} finally {
delete this.prefetchInFlight[key]
}
},
async fetchRows (options = {}) {
const silent = Boolean(options?.silent)
if (!silent) {
this.loading = true
this.error = ''
}
const limit = Number(options?.limit) > 0 ? Number(options.limit) : 500
const page = Number(options?.page) > 0 ? Number(options.page) : 1
const append = Boolean(options?.append)
const baseIndex = append ? this.rows.length : 0
const filters = normalizeFilters(options?.filters || {})
const cacheKey = makeCacheKey(limit, page, filters)
const startedAt = Date.now()
console.info('[product-pricing][frontend] request:start', {
at: new Date(startedAt).toISOString(),
timeout_ms: 180000,
limit,
after_product_code: afterProductCode || null,
page,
append
})
try {
const params = { limit }
if (afterProductCode) params.after_product_code = afterProductCode
if (options?.useCache !== false) {
const inFlight = this.prefetchInFlight[cacheKey]
if (inFlight) {
await inFlight
}
const cached = this.cacheGet(cacheKey)
if (cached) {
this.applyPageResult(cached, page)
console.info('[product-pricing][frontend] request:cache-hit', {
page: this.page,
total_pages: this.totalPages,
row_count: this.rows.length,
duration_ms: Date.now() - startedAt
})
return {
traceId: null,
fetched: this.rows.length,
hasMore: this.hasMore,
page: this.page,
totalPages: this.totalPages,
totalCount: this.totalCount
}
}
}
const params = { limit, page }
for (const key of Object.keys(filters)) {
if (key === 'q') {
params.q = filters.q
continue
}
const list = filters[key]
if (Array.isArray(list) && list.length > 0) params[key] = list.join(',')
}
const res = await api.request({
method: 'GET',
url: '/pricing/products',
@@ -111,44 +253,48 @@ export const useProductPricingStore = defineStore('product-pricing-store', {
timeout: 180000
})
const traceId = res?.headers?.['x-trace-id'] || null
const hasMoreHeader = String(res?.headers?.['x-has-more'] || '').toLowerCase()
const nextCursorHeader = toText(res?.headers?.['x-next-cursor'])
const totalCount = Number(res?.headers?.['x-total-count'] || 0)
const totalPages = Math.max(1, Number(res?.headers?.['x-total-pages'] || 1))
const currentPage = Math.max(1, Number(res?.headers?.['x-page'] || page))
const data = Array.isArray(res?.data) ? res.data : []
const mapped = data.map((x, i) => mapRow(x, i, baseIndex))
const fallbackNextCursor = mapped.length > 0
? toText(mapped[mapped.length - 1]?.productCode)
: ''
const nextCursor = nextCursorHeader || fallbackNextCursor
if (append) {
const merged = [...this.rows]
const seen = new Set(this.rows.map((x) => x?.productCode))
for (const row of mapped) {
const key = row?.productCode
if (key && seen.has(key)) continue
merged.push(row)
if (key) seen.add(key)
}
this.rows = merged
} else {
this.rows = mapped
const payload = {
rows: mapped,
totalCount: Number.isFinite(totalCount) ? totalCount : 0,
totalPages: Number.isFinite(totalPages) ? totalPages : 1,
page: Number.isFinite(currentPage) ? currentPage : page
}
this.cachePut(cacheKey, payload)
this.applyPageResult(payload, page)
// Background prefetch for next page to reduce perceived wait on page change.
if (this.page < this.totalPages) {
void this.prefetchPage({
limit,
page: this.page + 1,
filters
})
}
this.hasMore = hasMoreHeader ? hasMoreHeader === 'true' : mapped.length === limit
console.info('[product-pricing][frontend] request:success', {
trace_id: traceId,
duration_ms: Date.now() - startedAt,
row_count: this.rows.length,
fetched_count: mapped.length,
has_more: this.hasMore,
next_cursor: nextCursor || null
page: this.page,
total_pages: this.totalPages,
total_count: this.totalCount
})
return {
traceId,
fetched: mapped.length,
hasMore: this.hasMore,
nextCursor
page: this.page,
totalPages: this.totalPages,
totalCount: this.totalCount
}
} catch (err) {
if (!append) this.rows = []
this.rows = []
this.hasMore = false
const msg = err?.response?.data || err?.message || 'Urun fiyatlandirma listesi alinamadi'
this.error = toText(msg)
@@ -161,7 +307,7 @@ export const useProductPricingStore = defineStore('product-pricing-store', {
})
throw err
} finally {
this.loading = false
if (!silent) this.loading = false
}
},