diff --git a/svc/queries/product_pricing.go b/svc/queries/product_pricing.go index 9565421..f2cac04 100644 --- a/svc/queries/product_pricing.go +++ b/svc/queries/product_pricing.go @@ -5,17 +5,22 @@ import ( "bssapp-backend/models" "context" "database/sql" - "fmt" + "strconv" "strings" "time" ) -func GetProductPricingList(ctx context.Context, limit int, offset int) ([]models.ProductPricing, error) { +func GetProductPricingList(ctx context.Context, limit int, afterProductCode string) ([]models.ProductPricing, error) { if limit <= 0 { limit = 500 } - if offset < 0 { - offset = 0 + afterProductCode = strings.TrimSpace(afterProductCode) + + cursorFilter := "" + args := make([]any, 0, 1) + if afterProductCode != "" { + cursorFilter = "WHERE bp.ProductCode > @p1" + args = append(args, afterProductCode) } query := ` @@ -36,11 +41,11 @@ func GetProductPricingList(ctx context.Context, limit int, offset int) ([]models AND LEN(LTRIM(RTRIM(ProductCode))) = 13 ), paged_products AS ( - SELECT + SELECT TOP (` + strconv.Itoa(limit) + `) bp.ProductCode FROM base_products bp + ` + cursorFilter + ` ORDER BY bp.ProductCode - OFFSET %d ROWS FETCH NEXT %d ROWS ONLY ), latest_base_price AS ( SELECT @@ -182,7 +187,6 @@ func GetProductPricingList(ctx context.Context, limit int, offset int) ([]models ON st.ItemCode = bp.ProductCode ORDER BY bp.ProductCode; ` - query = fmt.Sprintf(query, offset, limit) var ( rows *sql.Rows @@ -190,7 +194,7 @@ func GetProductPricingList(ctx context.Context, limit int, offset int) ([]models ) for attempt := 1; attempt <= 3; attempt++ { var err error - rows, err = db.MssqlDB.QueryContext(ctx, query) + rows, err = db.MssqlDB.QueryContext(ctx, query, args...) if err == nil { rowsErr = nil break diff --git a/svc/routes/product_pricing.go b/svc/routes/product_pricing.go index b7bc564..46d20a4 100644 --- a/svc/routes/product_pricing.go +++ b/svc/routes/product_pricing.go @@ -35,14 +35,9 @@ func GetProductPricingListHandler(w http.ResponseWriter, r *http.Request) { 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 - } - } + afterProductCode := strings.TrimSpace(r.URL.Query().Get("after_product_code")) - rows, err := queries.GetProductPricingList(ctx, limit+1, offset) + rows, err := queries.GetProductPricingList(ctx, limit+1, afterProductCode) if err != nil { if isPricingTimeoutLike(err, ctx.Err()) { log.Printf( @@ -71,16 +66,21 @@ func GetProductPricingListHandler(w http.ResponseWriter, r *http.Request) { 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 offset=%d count=%d has_more=%t 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, claims.Username, claims.ID, limit, - offset, + afterProductCode, len(rows), hasMore, + nextCursor, time.Since(started).Milliseconds(), ) @@ -90,6 +90,9 @@ func GetProductPricingListHandler(w http.ResponseWriter, r *http.Request) { } else { w.Header().Set("X-Has-More", "false") } + if nextCursor != "" { + w.Header().Set("X-Next-Cursor", nextCursor) + } _ = json.NewEncoder(w).Encode(rows) } diff --git a/ui/src/pages/ProductPricing.vue b/ui/src/pages/ProductPricing.vue index 708950f..4b4cefb 100644 --- a/ui/src/pages/ProductPricing.vue +++ b/ui/src/pages/ProductPricing.vue @@ -315,7 +315,7 @@ import { useProductPricingStore } from 'src/stores/ProductPricingStore' const store = useProductPricingStore() const FETCH_LIMIT = 500 -const currentOffset = ref(0) +const nextCursor = ref('') const loadingMore = ref(false) const usdToTry = 38.25 @@ -852,14 +852,14 @@ function clearAllCurrencies () { } async function fetchChunk ({ reset = false } = {}) { - const offset = reset ? 0 : currentOffset.value + const afterProductCode = reset ? '' : nextCursor.value const result = await store.fetchRows({ limit: FETCH_LIMIT, - offset, + afterProductCode, append: !reset }) const fetched = Number(result?.fetched) || 0 - currentOffset.value = offset + fetched + nextCursor.value = String(result?.nextCursor || '') return fetched } @@ -894,7 +894,7 @@ async function reloadData () { console.info('[product-pricing][ui] reload:start', { at: new Date(startedAt).toISOString() }) - currentOffset.value = 0 + nextCursor.value = '' await fetchChunk({ reset: true }) await ensureEnoughVisibleRows(120, 6) console.info('[product-pricing][ui] reload:done', { diff --git a/ui/src/stores/ProductPricingStore.js b/ui/src/stores/ProductPricingStore.js index 9f1c4cd..bc03f58 100644 --- a/ui/src/stores/ProductPricingStore.js +++ b/ui/src/stores/ProductPricingStore.js @@ -10,9 +10,9 @@ function toNumber (value) { return Number.isFinite(n) ? Number(n.toFixed(2)) : 0 } -function mapRow (raw, index, offset = 0) { +function mapRow (raw, index, baseIndex = 0) { return { - id: offset + index + 1, + id: baseIndex + index + 1, productCode: toText(raw?.ProductCode), stockQty: toNumber(raw?.StockQty), stockEntryDate: toText(raw?.StockEntryDate), @@ -64,27 +64,31 @@ export const useProductPricingStore = defineStore('product-pricing-store', { this.loading = true this.error = '' const limit = Number(options?.limit) > 0 ? Number(options.limit) : 500 - const offset = Number(options?.offset) >= 0 ? Number(options.offset) : 0 + const afterProductCode = toText(options?.afterProductCode) const append = Boolean(options?.append) + const baseIndex = append ? this.rows.length : 0 const startedAt = Date.now() console.info('[product-pricing][frontend] request:start', { at: new Date(startedAt).toISOString(), timeout_ms: 180000, limit, - offset, + after_product_code: afterProductCode || null, append }) try { + const params = { limit } + if (afterProductCode) params.after_product_code = afterProductCode const res = await api.request({ method: 'GET', url: '/pricing/products', - params: { limit, offset }, + params, 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 data = Array.isArray(res?.data) ? res.data : [] - const mapped = data.map((x, i) => mapRow(x, i, offset)) + 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)) @@ -104,12 +108,14 @@ export const useProductPricingStore = defineStore('product-pricing-store', { duration_ms: Date.now() - startedAt, row_count: this.rows.length, fetched_count: mapped.length, - has_more: this.hasMore + has_more: this.hasMore, + next_cursor: nextCursorHeader || null }) return { traceId, fetched: mapped.length, - hasMore: this.hasMore + hasMore: this.hasMore, + nextCursor: nextCursorHeader } } catch (err) { if (!append) this.rows = []