From d2387bc221a1d3e2a068833cb5a6811ffed0864d Mon Sep 17 00:00:00 2001 From: M_Kececi Date: Wed, 22 Apr 2026 11:19:36 +0300 Subject: [PATCH] Merge remote-tracking branch 'origin/master' --- svc/queries/product_pricing.go | 232 ++++++++- svc/routes/login.go | 49 +- svc/routes/product_pricing.go | 73 ++- ui/.quasar/prod-spa/app.js | 75 --- ui/.quasar/prod-spa/client-entry.js | 158 ------ ui/.quasar/prod-spa/client-prefetch.js | 116 ----- ui/.quasar/prod-spa/quasar-user-options.js | 23 - ...g.js.temporary.compiled.1776695212119.mjs} | 0 ui/src/pages/ProductPricing.vue | 464 ++++++++++++++---- ui/src/stores/ProductPricingStore.js | 208 ++++++-- 10 files changed, 855 insertions(+), 543 deletions(-) delete mode 100644 ui/.quasar/prod-spa/app.js delete mode 100644 ui/.quasar/prod-spa/client-entry.js delete mode 100644 ui/.quasar/prod-spa/client-prefetch.js delete mode 100644 ui/.quasar/prod-spa/quasar-user-options.js rename ui/{quasar.config.js.temporary.compiled.1776338223103.mjs => quasar.config.js.temporary.compiled.1776695212119.mjs} (100%) diff --git a/svc/queries/product_pricing.go b/svc/queries/product_pricing.go index 728dabe..be9ccc4 100644 --- a/svc/queries/product_pricing.go +++ b/svc/queries/product_pricing.go @@ -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 { diff --git a/svc/routes/login.go b/svc/routes/login.go index 6d45279..b4761ec 100644 --- a/svc/routes/login.go +++ b/svc/routes/login.go @@ -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 } diff --git a/svc/routes/product_pricing.go b/svc/routes/product_pricing.go index 12104ab..3bedc29 100644 --- a/svc/routes/product_pricing.go +++ b/svc/routes/product_pricing.go @@ -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 +} diff --git a/ui/.quasar/prod-spa/app.js b/ui/.quasar/prod-spa/app.js deleted file mode 100644 index caeaac1..0000000 --- a/ui/.quasar/prod-spa/app.js +++ /dev/null @@ -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 " 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 - } -} diff --git a/ui/.quasar/prod-spa/client-entry.js b/ui/.quasar/prod-spa/client-entry.js deleted file mode 100644 index 5223e2b..0000000 --- a/ui/.quasar/prod-spa/client-entry.js +++ /dev/null @@ -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 " 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) - }) - }) - diff --git a/ui/.quasar/prod-spa/client-prefetch.js b/ui/.quasar/prod-spa/client-prefetch.js deleted file mode 100644 index 9bbe3c5..0000000 --- a/ui/.quasar/prod-spa/client-prefetch.js +++ /dev/null @@ -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 " 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() - }) - }) -} diff --git a/ui/.quasar/prod-spa/quasar-user-options.js b/ui/.quasar/prod-spa/quasar-user-options.js deleted file mode 100644 index ac1dae3..0000000 --- a/ui/.quasar/prod-spa/quasar-user-options.js +++ /dev/null @@ -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 " 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} } - diff --git a/ui/quasar.config.js.temporary.compiled.1776338223103.mjs b/ui/quasar.config.js.temporary.compiled.1776695212119.mjs similarity index 100% rename from ui/quasar.config.js.temporary.compiled.1776338223103.mjs rename to ui/quasar.config.js.temporary.compiled.1776695212119.mjs diff --git a/ui/src/pages/ProductPricing.vue b/ui/src/pages/ProductPricing.vue index 9be349a..61a2044 100644 --- a/ui/src/pages/ProductPricing.vue +++ b/ui/src/pages/ProductPricing.vue @@ -35,6 +35,26 @@ /> + + +
+ Sayfa {{ currentPage }} / {{ Math.max(1, store.totalPages || 1) }} - Toplam {{ store.totalCount || 0 }} urun kodu +
@@ -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" > + + @@ -306,17 +387,52 @@ Hata: {{ store.error }} + + + + + Secili Olanlari Toplu Degistir + + + + +
+ Uygulanacak satir sayisi: {{ selectedRowCount }} +
+
+ + + + +
+
diff --git a/ui/src/stores/ProductPricingStore.js b/ui/src/stores/ProductPricingStore.js index de09c84..1bf0b2b 100644 --- a/ui/src/stores/ProductPricingStore.js +++ b/ui/src/stores/ProductPricingStore.js @@ -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 } },