Compare commits

...

2 Commits

Author SHA1 Message Date
M_Kececi
c6bdf83f05 Merge remote-tracking branch 'origin/master' 2026-04-17 12:16:50 +03:00
M_Kececi
f9728b8a4c Merge remote-tracking branch 'origin/master' 2026-04-16 17:46:50 +03:00
13 changed files with 920 additions and 110 deletions

View File

@@ -847,6 +847,11 @@ func main() {
auditlog.Init(pgDB, 1000) auditlog.Init(pgDB, 1000)
log.Println("🕵️ AuditLog sistemi başlatıldı (buffer=1000)") log.Println("🕵️ AuditLog sistemi başlatıldı (buffer=1000)")
// -------------------------------------------------------
// 🚀 TRANSLATION QUERY PERFORMANCE INDEXES
// -------------------------------------------------------
routes.EnsureTranslationPerfIndexes(pgDB)
// ------------------------------------------------------- // -------------------------------------------------------
// ✉️ MAILER INIT // ✉️ MAILER INIT
// ------------------------------------------------------- // -------------------------------------------------------

View File

@@ -5,12 +5,25 @@ import (
"bssapp-backend/models" "bssapp-backend/models"
"context" "context"
"database/sql" "database/sql"
"strconv"
"strings" "strings"
"time" "time"
) )
func GetProductPricingList(ctx context.Context) ([]models.ProductPricing, error) { func GetProductPricingList(ctx context.Context, limit int, afterProductCode string) ([]models.ProductPricing, error) {
const query = ` if limit <= 0 {
limit = 500
}
afterProductCode = strings.TrimSpace(afterProductCode)
cursorFilter := ""
args := make([]any, 0, 1)
if afterProductCode != "" {
cursorFilter = "WHERE bp.ProductCode > @p1"
args = append(args, afterProductCode)
}
query := `
WITH base_products AS ( WITH base_products AS (
SELECT SELECT
LTRIM(RTRIM(ProductCode)) AS ProductCode, LTRIM(RTRIM(ProductCode)) AS ProductCode,
@@ -27,6 +40,13 @@ func GetProductPricingList(ctx context.Context) ([]models.ProductPricing, error)
AND IsBlocked = 0 AND IsBlocked = 0
AND LEN(LTRIM(RTRIM(ProductCode))) = 13 AND LEN(LTRIM(RTRIM(ProductCode))) = 13
), ),
paged_products AS (
SELECT TOP (` + strconv.Itoa(limit) + `)
bp.ProductCode
FROM base_products bp
` + cursorFilter + `
ORDER BY bp.ProductCode
),
latest_base_price AS ( latest_base_price AS (
SELECT SELECT
LTRIM(RTRIM(b.ItemCode)) AS ItemCode, LTRIM(RTRIM(b.ItemCode)) AS ItemCode,
@@ -42,8 +62,8 @@ func GetProductPricingList(ctx context.Context) ([]models.ProductPricing, error)
AND LTRIM(RTRIM(b.CurrencyCode)) = 'USD' AND LTRIM(RTRIM(b.CurrencyCode)) = 'USD'
AND EXISTS ( AND EXISTS (
SELECT 1 SELECT 1
FROM base_products bp FROM paged_products pp
WHERE bp.ProductCode = LTRIM(RTRIM(b.ItemCode)) WHERE pp.ProductCode = LTRIM(RTRIM(b.ItemCode))
) )
), ),
stock_entry_dates AS ( stock_entry_dates AS (
@@ -61,8 +81,8 @@ func GetProductPricingList(ctx context.Context) ([]models.ProductPricing, error)
) )
AND EXISTS ( AND EXISTS (
SELECT 1 SELECT 1
FROM base_products bp FROM paged_products pp
WHERE bp.ProductCode = LTRIM(RTRIM(s.ItemCode)) WHERE pp.ProductCode = LTRIM(RTRIM(s.ItemCode))
) )
GROUP BY LTRIM(RTRIM(s.ItemCode)) GROUP BY LTRIM(RTRIM(s.ItemCode))
), ),
@@ -75,8 +95,8 @@ func GetProductPricingList(ctx context.Context) ([]models.ProductPricing, error)
AND LEN(LTRIM(RTRIM(s.ItemCode))) = 13 AND LEN(LTRIM(RTRIM(s.ItemCode))) = 13
AND EXISTS ( AND EXISTS (
SELECT 1 SELECT 1
FROM base_products bp FROM paged_products pp
WHERE bp.ProductCode = LTRIM(RTRIM(s.ItemCode)) WHERE pp.ProductCode = LTRIM(RTRIM(s.ItemCode))
) )
GROUP BY LTRIM(RTRIM(s.ItemCode)) GROUP BY LTRIM(RTRIM(s.ItemCode))
), ),
@@ -89,8 +109,8 @@ func GetProductPricingList(ctx context.Context) ([]models.ProductPricing, error)
AND LEN(LTRIM(RTRIM(p.ItemCode))) = 13 AND LEN(LTRIM(RTRIM(p.ItemCode))) = 13
AND EXISTS ( AND EXISTS (
SELECT 1 SELECT 1
FROM base_products bp FROM paged_products pp
WHERE bp.ProductCode = LTRIM(RTRIM(p.ItemCode)) WHERE pp.ProductCode = LTRIM(RTRIM(p.ItemCode))
) )
GROUP BY LTRIM(RTRIM(p.ItemCode)) GROUP BY LTRIM(RTRIM(p.ItemCode))
), ),
@@ -103,8 +123,8 @@ func GetProductPricingList(ctx context.Context) ([]models.ProductPricing, error)
AND LEN(LTRIM(RTRIM(r.ItemCode))) = 13 AND LEN(LTRIM(RTRIM(r.ItemCode))) = 13
AND EXISTS ( AND EXISTS (
SELECT 1 SELECT 1
FROM base_products bp FROM paged_products pp
WHERE bp.ProductCode = LTRIM(RTRIM(r.ItemCode)) WHERE pp.ProductCode = LTRIM(RTRIM(r.ItemCode))
) )
GROUP BY LTRIM(RTRIM(r.ItemCode)) GROUP BY LTRIM(RTRIM(r.ItemCode))
), ),
@@ -117,29 +137,29 @@ func GetProductPricingList(ctx context.Context) ([]models.ProductPricing, error)
AND LEN(LTRIM(RTRIM(d.ItemCode))) = 13 AND LEN(LTRIM(RTRIM(d.ItemCode))) = 13
AND EXISTS ( AND EXISTS (
SELECT 1 SELECT 1
FROM base_products bp FROM paged_products pp
WHERE bp.ProductCode = LTRIM(RTRIM(d.ItemCode)) WHERE pp.ProductCode = LTRIM(RTRIM(d.ItemCode))
) )
GROUP BY LTRIM(RTRIM(d.ItemCode)) GROUP BY LTRIM(RTRIM(d.ItemCode))
), ),
stock_totals AS ( stock_totals AS (
SELECT SELECT
bp.ProductCode AS ItemCode, pp.ProductCode AS ItemCode,
CAST(ROUND( CAST(ROUND(
ISNULL(sb.InventoryQty1, 0) ISNULL(sb.InventoryQty1, 0)
- ISNULL(pb.PickingQty1, 0) - ISNULL(pb.PickingQty1, 0)
- ISNULL(rb.ReserveQty1, 0) - ISNULL(rb.ReserveQty1, 0)
- ISNULL(db.DispOrderQty1, 0) - ISNULL(db.DispOrderQty1, 0)
, 2) AS DECIMAL(18, 2)) AS StockQty , 2) AS DECIMAL(18, 2)) AS StockQty
FROM base_products bp FROM paged_products pp
LEFT JOIN stock_base sb LEFT JOIN stock_base sb
ON sb.ItemCode = bp.ProductCode ON sb.ItemCode = pp.ProductCode
LEFT JOIN pick_base pb LEFT JOIN pick_base pb
ON pb.ItemCode = bp.ProductCode ON pb.ItemCode = pp.ProductCode
LEFT JOIN reserve_base rb LEFT JOIN reserve_base rb
ON rb.ItemCode = bp.ProductCode ON rb.ItemCode = pp.ProductCode
LEFT JOIN disp_base db LEFT JOIN disp_base db
ON db.ItemCode = bp.ProductCode ON db.ItemCode = pp.ProductCode
) )
SELECT SELECT
bp.ProductCode AS ProductCode, bp.ProductCode AS ProductCode,
@@ -155,7 +175,9 @@ func GetProductPricingList(ctx context.Context) ([]models.ProductPricing, error)
bp.Icerik, bp.Icerik,
bp.Karisim, bp.Karisim,
bp.Marka bp.Marka
FROM base_products bp FROM paged_products pp
INNER JOIN base_products bp
ON bp.ProductCode = pp.ProductCode
LEFT JOIN latest_base_price lp LEFT JOIN latest_base_price lp
ON lp.ItemCode = bp.ProductCode ON lp.ItemCode = bp.ProductCode
AND lp.rn = 1 AND lp.rn = 1
@@ -172,7 +194,7 @@ func GetProductPricingList(ctx context.Context) ([]models.ProductPricing, error)
) )
for attempt := 1; attempt <= 3; attempt++ { for attempt := 1; attempt <= 3; attempt++ {
var err error var err error
rows, err = db.MssqlDB.QueryContext(ctx, query) rows, err = db.MssqlDB.QueryContext(ctx, query, args...)
if err == nil { if err == nil {
rowsErr = nil rowsErr = nil
break break

View File

@@ -29,7 +29,15 @@ func GetProductPricingListHandler(w http.ResponseWriter, r *http.Request) {
ctx, cancel := context.WithTimeout(r.Context(), 180*time.Second) ctx, cancel := context.WithTimeout(r.Context(), 180*time.Second)
defer cancel() defer cancel()
rows, err := queries.GetProductPricingList(ctx) limit := 500
if raw := strings.TrimSpace(r.URL.Query().Get("limit")); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed > 0 && parsed <= 10000 {
limit = parsed
}
}
afterProductCode := strings.TrimSpace(r.URL.Query().Get("after_product_code"))
rows, err := queries.GetProductPricingList(ctx, limit+1, afterProductCode)
if err != nil { if err != nil {
if isPricingTimeoutLike(err, ctx.Err()) { if isPricingTimeoutLike(err, ctx.Err()) {
log.Printf( log.Printf(
@@ -54,16 +62,37 @@ func GetProductPricingListHandler(w http.ResponseWriter, r *http.Request) {
http.Error(w, "Urun fiyatlandirma listesi alinamadi: "+err.Error(), http.StatusInternalServerError) http.Error(w, "Urun fiyatlandirma listesi alinamadi: "+err.Error(), http.StatusInternalServerError)
return 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( log.Printf(
"[ProductPricing] trace=%s success user=%s id=%d count=%d duration_ms=%d", "[ProductPricing] trace=%s success user=%s id=%d limit=%d after=%q count=%d has_more=%t next=%q duration_ms=%d",
traceID, traceID,
claims.Username, claims.Username,
claims.ID, claims.ID,
limit,
afterProductCode,
len(rows), len(rows),
hasMore,
nextCursor,
time.Since(started).Milliseconds(), time.Since(started).Milliseconds(),
) )
w.Header().Set("Content-Type", "application/json; charset=utf-8") 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) _ = json.NewEncoder(w).Encode(rows)
} }

View File

@@ -0,0 +1,41 @@
package routes
import (
"database/sql"
"log"
"strings"
)
// EnsureTranslationPerfIndexes creates helpful indexes for translation listing/search.
// It is safe to run on each startup; failures are logged and do not stop the service.
func EnsureTranslationPerfIndexes(db *sql.DB) {
if db == nil {
return
}
statements := []string{
`CREATE EXTENSION IF NOT EXISTS pg_trgm`,
`CREATE INDEX IF NOT EXISTS idx_mk_translator_t_key_lang ON mk_translator (t_key, lang_code)`,
`CREATE INDEX IF NOT EXISTS idx_mk_translator_status_lang_updated ON mk_translator (status, lang_code, updated_at DESC)`,
`CREATE INDEX IF NOT EXISTS idx_mk_translator_manual_status ON mk_translator (is_manual, status)`,
`CREATE INDEX IF NOT EXISTS idx_mk_translator_source_type_expr ON mk_translator ((COALESCE(NULLIF(provider_meta->>'source_type',''),'dummy')))`,
`CREATE INDEX IF NOT EXISTS idx_mk_translator_source_text_trgm ON mk_translator USING gin (source_text_tr gin_trgm_ops)`,
`CREATE INDEX IF NOT EXISTS idx_mk_translator_translated_text_trgm ON mk_translator USING gin (translated_text gin_trgm_ops)`,
}
for _, stmt := range statements {
if _, err := db.Exec(stmt); err != nil {
log.Printf("[TranslationPerf] index_setup_warn sql=%q err=%v", summarizeSQL(stmt), err)
continue
}
log.Printf("[TranslationPerf] index_ready sql=%q", summarizeSQL(stmt))
}
}
func summarizeSQL(sqlText string) string {
s := strings.TrimSpace(sqlText)
if len(s) <= 100 {
return s
}
return s[:100] + "..."
}

View File

@@ -143,6 +143,12 @@ func GetTranslationRowsHandler(db *sql.DB) http.HandlerFunc {
limit = parsed limit = parsed
} }
} }
offset := 0
if raw := strings.TrimSpace(r.URL.Query().Get("offset")); raw != "" {
if parsed, err := strconv.Atoi(raw); err == nil && parsed >= 0 && parsed <= 1000000 {
offset = parsed
}
}
clauses := []string{"1=1"} clauses := []string{"1=1"}
args := make([]any, 0, 8) args := make([]any, 0, 8)
@@ -202,6 +208,11 @@ ORDER BY t_key, lang_code
if limit > 0 { if limit > 0 {
query += fmt.Sprintf("LIMIT $%d", argIndex) query += fmt.Sprintf("LIMIT $%d", argIndex)
args = append(args, limit) args = append(args, limit)
argIndex++
}
if offset > 0 {
query += fmt.Sprintf(" OFFSET $%d", argIndex)
args = append(args, offset)
} }
rows, err := db.Query(query, args...) rows, err := db.Query(query, args...)

View File

@@ -0,0 +1,75 @@
/* 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

@@ -0,0 +1,158 @@
/* 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

@@ -0,0 +1,116 @@
/* 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

@@ -0,0 +1,23 @@
/* 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

@@ -53,9 +53,10 @@
:virtual-scroll-sticky-size-start="headerHeight" :virtual-scroll-sticky-size-start="headerHeight"
:virtual-scroll-slice-size="36" :virtual-scroll-slice-size="36"
:rows-per-page-options="[0]" :rows-per-page-options="[0]"
:pagination="{ rowsPerPage: 0 }" v-model:pagination="tablePagination"
hide-bottom hide-bottom
:table-style="tableStyle" :table-style="tableStyle"
@virtual-scroll="onTableVirtualScroll"
> >
<template #header="props"> <template #header="props">
<q-tr :props="props" class="header-row-fixed"> <q-tr :props="props" class="header-row-fixed">
@@ -309,10 +310,13 @@
</template> </template>
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue' import { computed, onMounted, ref, watch } from 'vue'
import { useProductPricingStore } from 'src/stores/ProductPricingStore' import { useProductPricingStore } from 'src/stores/ProductPricingStore'
const store = useProductPricingStore() const store = useProductPricingStore()
const FETCH_LIMIT = 500
const nextCursor = ref('')
const loadingMore = ref(false)
const usdToTry = 38.25 const usdToTry = 38.25
const eurToTry = 41.6 const eurToTry = 41.6
@@ -381,6 +385,12 @@ const headerFilterFieldSet = new Set([
]) ])
const mainTableRef = ref(null) const mainTableRef = ref(null)
const tablePagination = ref({
page: 1,
rowsPerPage: 0,
sortBy: 'productCode',
descending: false
})
const selectedMap = ref({}) const selectedMap = ref({})
const selectedCurrencies = ref(['USD', 'EUR', 'TRY']) const selectedCurrencies = ref(['USD', 'EUR', 'TRY'])
const showSelectedOnly = ref(false) const showSelectedOnly = ref(false)
@@ -570,6 +580,7 @@ const selectedRowCount = computed(() => Object.values(selectedMap.value).filter(
const selectedVisibleCount = computed(() => visibleRowIds.value.filter((id) => !!selectedMap.value[id]).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 allSelectedVisible = computed(() => visibleRowIds.value.length > 0 && selectedVisibleCount.value === visibleRowIds.value.length)
const someSelectedVisible = computed(() => selectedVisibleCount.value > 0) const someSelectedVisible = computed(() => selectedVisibleCount.value > 0)
const hasMoreRows = computed(() => Boolean(store.hasMore))
function isHeaderFilterField (field) { function isHeaderFilterField (field) {
return headerFilterFieldSet.has(field) return headerFilterFieldSet.has(field)
@@ -840,12 +851,52 @@ function clearAllCurrencies () {
selectedCurrencies.value = [] selectedCurrencies.value = []
} }
async function fetchChunk ({ reset = false } = {}) {
const afterProductCode = reset ? '' : nextCursor.value
const result = await store.fetchRows({
limit: FETCH_LIMIT,
afterProductCode,
append: !reset
})
const fetched = Number(result?.fetched) || 0
nextCursor.value = String(result?.nextCursor || '')
return fetched
}
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 () {
const startedAt = Date.now() const startedAt = Date.now()
console.info('[product-pricing][ui] reload:start', { console.info('[product-pricing][ui] reload:start', {
at: new Date(startedAt).toISOString() at: new Date(startedAt).toISOString()
}) })
await store.fetchRows() nextCursor.value = ''
await fetchChunk({ reset: true })
await ensureEnoughVisibleRows(120, 6)
console.info('[product-pricing][ui] reload:done', { console.info('[product-pricing][ui] reload:done', {
duration_ms: Date.now() - startedAt, duration_ms: Date.now() - startedAt,
row_count: Array.isArray(store.rows) ? store.rows.length : 0, row_count: Array.isArray(store.rows) ? store.rows.length : 0,
@@ -857,6 +908,19 @@ async function reloadData () {
onMounted(async () => { onMounted(async () => {
await reloadData() await reloadData()
}) })
watch(
[
columnFilters,
numberRangeFilters,
dateRangeFilters,
showSelectedOnly,
() => tablePagination.value.sortBy,
() => tablePagination.value.descending
],
() => { void ensureEnoughVisibleRows(80, 4) },
{ deep: true }
)
</script> </script>
<style scoped> <style scoped>

View File

@@ -1,5 +1,6 @@
<template> <template>
<q-page v-if="canUpdateLanguage" class="q-pa-md"> <q-page v-if="canUpdateLanguage" class="q-pa-md translation-page">
<div class="translation-toolbar sticky-toolbar">
<div class="row q-col-gutter-sm items-end q-mb-md"> <div class="row q-col-gutter-sm items-end q-mb-md">
<div class="col-12 col-md-4"> <div class="col-12 col-md-4">
<q-input <q-input
@@ -53,17 +54,23 @@
@click="bulkSaveSelected" @click="bulkSaveSelected"
/> />
</div> </div>
</div>
<q-table <q-table
ref="tableRef"
class="translation-table"
flat flat
bordered bordered
dense virtual-scroll
:virtual-scroll-sticky-size-start="56"
row-key="t_key" row-key="t_key"
:loading="store.loading || store.saving" :loading="store.loading || store.saving"
:rows="pivotRows" :rows="pivotRows"
:columns="columns" :columns="columns"
:rows-per-page-options="[0]" :rows-per-page-options="[0]"
:pagination="{ rowsPerPage: 0 }" v-model:pagination="tablePagination"
hide-bottom
@virtual-scroll="onVirtualScroll"
> >
<template #body-cell-actions="props"> <template #body-cell-actions="props">
<q-td :props="props"> <q-td :props="props">
@@ -91,7 +98,9 @@
<template #body-cell-source_text_tr="props"> <template #body-cell-source_text_tr="props">
<q-td :props="props" :class="cellClass(props.row.t_key, 'source_text_tr')"> <q-td :props="props" :class="cellClass(props.row.t_key, 'source_text_tr')">
<q-input v-model="rowDraft(props.row.t_key).source_text_tr" dense outlined @blur="queueAutoSave(props.row.t_key)" /> <div class="source-text-label" :title="rowDraft(props.row.t_key).source_text_tr">
{{ rowDraft(props.row.t_key).source_text_tr }}
</div>
</q-td> </q-td>
</template> </template>
@@ -111,37 +120,79 @@
<template #body-cell-en="props"> <template #body-cell-en="props">
<q-td :props="props" :class="cellClass(props.row.t_key, 'en')"> <q-td :props="props" :class="cellClass(props.row.t_key, 'en')">
<q-input v-model="rowDraft(props.row.t_key).en" dense outlined @blur="queueAutoSave(props.row.t_key)" /> <q-input
v-model="rowDraft(props.row.t_key).en"
type="textarea"
autogrow
:max-rows="8"
outlined
@blur="queueAutoSave(props.row.t_key)"
/>
</q-td> </q-td>
</template> </template>
<template #body-cell-de="props"> <template #body-cell-de="props">
<q-td :props="props" :class="cellClass(props.row.t_key, 'de')"> <q-td :props="props" :class="cellClass(props.row.t_key, 'de')">
<q-input v-model="rowDraft(props.row.t_key).de" dense outlined @blur="queueAutoSave(props.row.t_key)" /> <q-input
v-model="rowDraft(props.row.t_key).de"
type="textarea"
autogrow
:max-rows="8"
outlined
@blur="queueAutoSave(props.row.t_key)"
/>
</q-td> </q-td>
</template> </template>
<template #body-cell-es="props"> <template #body-cell-es="props">
<q-td :props="props" :class="cellClass(props.row.t_key, 'es')"> <q-td :props="props" :class="cellClass(props.row.t_key, 'es')">
<q-input v-model="rowDraft(props.row.t_key).es" dense outlined @blur="queueAutoSave(props.row.t_key)" /> <q-input
v-model="rowDraft(props.row.t_key).es"
type="textarea"
autogrow
:max-rows="8"
outlined
@blur="queueAutoSave(props.row.t_key)"
/>
</q-td> </q-td>
</template> </template>
<template #body-cell-it="props"> <template #body-cell-it="props">
<q-td :props="props" :class="cellClass(props.row.t_key, 'it')"> <q-td :props="props" :class="cellClass(props.row.t_key, 'it')">
<q-input v-model="rowDraft(props.row.t_key).it" dense outlined @blur="queueAutoSave(props.row.t_key)" /> <q-input
v-model="rowDraft(props.row.t_key).it"
type="textarea"
autogrow
:max-rows="8"
outlined
@blur="queueAutoSave(props.row.t_key)"
/>
</q-td> </q-td>
</template> </template>
<template #body-cell-ru="props"> <template #body-cell-ru="props">
<q-td :props="props" :class="cellClass(props.row.t_key, 'ru')"> <q-td :props="props" :class="cellClass(props.row.t_key, 'ru')">
<q-input v-model="rowDraft(props.row.t_key).ru" dense outlined @blur="queueAutoSave(props.row.t_key)" /> <q-input
v-model="rowDraft(props.row.t_key).ru"
type="textarea"
autogrow
:max-rows="8"
outlined
@blur="queueAutoSave(props.row.t_key)"
/>
</q-td> </q-td>
</template> </template>
<template #body-cell-ar="props"> <template #body-cell-ar="props">
<q-td :props="props" :class="cellClass(props.row.t_key, 'ar')"> <q-td :props="props" :class="cellClass(props.row.t_key, 'ar')">
<q-input v-model="rowDraft(props.row.t_key).ar" dense outlined @blur="queueAutoSave(props.row.t_key)" /> <q-input
v-model="rowDraft(props.row.t_key).ar"
type="textarea"
autogrow
:max-rows="8"
outlined
@blur="queueAutoSave(props.row.t_key)"
/>
</q-td> </q-td>
</template> </template>
</q-table> </q-table>
@@ -155,7 +206,7 @@
</template> </template>
<script setup> <script setup>
import { computed, onMounted, ref } from 'vue' import { computed, onBeforeUnmount, onMounted, ref, watch } from 'vue'
import { useQuasar } from 'quasar' import { useQuasar } from 'quasar'
import { usePermission } from 'src/composables/usePermission' import { usePermission } from 'src/composables/usePermission'
import { useTranslationStore } from 'src/stores/translationStore' import { useTranslationStore } from 'src/stores/translationStore'
@@ -169,6 +220,18 @@ const filters = ref({
q: '' q: ''
}) })
const autoTranslate = ref(false) const autoTranslate = ref(false)
const tableRef = ref(null)
const FETCH_LIMIT = 1400
const loadedOffset = ref(0)
const hasMoreRows = ref(true)
const loadingMore = ref(false)
const tablePagination = ref({
page: 1,
rowsPerPage: 0,
sortBy: 'source_text_tr',
descending: false
})
let filterReloadTimer = null
const sourceTypeOptions = [ const sourceTypeOptions = [
{ label: 'dummy', value: 'dummy' }, { label: 'dummy', value: 'dummy' },
@@ -179,15 +242,14 @@ const sourceTypeOptions = [
const columns = [ const columns = [
{ name: 'actions', label: 'Güncelle', field: 'actions', align: 'left' }, { name: 'actions', label: 'Güncelle', field: 'actions', align: 'left' },
{ name: 'select', label: 'Seç', field: 'select', align: 'left' }, { name: 'select', label: 'Seç', field: 'select', align: 'left' },
{ name: 't_key', label: 'Key', field: 't_key', align: 'left', sortable: true }, { name: 'source_text_tr', label: 'Türkçe Metin', field: 'source_text_tr', align: 'left', style: 'min-width: 340px' },
{ name: 'source_text_tr', label: 'Türkçe kaynak', field: 'source_text_tr', align: 'left' }, { name: 'source_type', label: 'Kaynak', field: 'source_type', align: 'left', style: 'min-width: 140px' },
{ name: 'source_type', label: 'Veri tipi', field: 'source_type', align: 'left' }, { name: 'en', label: 'İngilizce', field: 'en', align: 'left', style: 'min-width: 220px' },
{ name: 'en', label: 'English', field: 'en', align: 'left' }, { name: 'de', label: 'Almanca', field: 'de', align: 'left', style: 'min-width: 220px' },
{ name: 'de', label: 'Deutch', field: 'de', align: 'left' }, { name: 'es', label: 'İspanyolca', field: 'es', align: 'left', style: 'min-width: 220px' },
{ name: 'es', label: 'Espanol', field: 'es', align: 'left' }, { name: 'it', label: 'İtalyanca', field: 'it', align: 'left', style: 'min-width: 220px' },
{ name: 'it', label: 'Italiano', field: 'it', align: 'left' }, { name: 'ru', label: 'Rusça', field: 'ru', align: 'left', style: 'min-width: 220px' },
{ name: 'ru', label: 'Русский', field: 'ru', align: 'left' }, { name: 'ar', label: 'Arapça', field: 'ar', align: 'left', style: 'min-width: 220px' }
{ name: 'ar', label: 'العربية', field: 'ar', align: 'left' }
] ]
const draftByKey = ref({}) const draftByKey = ref({})
@@ -242,10 +304,33 @@ const pivotRows = computed(() => {
return Array.from(byKey.values()).sort((a, b) => a.t_key.localeCompare(b.t_key)) return Array.from(byKey.values()).sort((a, b) => a.t_key.localeCompare(b.t_key))
}) })
function snapshotDrafts () { function snapshotDrafts (options = {}) {
const preserveDirty = Boolean(options?.preserveDirty)
const draft = {} const draft = {}
const original = {} const original = {}
for (const row of pivotRows.value) { for (const row of pivotRows.value) {
const existingDraft = draftByKey.value[row.t_key]
const existingOriginal = originalByKey.value[row.t_key]
const keepExisting = preserveDirty &&
existingDraft &&
existingOriginal &&
(
existingDraft.source_text_tr !== existingOriginal.source_text_tr ||
existingDraft.source_type !== existingOriginal.source_type ||
existingDraft.en !== existingOriginal.en ||
existingDraft.de !== existingOriginal.de ||
existingDraft.es !== existingOriginal.es ||
existingDraft.it !== existingOriginal.it ||
existingDraft.ru !== existingOriginal.ru ||
existingDraft.ar !== existingOriginal.ar
)
if (keepExisting) {
draft[row.t_key] = { ...existingDraft }
original[row.t_key] = { ...existingOriginal }
continue
}
draft[row.t_key] = { draft[row.t_key] = {
source_text_tr: row.source_text_tr || '', source_text_tr: row.source_text_tr || '',
source_type: row.source_type || 'dummy', source_type: row.source_type || 'dummy',
@@ -280,8 +365,9 @@ function rowDraft (key) {
} }
function buildFilters () { function buildFilters () {
const query = String(filters.value.q || '').trim()
return { return {
q: filters.value.q || undefined q: query || undefined
} }
} }
@@ -349,10 +435,30 @@ function queueAutoSave (key) {
autoSaveTimers.set(key, timer) autoSaveTimers.set(key, timer)
} }
async function fetchRowsChunk (append = false) {
const params = {
...buildFilters(),
limit: FETCH_LIMIT,
offset: append ? loadedOffset.value : 0
}
await store.fetchRows(params, { append })
const incomingCount = Number(store.count) || 0
if (append) {
loadedOffset.value += incomingCount
} else {
loadedOffset.value = incomingCount
}
hasMoreRows.value = incomingCount === FETCH_LIMIT
snapshotDrafts({ preserveDirty: append })
}
async function loadRows () { async function loadRows () {
try { try {
await store.fetchRows(buildFilters()) loadedOffset.value = 0
snapshotDrafts() hasMoreRows.value = true
await fetchRowsChunk(false)
} catch (err) { } catch (err) {
console.error('[translation-sync][ui] loadRows:error', { console.error('[translation-sync][ui] loadRows:error', {
message: err?.message || 'Çeviri satırları yüklenemedi' message: err?.message || 'Çeviri satırları yüklenemedi'
@@ -364,6 +470,42 @@ async function loadRows () {
} }
} }
async function loadMoreRows () {
if (!hasMoreRows.value || loadingMore.value || store.loading || store.saving) return
loadingMore.value = true
try {
await fetchRowsChunk(true)
} finally {
loadingMore.value = false
}
}
async function ensureEnoughVisibleRows (minRows = 120, maxBatches = 4) {
let guard = 0
while (hasMoreRows.value && pivotRows.value.length < minRows && guard < maxBatches) {
await loadMoreRows()
guard++
}
}
function onVirtualScroll (details) {
const to = Number(details?.to || 0)
if (!Number.isFinite(to)) return
if (to >= pivotRows.value.length - 15) {
void loadMoreRows()
}
}
function scheduleFilterReload () {
if (filterReloadTimer) {
clearTimeout(filterReloadTimer)
}
filterReloadTimer = setTimeout(() => {
filterReloadTimer = null
void loadRows()
}, 350)
}
async function ensureMissingLangRows (key, draft, langs) { async function ensureMissingLangRows (key, draft, langs) {
const missingLangs = [] const missingLangs = []
if (!langs.en && String(draft.en || '').trim() !== '') missingLangs.push('en') if (!langs.en && String(draft.en || '').trim() !== '') missingLangs.push('en')
@@ -618,11 +760,81 @@ async function syncSources () {
} }
onMounted(() => { onMounted(() => {
loadRows() void loadRows()
}) })
onBeforeUnmount(() => {
if (filterReloadTimer) {
clearTimeout(filterReloadTimer)
filterReloadTimer = null
}
})
watch(
() => filters.value.q,
() => { scheduleFilterReload() }
)
watch(
[() => tablePagination.value.sortBy, () => tablePagination.value.descending],
() => { void ensureEnoughVisibleRows(120, 4) }
)
</script> </script>
<style scoped> <style scoped>
.translation-page {
height: calc(100vh - 120px);
display: flex;
flex-direction: column;
overflow: hidden;
}
.translation-toolbar {
background: #fff;
padding-top: 6px;
}
.sticky-toolbar {
position: sticky;
top: 0;
z-index: 35;
}
.translation-table {
flex: 1;
min-height: 0;
}
.translation-table :deep(.q-table__middle) {
max-height: calc(100vh - 280px);
overflow: auto;
}
.translation-table :deep(.q-table thead tr th) {
position: sticky;
top: 0;
z-index: 30;
background: #fff;
}
.translation-table :deep(.q-table tbody td) {
vertical-align: top;
padding: 6px;
}
.translation-table :deep(.q-field__native) {
line-height: 1.35;
word-break: break-word;
}
.source-text-label {
white-space: pre-wrap;
word-break: break-word;
line-height: 1.4;
max-height: 11.2em;
overflow: auto;
}
.cell-dirty { .cell-dirty {
background: #fff3cd; background: #fff3cd;
} }
@@ -631,3 +843,4 @@ onMounted(() => {
background: #d9f7e8; background: #d9f7e8;
} }
</style> </style>

View File

@@ -10,9 +10,9 @@ function toNumber (value) {
return Number.isFinite(n) ? Number(n.toFixed(2)) : 0 return Number.isFinite(n) ? Number(n.toFixed(2)) : 0
} }
function mapRow (raw, index) { function mapRow (raw, index, baseIndex = 0) {
return { return {
id: index + 1, id: baseIndex + index + 1,
productCode: toText(raw?.ProductCode), productCode: toText(raw?.ProductCode),
stockQty: toNumber(raw?.StockQty), stockQty: toNumber(raw?.StockQty),
stockEntryDate: toText(raw?.StockEntryDate), stockEntryDate: toText(raw?.StockEntryDate),
@@ -55,34 +55,71 @@ export const useProductPricingStore = defineStore('product-pricing-store', {
state: () => ({ state: () => ({
rows: [], rows: [],
loading: false, loading: false,
error: '' error: '',
hasMore: true
}), }),
actions: { actions: {
async fetchRows () { async fetchRows (options = {}) {
this.loading = true this.loading = true
this.error = '' this.error = ''
const limit = Number(options?.limit) > 0 ? Number(options.limit) : 500
const afterProductCode = toText(options?.afterProductCode)
const append = Boolean(options?.append)
const baseIndex = append ? this.rows.length : 0
const startedAt = Date.now() const startedAt = Date.now()
console.info('[product-pricing][frontend] request:start', { console.info('[product-pricing][frontend] request:start', {
at: new Date(startedAt).toISOString(), at: new Date(startedAt).toISOString(),
timeout_ms: 600000 timeout_ms: 180000,
limit,
after_product_code: afterProductCode || null,
append
}) })
try { try {
const params = { limit }
if (afterProductCode) params.after_product_code = afterProductCode
const res = await api.request({ const res = await api.request({
method: 'GET', method: 'GET',
url: '/pricing/products', url: '/pricing/products',
timeout: 600000 params,
timeout: 180000
}) })
const traceId = res?.headers?.['x-trace-id'] || null 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 data = Array.isArray(res?.data) ? res.data : [] const data = Array.isArray(res?.data) ? res.data : []
this.rows = data.map((x, i) => mapRow(x, i)) const mapped = data.map((x, i) => mapRow(x, i, baseIndex))
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
}
this.hasMore = hasMoreHeader ? hasMoreHeader === 'true' : mapped.length === limit
console.info('[product-pricing][frontend] request:success', { console.info('[product-pricing][frontend] request:success', {
trace_id: traceId, trace_id: traceId,
duration_ms: Date.now() - startedAt, duration_ms: Date.now() - startedAt,
row_count: this.rows.length row_count: this.rows.length,
fetched_count: mapped.length,
has_more: this.hasMore,
next_cursor: nextCursorHeader || null
}) })
return {
traceId,
fetched: mapped.length,
hasMore: this.hasMore,
nextCursor: nextCursorHeader
}
} catch (err) { } catch (err) {
this.rows = [] if (!append) this.rows = []
this.hasMore = false
const msg = err?.response?.data || err?.message || 'Urun fiyatlandirma listesi alinamadi' const msg = err?.response?.data || err?.message || 'Urun fiyatlandirma listesi alinamadi'
this.error = toText(msg) this.error = toText(msg)
console.error('[product-pricing][frontend] request:error', { console.error('[product-pricing][frontend] request:error', {
@@ -92,6 +129,7 @@ export const useProductPricingStore = defineStore('product-pricing-store', {
status: err?.response?.status || null, status: err?.response?.status || null,
message: this.error message: this.error
}) })
throw err
} finally { } finally {
this.loading = false this.loading = false
} }

View File

@@ -10,12 +10,27 @@ export const useTranslationStore = defineStore('translation', {
}), }),
actions: { actions: {
async fetchRows (filters = {}) { async fetchRows (filters = {}, options = {}) {
this.loading = true this.loading = true
const append = Boolean(options?.append)
try { try {
const res = await api.get('/language/translations', { params: filters }) const res = await api.get('/language/translations', { params: filters })
const payload = res?.data || {} const payload = res?.data || {}
this.rows = Array.isArray(payload.rows) ? payload.rows : [] const incoming = Array.isArray(payload.rows) ? payload.rows : []
if (append) {
const merged = [...this.rows]
const seen = new Set(this.rows.map((x) => x?.id))
for (const row of incoming) {
const id = row?.id
if (!seen.has(id)) {
merged.push(row)
seen.add(id)
}
}
this.rows = merged
} else {
this.rows = incoming
}
this.count = Number(payload.count) || this.rows.length this.count = Number(payload.count) || this.rows.length
} finally { } finally {
this.loading = false this.loading = false