From b80b5024f4cfa83edd3ec5f964108d31a8eaf023 Mon Sep 17 00:00:00 2001 From: M_Kececi Date: Mon, 13 Jul 2026 21:55:06 +0300 Subject: [PATCH] Fix product performance grouped JSON build --- svc/main.go | 21 ++++++++++++ svc/queries/product_performance.go | 8 +++-- ui/src/pages/OrderPriceList.vue | 4 +-- .../pages/ProductPerformanceProfitability.vue | 17 +++++++--- ui/src/pages/ProductStockByAttributes.vue | 34 +++++-------------- ui/src/pages/ProductStockQuery.vue | 34 +++++-------------- 6 files changed, 59 insertions(+), 59 deletions(-) diff --git a/svc/main.go b/svc/main.go index 7179747..246b119 100644 --- a/svc/main.go +++ b/svc/main.go @@ -227,6 +227,7 @@ InitRoutes — FULL V3 (Method-aware) PERMISSION EDITION func InitRoutes(pgDB *sql.DB, mssql *sql.DB, ml *mailer.GraphMailer) *mux.Router { r := mux.NewRouter() + mountUploads(r) mountSPA(r) /* @@ -1517,6 +1518,26 @@ func main() { } +func mountUploads(r *mux.Router) { + root := strings.TrimSpace(os.Getenv("BLOB_ROOT")) + if root == "" { + return + } + uploadsRoot := filepath.Join(root, "uploads") + if fi, err := os.Stat(uploadsRoot); err != nil || !fi.IsDir() { + return + } + fileServer := http.StripPrefix("/uploads/", http.FileServer(http.Dir(uploadsRoot))) + r.PathPrefix("/uploads/").Handler(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet && r.Method != http.MethodHead { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Cache-Control", "public, max-age=3600") + fileServer.ServeHTTP(w, r) + })).Methods(http.MethodGet, http.MethodHead, http.MethodOptions) +} + func mountSPA(r *mux.Router) { r.NotFoundHandler = http.HandlerFunc(spaIndex) r.HandleFunc("/", spaIndex).Methods(http.MethodGet) diff --git a/svc/queries/product_performance.go b/svc/queries/product_performance.go index 6a80086..cf87a5c 100644 --- a/svc/queries/product_performance.go +++ b/svc/queries/product_performance.go @@ -6129,6 +6129,10 @@ func productPerformanceMapVariantKey(row map[string]any) string { func deriveProductPerformanceGroupMetrics(out map[string]any, groupField string) { normalizeProductPerformanceCostFields(out) + isGroup := strings.TrimSpace(groupField) != "" + if isGroup { + out["group_field"] = groupField + } preservedCustomerScores := map[string]float64{} preservedProductScores := map[string]float64{} for _, suffix := range productPerformancePeriodSuffixes() { @@ -6149,7 +6153,7 @@ func deriveProductPerformanceGroupMetrics(out map[string]any, groupField string) qty := floatFromMap(out, "sales_qty_"+suffix) stockQty := floatFromMap(out, "stock_qty") turnoverBase := floatFromMap(out, "avg_stock_"+suffix) - if turnoverBase <= 0 { + if turnoverBase <= 0 && !isGroup { turnoverBase = stockQty } days := productPerformancePeriodDays(out, suffix) @@ -6381,7 +6385,7 @@ func productPerformanceSalesPeriodScore(row map[string]any, suffix string) float stockTurnover := floatFromMap(row, "stock_turnover_"+suffix) if stockTurnover == 0 { avgStock := floatFromMap(row, "avg_stock_"+suffix) - if avgStock <= 0 { + if avgStock <= 0 && strings.TrimSpace(stringFromMap(row, "group_field")) == "" { avgStock = floatFromMap(row, "stock_qty") } if avgStock > 0 { diff --git a/ui/src/pages/OrderPriceList.vue b/ui/src/pages/OrderPriceList.vue index 1f8b28c..2d5fbac 100644 --- a/ui/src/pages/OrderPriceList.vue +++ b/ui/src/pages/OrderPriceList.vue @@ -1289,12 +1289,12 @@ function normalizeUploadsPath (storagePath) { function resolveProductImageUrl (item) { if (!item || typeof item !== 'object') return '' + const contentUrl = toText(item.content_url || item.ContentURL) + if (contentUrl) return contentUrl.startsWith('/api/') ? contentUrl : contentUrl const thumbUrl = toText(item.thumb_url || item.thumbUrl) if (thumbUrl) return thumbUrl const fullUrl = toText(item.full_url || item.fullUrl) if (fullUrl) return fullUrl - const contentUrl = toText(item.content_url || item.ContentURL) - if (contentUrl) return contentUrl.startsWith('/api/') ? contentUrl : contentUrl const uploadsPath = normalizeUploadsPath(item.storage_path || item.storage) if (uploadsPath) return uploadsPath const fileName = toText(item.file_name || item.FileName) diff --git a/ui/src/pages/ProductPerformanceProfitability.vue b/ui/src/pages/ProductPerformanceProfitability.vue index 2c5e7f5..968bcec 100644 --- a/ui/src/pages/ProductPerformanceProfitability.vue +++ b/ui/src/pages/ProductPerformanceProfitability.vue @@ -3535,6 +3535,7 @@ function weightFieldForMetric (field) { function applyDerivedGroupMetrics (out, sourceRows, groupField = '') { Object.assign(out, normalizeProductCostFields(out)) + const isGroup = Boolean(String(groupField || '').trim()) const preservedCustomerScores = {} const preservedProductScores = {} for (const suffix of ['90d', '180d', '365d', 'total']) { @@ -3556,7 +3557,7 @@ function applyDerivedGroupMetrics (out, sourceRows, groupField = '') { const qty = Number(out[`sales_qty_${suffix}`] || 0) const stockQty = Number(out.stock_qty || 0) const avgStock = Number(out[`avg_stock_${suffix}`] || 0) - const turnoverBase = avgStock > 0 ? avgStock : stockQty + const turnoverBase = avgStock > 0 ? avgStock : (isGroup ? 0 : stockQty) const days = productPerformancePeriodDays(out, suffix) const avgDaily = days > 0 ? qty / days : Number(out[`avg_daily_sales_${suffix}`] || 0) if (days > 0) out[`avg_daily_sales_${suffix}`] = avgDaily @@ -3660,7 +3661,9 @@ function productPeriodMetricSource (row, suffix) { const salesIndex = Number(row?.[`sales_index_${suffix}`] || 0) const qty = Number(row?.[`sales_qty_${suffix}`] || 0) const stockQty = Number(row?.stock_qty || 0) - const avgStock = Number(row?.[`avg_stock_${suffix}`] || 0) || stockQty + const avgStock = Number(row?.[`avg_stock_${suffix}`] || 0) + const isGroup = Boolean(row?.__group || String(row?.group_field || '').trim()) + const turnoverBase = avgStock > 0 ? avgStock : (isGroup ? 0 : stockQty) const turnoverKey = `stock_turnover_${suffix}` const hasTurnover = Object.prototype.hasOwnProperty.call(row || {}, turnoverKey) return { @@ -3670,7 +3673,7 @@ function productPeriodMetricSource (row, suffix) { gross_margin_cost_90d: Number(row?.[`gross_margin_cost_${suffix}`] ?? 0), gross_margin_90d: Number(row?.[`gross_margin_${suffix}`] ?? 0), sales_qty_90d: qty, - stock_turnover_90d: hasTurnover ? Number(row?.[turnoverKey] || 0) : annualizedStockTurnover(qty, avgStock, productPerformancePeriodDays(row, suffix)), + stock_turnover_90d: hasTurnover ? Number(row?.[turnoverKey] || 0) : annualizedStockTurnover(qty, turnoverBase, productPerformancePeriodDays(row, suffix)), market_count_90d: periodCount(row, 'market_count', suffix), customer_count_90d: periodCount(row, 'customer_count', suffix), stock_qty: stockQty @@ -4344,11 +4347,15 @@ function existingOrStockTurnover (value, salesQty, stockQty, periodDays) { } function groupedOrExistingStockTurnover (row, suffix, periodDays) { - const stockBase = Number(row?.[`avg_stock_${suffix}`] || row?.stock_qty || 0) const salesQty = Number(row?.[`sales_qty_${suffix}`] || 0) + const avgStock = Number(row?.[`avg_stock_${suffix}`] || 0) + const stockQty = Number(row?.stock_qty || 0) if (row?.__group) { - return stockTurnover(salesQty, stockBase, periodDays) + if (avgStock > 0) return stockTurnover(salesQty, avgStock, periodDays) + const current = Number(row?.[`stock_turnover_${suffix}`]) + return Number.isFinite(current) ? current : 0 } + const stockBase = avgStock > 0 ? avgStock : stockQty return existingOrStockTurnover(row?.[`stock_turnover_${suffix}`], salesQty, stockBase, periodDays) } diff --git a/ui/src/pages/ProductStockByAttributes.vue b/ui/src/pages/ProductStockByAttributes.vue index d670228..4c42777 100644 --- a/ui/src/pages/ProductStockByAttributes.vue +++ b/ui/src/pages/ProductStockByAttributes.vue @@ -423,7 +423,6 @@ const productImageListByCode = ref({}) const productImageListLoading = ref({}) const productImageFallbackByKey = ref({}) const productImageContentLoading = ref({}) -const productImageBlobUrls = ref([]) const productImageListBlockedUntil = ref(0) const productCardDialog = ref(false) const productCardData = ref({}) @@ -702,24 +701,13 @@ function clearGalleryQueryIndex() { async function resolveProductImageUrlForCarousel(item) { const resolved = resolveProductImageUrl(item) + const contentUrl = String(resolved.contentUrl || '').trim() + if (contentUrl) return contentUrl + const directUrl = String(resolved.fullUrl || resolved.thumbUrl || resolved.publicUrl || '').trim() if (directUrl) return directUrl - const contentUrl = String(resolved.contentUrl || '').trim() - if (contentUrl) { - try { - const blobRes = await api.get(contentUrl, { baseURL: '', responseType: 'blob' }) - const blob = blobRes?.data - if (blob instanceof Blob) { - const objectUrl = URL.createObjectURL(blob) - productImageBlobUrls.value.push(objectUrl) - return objectUrl - } - } catch { - // fall through to public url - } - } - return contentUrl + return '' } function getProductImageUrl(code, color, secondColor = '', dim1Id = '', dim3Id = '') { @@ -769,9 +757,9 @@ async function flushProductImageBatch() { const list = Array.isArray(item.images) ? item.images : [] const first = list[0] || null const resolved = resolveProductImageUrl(first) - const url = resolved.thumbUrl || resolved.fullUrl || resolved.publicUrl || resolved.contentUrl || '' + const url = resolved.contentUrl || resolved.thumbUrl || resolved.fullUrl || resolved.publicUrl || '' productImageCache.value[key] = String(url || '').trim() - productImageFallbackByKey.value[key] = resolved.fullUrl || resolved.publicUrl || resolved.contentUrl || '' + productImageFallbackByKey.value[key] = resolved.thumbUrl || resolved.fullUrl || resolved.publicUrl || '' if (queued) productImageListByCode.value[queued.listKey] = list delete productImageLoading.value[key] } @@ -860,11 +848,11 @@ async function ensureProductImage(code, color, secondColor = '', dim1Id = '', di productImageCache.value[key] = String( preferredCardUrl || - primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.thumbUrl || primaryResolved.contentUrl || - secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.thumbUrl || secondaryResolved.contentUrl || + primaryResolved.contentUrl || primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.thumbUrl || + secondaryResolved.contentUrl || secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.thumbUrl || '' ).trim() - productImageFallbackByKey.value[key] = primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.contentUrl || secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.contentUrl || '' + productImageFallbackByKey.value[key] = primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.thumbUrl || secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.thumbUrl || '' } catch (err) { console.warn('[ProductStockByAttributes] product image fetch failed', { code, color, err }) productImageCache.value[key] = '' @@ -1576,10 +1564,6 @@ onUnmounted(() => { clearTimeout(filterOptionsDebounceTimer) filterOptionsDebounceTimer = null } - for (const url of productImageBlobUrls.value) { - try { URL.revokeObjectURL(url) } catch {} - } - productImageBlobUrls.value = [] }) diff --git a/ui/src/pages/ProductStockQuery.vue b/ui/src/pages/ProductStockQuery.vue index 4f14ef5..9728e6c 100644 --- a/ui/src/pages/ProductStockQuery.vue +++ b/ui/src/pages/ProductStockQuery.vue @@ -390,7 +390,6 @@ const productImageListByCode = ref({}) const productImageListLoading = ref({}) const productImageFallbackByKey = ref({}) const productImageContentLoading = ref({}) -const productImageBlobUrls = ref([]) const productImageListBlockedUntil = ref(0) const productCardDialog = ref(false) const productCardData = ref({}) @@ -681,24 +680,13 @@ function clearGalleryQueryIndex() { async function resolveProductImageUrlForCarousel(item) { const resolved = resolveProductImageUrl(item) + const contentUrl = String(resolved.contentUrl || '').trim() + if (contentUrl) return contentUrl + const directUrl = String(resolved.fullUrl || resolved.thumbUrl || resolved.publicUrl || '').trim() if (directUrl) return directUrl - const contentUrl = String(resolved.contentUrl || '').trim() - if (contentUrl) { - try { - const blobRes = await api.get(contentUrl, { baseURL: '', responseType: 'blob' }) - const blob = blobRes?.data - if (blob instanceof Blob) { - const objectUrl = URL.createObjectURL(blob) - productImageBlobUrls.value.push(objectUrl) - return objectUrl - } - } catch { - // fall through to public url - } - } - return contentUrl + return '' } function getProductImageUrl(code, color, secondColor = '', dim1Id = '', dim3Id = '') { @@ -748,9 +736,9 @@ async function flushProductImageBatch() { const list = Array.isArray(item.images) ? item.images : [] const first = list[0] || null const resolved = resolveProductImageUrl(first) - const url = resolved.thumbUrl || resolved.fullUrl || resolved.publicUrl || resolved.contentUrl || '' + const url = resolved.contentUrl || resolved.thumbUrl || resolved.fullUrl || resolved.publicUrl || '' productImageCache.value[key] = String(url || '').trim() - productImageFallbackByKey.value[key] = resolved.fullUrl || resolved.publicUrl || resolved.contentUrl || '' + productImageFallbackByKey.value[key] = resolved.thumbUrl || resolved.fullUrl || resolved.publicUrl || '' if (queued) productImageListByCode.value[queued.listKey] = list delete productImageLoading.value[key] } @@ -836,11 +824,11 @@ async function ensureProductImage(code, color, secondColor = '', dim1Id = '', di const secondaryResolved = resolveProductImageUrl(secondaryItem) productImageCache.value[key] = String( preferredCardUrl || - primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.thumbUrl || primaryResolved.contentUrl || - secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.thumbUrl || secondaryResolved.contentUrl || + primaryResolved.contentUrl || primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.thumbUrl || + secondaryResolved.contentUrl || secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.thumbUrl || '' ).trim() - productImageFallbackByKey.value[key] = primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.contentUrl || secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.contentUrl || '' + productImageFallbackByKey.value[key] = primaryResolved.fullUrl || primaryResolved.publicUrl || primaryResolved.thumbUrl || secondaryResolved.fullUrl || secondaryResolved.publicUrl || secondaryResolved.thumbUrl || '' } catch (err) { console.warn('[ProductStockQuery] product image fetch failed', { code, color, err }) productImageCache.value[key] = '' @@ -1374,10 +1362,6 @@ function resetForm() { onUnmounted(() => { window.removeEventListener('mousemove', onFullscreenMouseMove) window.removeEventListener('mouseup', onFullscreenMouseUp) - for (const url of productImageBlobUrls.value) { - try { URL.revokeObjectURL(url) } catch {} - } - productImageBlobUrls.value = [] }) onMounted(async () => {