diff --git a/frontend/src/core/reject-history-filters.js b/frontend/src/core/reject-history-filters.js index daa1ccd..53eed69 100644 --- a/frontend/src/core/reject-history-filters.js +++ b/frontend/src/core/reject-history-filters.js @@ -173,8 +173,7 @@ export function buildViewParams(queryId, { metricFilter = 'all', trendDates = [], detailReason = '', - paretoDimension = '', - paretoValues = [], + paretoSelections = {}, page = 1, perPage = 50, policyFilters = {}, @@ -198,13 +197,19 @@ export function buildViewParams(queryId, { if (detailReason) { params.detail_reason = detailReason; } - const normalizedParetoDimension = normalizeText(paretoDimension).toLowerCase(); - const normalizedParetoValues = normalizeArray(paretoValues); - if (normalizedParetoDimension) { - params.pareto_dimension = normalizedParetoDimension; - } - if (normalizedParetoValues.length > 0) { - params.pareto_values = normalizedParetoValues; + const selectionParamMap = { + reason: 'sel_reason', + package: 'sel_package', + type: 'sel_type', + workflow: 'sel_workflow', + workcenter: 'sel_workcenter', + equipment: 'sel_equipment', + }; + for (const [dimension, paramName] of Object.entries(selectionParamMap)) { + const normalizedValues = normalizeArray(paretoSelections?.[dimension]); + if (normalizedValues.length > 0) { + params[paramName] = normalizedValues; + } } params.page = page || 1; params.per_page = perPage || 50; diff --git a/frontend/src/reject-history/App.vue b/frontend/src/reject-history/App.vue index 5a5e8b7..5482264 100644 --- a/frontend/src/reject-history/App.vue +++ b/frontend/src/reject-history/App.vue @@ -10,22 +10,64 @@ import { replaceRuntimeHistory } from '../core/shell-navigation.js'; import DetailTable from './components/DetailTable.vue'; import FilterPanel from './components/FilterPanel.vue'; -import ParetoSection from './components/ParetoSection.vue'; +import ParetoGrid from './components/ParetoGrid.vue'; import SummaryCards from './components/SummaryCards.vue'; import TrendChart from './components/TrendChart.vue'; const API_TIMEOUT = 360000; const DEFAULT_PER_PAGE = 50; const PARETO_TOP20_DIMENSIONS = new Set(['type', 'workflow', 'equipment']); -const PARETO_DIMENSION_LABELS = { - reason: '不良原因', - package: 'PACKAGE', - type: 'TYPE', - workflow: 'WORKFLOW', - workcenter: '站點', - equipment: '機台', +const PARETO_DIMENSIONS = ['reason', 'package', 'type', 'workflow', 'workcenter', 'equipment']; +const PARETO_SELECTION_PARAM_MAP = { + reason: 'sel_reason', + package: 'sel_package', + type: 'sel_type', + workflow: 'sel_workflow', + workcenter: 'sel_workcenter', + equipment: 'sel_equipment', }; +function createEmptyParetoSelections() { + return { + reason: [], + package: [], + type: [], + workflow: [], + workcenter: [], + equipment: [], + }; +} + +function createEmptyParetoData() { + return { + reason: { items: [], dimension: 'reason', metric_mode: 'reject_total' }, + package: { items: [], dimension: 'package', metric_mode: 'reject_total' }, + type: { items: [], dimension: 'type', metric_mode: 'reject_total' }, + workflow: { items: [], dimension: 'workflow', metric_mode: 'reject_total' }, + workcenter: { items: [], dimension: 'workcenter', metric_mode: 'reject_total' }, + equipment: { items: [], dimension: 'equipment', metric_mode: 'reject_total' }, + }; +} + +function getDimensionLabel(dimension) { + switch (dimension) { + case 'reason': + return '不良原因'; + case 'package': + return 'PACKAGE'; + case 'type': + return 'TYPE'; + case 'workflow': + return 'WORKFLOW'; + case 'workcenter': + return '站點'; + case 'equipment': + return '機台'; + default: + return 'Pareto'; + } +} + // ---- Primary query form state ---- const queryMode = ref('date_range'); const containerInputType = ref('lot'); @@ -69,11 +111,9 @@ const supplementaryFilters = reactive({ const page = ref(1); const selectedTrendDates = ref([]); const trendLegendSelected = ref({ '扣帳報廢量': true, '不扣帳報廢量': true }); -const paretoDimension = ref('reason'); -const selectedParetoValues = ref([]); const paretoDisplayScope = ref('all'); -const dimensionParetoItems = ref([]); -const dimensionParetoLoading = ref(false); +const paretoSelections = reactive(createEmptyParetoSelections()); +const paretoData = reactive(createEmptyParetoData()); // ---- Data state ---- const summary = ref({ @@ -102,6 +142,7 @@ const loading = reactive({ initial: false, querying: false, list: false, + pareto: false, exporting: false, }); const errorMessage = ref(''); @@ -109,6 +150,7 @@ const lastQueryAt = ref(''); // ---- Request staleness tracking ---- let activeRequestId = 0; +let activeParetoRequestId = 0; function nextRequestId() { activeRequestId += 1; @@ -119,6 +161,15 @@ function isStaleRequest(id) { return id !== activeRequestId; } +function nextParetoRequestId() { + activeParetoRequestId += 1; + return activeParetoRequestId; +} + +function isStaleParetoRequest(id) { + return id !== activeParetoRequestId; +} + // ---- Helpers ---- function toDateString(value) { const y = value.getFullYear(); @@ -143,6 +194,10 @@ function metricFilterParam() { return 'all'; } +function paretoMetricApiMode() { + return paretoMetricMode.value === 'defect' ? 'defect' : 'reject_total'; +} + function unwrapApiResult(result, fallbackMessage) { if (result?.success === true) { return result; @@ -153,6 +208,93 @@ function unwrapApiResult(result, fallbackMessage) { return result; } +function resetParetoSelections() { + for (const dimension of PARETO_DIMENSIONS) { + paretoSelections[dimension] = []; + } +} + +function resetParetoData() { + for (const dimension of PARETO_DIMENSIONS) { + paretoData[dimension] = { + items: [], + dimension, + metric_mode: paretoMetricApiMode(), + }; + } +} + +function buildBatchParetoParams() { + const params = { + query_id: queryId.value, + metric_mode: paretoMetricApiMode(), + pareto_scope: committedPrimary.paretoTop80 ? 'top80' : 'all', + pareto_display_scope: paretoDisplayScope.value, + include_excluded_scrap: committedPrimary.includeExcludedScrap ? 'true' : 'false', + exclude_material_scrap: committedPrimary.excludeMaterialScrap ? 'true' : 'false', + exclude_pb_diode: committedPrimary.excludePbDiode ? 'true' : 'false', + }; + + if (supplementaryFilters.packages.length > 0) { + params.packages = supplementaryFilters.packages; + } + if (supplementaryFilters.workcenterGroups.length > 0) { + params.workcenter_groups = supplementaryFilters.workcenterGroups; + } + if (supplementaryFilters.reason) { + params.reason = supplementaryFilters.reason; + } + if (selectedTrendDates.value.length > 0) { + params.trend_dates = selectedTrendDates.value; + } + for (const [dimension, key] of Object.entries(PARETO_SELECTION_PARAM_MAP)) { + if (paretoSelections[dimension]?.length > 0) { + params[key] = paretoSelections[dimension]; + } + } + return params; +} + +async function fetchBatchPareto() { + if (!queryId.value) return; + + const requestId = nextParetoRequestId(); + loading.pareto = true; + + try { + const resp = await apiGet('/api/reject-history/batch-pareto', { + params: buildBatchParetoParams(), + timeout: API_TIMEOUT, + }); + if (isStaleParetoRequest(requestId)) return; + + if (resp?.success === false && resp?.error === 'cache_miss') { + await executePrimaryQuery(); + return; + } + + const result = unwrapApiResult(resp, '查詢批次 Pareto 失敗'); + const dimensions = result.data?.dimensions || {}; + for (const dimension of PARETO_DIMENSIONS) { + paretoData[dimension] = dimensions[dimension] || { + items: [], + dimension, + metric_mode: paretoMetricApiMode(), + }; + } + } catch (error) { + if (isStaleParetoRequest(requestId)) return; + resetParetoData(); + if (error?.name !== 'AbortError') { + errorMessage.value = error?.message || '查詢批次 Pareto 失敗'; + } + } finally { + if (!isStaleParetoRequest(requestId)) { + loading.pareto = false; + } + } +} + // ---- Primary query (POST /query → Oracle → cache) ---- async function executePrimaryQuery() { const requestId = nextRequestId(); @@ -180,7 +322,6 @@ async function executePrimaryQuery() { const result = unwrapApiResult(resp, '主查詢執行失敗'); - // Commit primary params for URL state and chips committedPrimary.mode = queryMode.value; committedPrimary.startDate = draftFilters.startDate; committedPrimary.endDate = draftFilters.endDate; @@ -192,7 +333,6 @@ async function executePrimaryQuery() { committedPrimary.excludePbDiode = draftFilters.excludePbDiode; committedPrimary.paretoTop80 = draftFilters.paretoTop80; - // Store query result queryId.value = result.query_id; resolutionInfo.value = result.resolution_info || null; const af = result.available_filters || {}; @@ -202,24 +342,22 @@ async function executePrimaryQuery() { reasons: af.reasons || [], }; - // Reset supplementary + interactive supplementaryFilters.packages = []; supplementaryFilters.workcenterGroups = []; supplementaryFilters.reason = ''; page.value = 1; selectedTrendDates.value = []; - selectedParetoValues.value = []; - paretoDisplayScope.value = 'all'; - paretoDimension.value = 'reason'; - dimensionParetoItems.value = []; + resetParetoSelections(); + resetParetoData(); - // Apply initial data analyticsRawItems.value = Array.isArray(result.analytics_raw) ? result.analytics_raw : []; summary.value = result.summary || summary.value; detail.value = result.detail || detail.value; + await fetchBatchPareto(); + lastQueryAt.value = new Date().toLocaleString('zh-TW'); updateUrlState(); } catch (error) { @@ -249,8 +387,7 @@ async function refreshView() { supplementaryFilters, metricFilter: metricFilterParam(), trendDates: selectedTrendDates.value, - paretoDimension: paretoDimension.value, - paretoValues: selectedParetoValues.value, + paretoSelections, page: page.value, perPage: DEFAULT_PER_PAGE, policyFilters: { @@ -266,7 +403,6 @@ async function refreshView() { }); if (isStaleRequest(requestId)) return; - // Handle cache expired → auto re-execute primary query if (resp?.success === false && resp?.error === 'cache_expired') { await executePrimaryQuery(); return; @@ -309,6 +445,8 @@ function clearFilters() { draftFilters.excludeMaterialScrap = true; draftFilters.excludePbDiode = true; draftFilters.paretoTop80 = true; + paretoDisplayScope.value = 'all'; + resetParetoSelections(); void executePrimaryQuery(); } @@ -329,110 +467,54 @@ function onTrendDateClick(dateStr) { selectedTrendDates.value = [...selectedTrendDates.value, dateStr]; } page.value = 1; - void refreshView(); - refreshDimensionParetoIfActive(); + updateUrlState(); + void Promise.all([refreshView(), fetchBatchPareto()]); } function onTrendLegendChange(selected) { trendLegendSelected.value = { ...selected }; page.value = 1; updateUrlState(); - void refreshView(); - refreshDimensionParetoIfActive(); + void Promise.all([refreshView(), fetchBatchPareto()]); } -function onParetoItemToggle(itemValue) { +function onParetoItemToggle(dimension, itemValue) { + if (!Object.hasOwn(PARETO_SELECTION_PARAM_MAP, dimension)) { + return; + } const normalized = String(itemValue || '').trim(); if (!normalized) return; - if (selectedParetoValues.value.includes(normalized)) { - selectedParetoValues.value = selectedParetoValues.value.filter( - (item) => item !== normalized, - ); + + const current = paretoSelections[dimension] || []; + if (current.includes(normalized)) { + paretoSelections[dimension] = current.filter((item) => item !== normalized); } else { - selectedParetoValues.value = [...selectedParetoValues.value, normalized]; + paretoSelections[dimension] = [...current, normalized]; } + page.value = 1; updateUrlState(); - void refreshView(); + void Promise.all([fetchBatchPareto(), refreshView()]); } function handleParetoScopeToggle(checked) { draftFilters.paretoTop80 = Boolean(checked); committedPrimary.paretoTop80 = Boolean(checked); updateUrlState(); - refreshDimensionParetoIfActive(); -} - -let activeDimRequestId = 0; - -async function fetchDimensionPareto(dim) { - if (dim === 'reason' || !queryId.value) return; - activeDimRequestId += 1; - const myId = activeDimRequestId; - dimensionParetoLoading.value = true; - try { - const params = { - query_id: queryId.value, - start_date: committedPrimary.startDate, - end_date: committedPrimary.endDate, - dimension: dim, - metric_mode: paretoMetricMode.value === 'defect' ? 'defect' : 'reject_total', - pareto_scope: committedPrimary.paretoTop80 ? 'top80' : 'all', - include_excluded_scrap: committedPrimary.includeExcludedScrap, - exclude_material_scrap: committedPrimary.excludeMaterialScrap, - exclude_pb_diode: committedPrimary.excludePbDiode, - packages: supplementaryFilters.packages.length > 0 ? supplementaryFilters.packages : undefined, - workcenter_groups: supplementaryFilters.workcenterGroups.length > 0 ? supplementaryFilters.workcenterGroups : undefined, - reason: supplementaryFilters.reason || undefined, - trend_dates: selectedTrendDates.value.length > 0 ? selectedTrendDates.value : undefined, - }; - const resp = await apiGet('/api/reject-history/reason-pareto', { params, timeout: API_TIMEOUT }); - if (myId !== activeDimRequestId) return; - const result = unwrapApiResult(resp, '查詢維度 Pareto 失敗'); - dimensionParetoItems.value = result.data?.items || []; - } catch (err) { - if (myId !== activeDimRequestId) return; - dimensionParetoItems.value = []; - if (err?.name !== 'AbortError') { - errorMessage.value = err.message || '查詢維度 Pareto 失敗'; - } - } finally { - if (myId === activeDimRequestId) { - dimensionParetoLoading.value = false; - } - } -} - -function refreshDimensionParetoIfActive() { - if (paretoDimension.value !== 'reason') { - void fetchDimensionPareto(paretoDimension.value); - } -} - -function onDimensionChange(dim) { - paretoDimension.value = dim; - selectedParetoValues.value = []; - paretoDisplayScope.value = 'all'; - page.value = 1; - if (dim === 'reason') { - dimensionParetoItems.value = []; - void refreshView(); - } else { - void fetchDimensionPareto(dim); - void refreshView(); - } + void fetchBatchPareto(); } function onParetoDisplayScopeChange(scope) { paretoDisplayScope.value = scope === 'top20' ? 'top20' : 'all'; updateUrlState(); + void fetchBatchPareto(); } function clearParetoSelection() { - selectedParetoValues.value = []; + resetParetoSelections(); page.value = 1; updateUrlState(); - void refreshView(); + void Promise.all([fetchBatchPareto(), refreshView()]); } function onSupplementaryChange(filters) { @@ -441,37 +523,32 @@ function onSupplementaryChange(filters) { supplementaryFilters.reason = filters.reason || ''; page.value = 1; selectedTrendDates.value = []; - selectedParetoValues.value = []; - void refreshView(); - refreshDimensionParetoIfActive(); + resetParetoSelections(); + updateUrlState(); + void Promise.all([refreshView(), fetchBatchPareto()]); } function removeFilterChip(chip) { if (!chip?.removable) return; if (chip.type === 'pareto-value') { - selectedParetoValues.value = selectedParetoValues.value.filter( - (value) => value !== chip.value, - ); - page.value = 1; - updateUrlState(); - void refreshView(); + onParetoItemToggle(chip.dimension, chip.value); return; } if (chip.type === 'trend-dates') { selectedTrendDates.value = []; page.value = 1; - void refreshView(); - refreshDimensionParetoIfActive(); + updateUrlState(); + void Promise.all([refreshView(), fetchBatchPareto()]); return; } if (chip.type === 'reason') { supplementaryFilters.reason = ''; page.value = 1; - void refreshView(); - refreshDimensionParetoIfActive(); + updateUrlState(); + void Promise.all([refreshView(), fetchBatchPareto()]); return; } @@ -480,8 +557,8 @@ function removeFilterChip(chip) { (g) => g !== chip.value, ); page.value = 1; - void refreshView(); - refreshDimensionParetoIfActive(); + updateUrlState(); + void Promise.all([refreshView(), fetchBatchPareto()]); return; } @@ -490,9 +567,8 @@ function removeFilterChip(chip) { (p) => p !== chip.value, ); page.value = 1; - void refreshView(); - refreshDimensionParetoIfActive(); - return; + updateUrlState(); + void Promise.all([refreshView(), fetchBatchPareto()]); } } @@ -511,10 +587,12 @@ async function exportCsv() { if (supplementaryFilters.reason) params.set('reason', supplementaryFilters.reason); params.set('metric_filter', metricFilterParam()); for (const date of selectedTrendDates.value) params.append('trend_dates', date); - params.set('pareto_dimension', paretoDimension.value); - for (const value of selectedParetoValues.value) params.append('pareto_values', value); + for (const [dimension, key] of Object.entries(PARETO_SELECTION_PARAM_MAP)) { + for (const value of paretoSelections[dimension] || []) { + params.append(key, value); + } + } - // Policy filters (applied in-memory on cached data) if (committedPrimary.includeExcludedScrap) params.set('include_excluded_scrap', 'true'); if (!committedPrimary.excludeMaterialScrap) params.set('exclude_material_scrap', 'false'); if (!committedPrimary.excludePbDiode) params.set('exclude_pb_diode', 'false'); @@ -612,99 +690,30 @@ const paretoMetricLabel = computed(() => { } }); -const allParetoItems = computed(() => { - const raw = analyticsRawItems.value; - if (!raw || raw.length === 0) return []; +const selectedParetoCount = computed(() => { + let count = 0; + for (const dimension of PARETO_DIMENSIONS) { + count += (paretoSelections[dimension] || []).length; + } + return count; +}); - const mode = paretoMetricMode.value; - if (mode === 'none') return []; - - const dateSet = - selectedTrendDates.value.length > 0 ? new Set(selectedTrendDates.value) : null; - const filtered = dateSet ? raw.filter((r) => dateSet.has(r.bucket_date)) : raw; - if (filtered.length === 0) return []; - - const map = new Map(); - for (const item of filtered) { - const key = item.reason; - if (!map.has(key)) { - map.set(key, { - reason: key, - MOVEIN_QTY: 0, - REJECT_TOTAL_QTY: 0, - DEFECT_QTY: 0, - AFFECTED_LOT_COUNT: 0, - }); +const selectedParetoSummary = computed(() => { + const tokens = []; + for (const dimension of PARETO_DIMENSIONS) { + for (const value of paretoSelections[dimension] || []) { + tokens.push(`${getDimensionLabel(dimension)}:${value}`); } - const acc = map.get(key); - acc.MOVEIN_QTY += Number(item.MOVEIN_QTY || 0); - acc.REJECT_TOTAL_QTY += Number(item.REJECT_TOTAL_QTY || 0); - acc.DEFECT_QTY += Number(item.DEFECT_QTY || 0); - acc.AFFECTED_LOT_COUNT += Number(item.AFFECTED_LOT_COUNT || 0); } - - const withMetric = Array.from(map.values()).map((row) => { - let mv; - if (mode === 'all') mv = row.REJECT_TOTAL_QTY + row.DEFECT_QTY; - else if (mode === 'reject') mv = row.REJECT_TOTAL_QTY; - else mv = row.DEFECT_QTY; - return { ...row, metric_value: mv }; - }); - - const sorted = withMetric - .filter((r) => r.metric_value > 0) - .sort((a, b) => b.metric_value - a.metric_value); - const total = sorted.reduce((sum, r) => sum + r.metric_value, 0); - let cum = 0; - return sorted.map((row) => { - const pct = total ? Number(((row.metric_value / total) * 100).toFixed(4)) : 0; - cum += pct; - return { - reason: row.reason, - metric_value: row.metric_value, - MOVEIN_QTY: row.MOVEIN_QTY, - REJECT_TOTAL_QTY: row.REJECT_TOTAL_QTY, - DEFECT_QTY: row.DEFECT_QTY, - count: row.AFFECTED_LOT_COUNT, - pct, - cumPct: Number(cum.toFixed(4)), - }; - }); -}); - -const filteredParetoItems = computed(() => { - const items = allParetoItems.value || []; - if (!committedPrimary.paretoTop80 || items.length === 0) { - return items; + if (tokens.length <= 3) { + return tokens.join(', '); } - const cutIdx = items.findIndex((item) => Number(item.cumPct || 0) >= 80); - const top80Count = cutIdx >= 0 ? cutIdx + 1 : items.length; - return items.slice(0, Math.max(top80Count, Math.min(5, items.length))); + return `${tokens.slice(0, 3).join(', ')}... (${tokens.length} 項)`; }); -const activeParetoItems = computed(() => { - const baseItems = - paretoDimension.value === 'reason' - ? filteredParetoItems.value - : (dimensionParetoItems.value || []); - - if ( - PARETO_TOP20_DIMENSIONS.has(paretoDimension.value) - && paretoDisplayScope.value === 'top20' - ) { - return baseItems.slice(0, 20); - } - return baseItems; -}); - -const selectedParetoDimensionLabel = computed( - () => PARETO_DIMENSION_LABELS[paretoDimension.value] || 'Pareto', -); - const activeFilterChips = computed(() => { const chips = []; - // Primary query info if (committedPrimary.mode === 'date_range') { chips.push({ key: 'date-range', @@ -727,7 +736,6 @@ const activeFilterChips = computed(() => { }); } - // Policy chips chips.push({ key: 'policy-mode', label: committedPrimary.includeExcludedScrap @@ -752,7 +760,6 @@ const activeFilterChips = computed(() => { value: '', }); - // Supplementary chips (removable) if (supplementaryFilters.reason) { chips.push({ key: `reason:${supplementaryFilters.reason}`, @@ -783,7 +790,6 @@ const activeFilterChips = computed(() => { }); }); - // Interactive chips (removable) if (selectedTrendDates.value.length > 0) { const dates = selectedTrendDates.value; const label = @@ -797,15 +803,18 @@ const activeFilterChips = computed(() => { }); } - selectedParetoValues.value.forEach((value) => { - chips.push({ - key: `pareto-value:${paretoDimension.value}:${value}`, - label: `${selectedParetoDimensionLabel.value}: ${value}`, - removable: true, - type: 'pareto-value', - value, - }); - }); + for (const dimension of PARETO_DIMENSIONS) { + for (const value of paretoSelections[dimension] || []) { + chips.push({ + key: `pareto-value:${dimension}:${value}`, + label: `${getDimensionLabel(dimension)}: ${value}`, + removable: true, + type: 'pareto-value', + dimension, + value, + }); + } + } return chips; }); @@ -855,18 +864,20 @@ function updateUrlState() { params.set('exclude_material_scrap', String(committedPrimary.excludeMaterialScrap)); params.set('exclude_pb_diode', String(committedPrimary.excludePbDiode)); - // Supplementary appendArrayParams(params, 'packages', supplementaryFilters.packages); appendArrayParams(params, 'workcenter_groups', supplementaryFilters.workcenterGroups); if (supplementaryFilters.reason) { params.set('reason', supplementaryFilters.reason); } - // Interactive appendArrayParams(params, 'trend_dates', selectedTrendDates.value); - params.set('pareto_dimension', paretoDimension.value); - appendArrayParams(params, 'pareto_values', selectedParetoValues.value); - if (paretoDisplayScope.value !== 'all') params.set('pareto_display_scope', paretoDisplayScope.value); + for (const [dimension, key] of Object.entries(PARETO_SELECTION_PARAM_MAP)) { + appendArrayParams(params, key, paretoSelections[dimension] || []); + } + + if (paretoDisplayScope.value !== 'all') { + params.set('pareto_display_scope', paretoDisplayScope.value); + } if (!committedPrimary.paretoTop80) { params.set('pareto_scope_all', 'true'); } @@ -903,7 +914,6 @@ function readBooleanParam(params, key, defaultValue = false) { function restoreFromUrl() { const params = new URLSearchParams(window.location.search); - // Mode const mode = String(params.get('mode') || '').trim(); if (mode === 'container') { queryMode.value = 'container'; @@ -920,7 +930,6 @@ function restoreFromUrl() { } } - // Policy draftFilters.includeExcludedScrap = readBooleanParam( params, 'include_excluded_scrap', @@ -934,38 +943,38 @@ function restoreFromUrl() { draftFilters.excludePbDiode = readBooleanParam(params, 'exclude_pb_diode', true); draftFilters.paretoTop80 = !readBooleanParam(params, 'pareto_scope_all', false); - // Supplementary (will be applied after primary query) - const urlPackages = readArrayParam(params, 'packages'); - const urlWcGroups = readArrayParam(params, 'workcenter_groups'); - const urlReason = String(params.get('reason') || '').trim(); + supplementaryFilters.packages = readArrayParam(params, 'packages'); + supplementaryFilters.workcenterGroups = readArrayParam(params, 'workcenter_groups'); + supplementaryFilters.reason = String(params.get('reason') || '').trim(); + + selectedTrendDates.value = readArrayParam(params, 'trend_dates'); + + const restoredSelections = createEmptyParetoSelections(); + for (const [dimension, key] of Object.entries(PARETO_SELECTION_PARAM_MAP)) { + restoredSelections[dimension] = readArrayParam(params, key); + } + + const legacyDimension = String(params.get('pareto_dimension') || '').trim().toLowerCase(); + const legacyValues = readArrayParam(params, 'pareto_values'); + const hasSelParams = Object.values(restoredSelections).some((values) => values.length > 0); + if (!hasSelParams && legacyValues.length > 0) { + const fallbackDimension = Object.hasOwn(PARETO_SELECTION_PARAM_MAP, legacyDimension) + ? legacyDimension + : 'reason'; + restoredSelections[fallbackDimension] = legacyValues; + } + + for (const dimension of PARETO_DIMENSIONS) { + paretoSelections[dimension] = restoredSelections[dimension]; + } - // Interactive - const urlTrendDates = readArrayParam(params, 'trend_dates'); - const rawParetoDimension = String(params.get('pareto_dimension') || '').trim().toLowerCase(); - const urlParetoDimension = Object.hasOwn(PARETO_DIMENSION_LABELS, rawParetoDimension) - ? rawParetoDimension - : 'reason'; - const urlParetoValues = readArrayParam(params, 'pareto_values'); const urlParetoDisplayScope = String(params.get('pareto_display_scope') || '').trim().toLowerCase(); - const parsedPage = Number(params.get('page') || '1'); - - paretoDimension.value = urlParetoDimension; - selectedParetoValues.value = urlParetoValues; paretoDisplayScope.value = urlParetoDisplayScope === 'top20' ? 'top20' : 'all'; - return { - packages: urlPackages, - workcenterGroups: urlWcGroups, - reason: urlReason, - trendDates: urlTrendDates, - paretoDimension: urlParetoDimension, - paretoValues: urlParetoValues, - paretoDisplayScope: paretoDisplayScope.value, - page: Number.isFinite(parsedPage) && parsedPage > 0 ? parsedPage : 1, - }; + const parsedPage = Number(params.get('page') || '1'); + page.value = Number.isFinite(parsedPage) && parsedPage > 0 ? parsedPage : 1; } -// ---- Mount ---- onMounted(() => { setDefaultDateRange(); restoreFromUrl(); @@ -1004,11 +1013,13 @@ onMounted(() => { :resolution-info="resolutionInfo" :loading="loading" :active-filter-chips="activeFilterChips" + :pareto-display-scope="paretoDisplayScope" @apply="applyFilters" @clear="clearFilters" @export-csv="exportCsv" @remove-chip="removeFilterChip" @pareto-scope-toggle="handleParetoScopeToggle" + @pareto-display-scope-change="onParetoDisplayScopeChange" @update:query-mode="queryMode = $event" @update:container-input-type="containerInputType = $event" @update:container-input="containerInput = $event" @@ -1026,26 +1037,22 @@ onMounted(() => { @legend-change="onTrendLegendChange" /> - diff --git a/frontend/src/reject-history/components/DetailTable.vue b/frontend/src/reject-history/components/DetailTable.vue index 44ce996..5600d56 100644 --- a/frontend/src/reject-history/components/DetailTable.vue +++ b/frontend/src/reject-history/components/DetailTable.vue @@ -2,14 +2,14 @@ import { ref } from 'vue'; defineProps({ - items: { type: Array, default: () => [] }, + items: { type: Array, default: () => [] }, pagination: { type: Object, default: () => ({ page: 1, perPage: 50, total: 0, totalPages: 1 }), }, loading: { type: Boolean, default: false }, - selectedParetoValues: { type: Array, default: () => [] }, - selectedParetoDimensionLabel: { type: String, default: '' }, + selectedParetoCount: { type: Number, default: 0 }, + selectedParetoSummary: { type: String, default: '' }, }); defineEmits(['go-to-page', 'clear-pareto-selection']); @@ -26,13 +26,8 @@ function formatNumber(value) {
明細列表 - - {{ selectedParetoDimensionLabel || 'Pareto 篩選' }}: - {{ - selectedParetoValues.length === 1 - ? selectedParetoValues[0] - : `${selectedParetoValues.length} 項` - }} + + Pareto 篩選: {{ selectedParetoSummary || `${selectedParetoCount} 項` }}
diff --git a/frontend/src/reject-history/components/FilterPanel.vue b/frontend/src/reject-history/components/FilterPanel.vue index f8a727d..038467b 100644 --- a/frontend/src/reject-history/components/FilterPanel.vue +++ b/frontend/src/reject-history/components/FilterPanel.vue @@ -8,21 +8,23 @@ const props = defineProps({ containerInput: { type: String, default: '' }, availableFilters: { type: Object, default: () => ({}) }, supplementaryFilters: { type: Object, default: () => ({}) }, - queryId: { type: String, default: '' }, - resolutionInfo: { type: Object, default: null }, - loading: { type: Object, required: true }, - activeFilterChips: { type: Array, default: () => [] }, -}); + queryId: { type: String, default: '' }, + resolutionInfo: { type: Object, default: null }, + loading: { type: Object, required: true }, + activeFilterChips: { type: Array, default: () => [] }, + paretoDisplayScope: { type: String, default: 'all' }, +}); const emit = defineEmits([ 'apply', 'clear', 'export-csv', - 'remove-chip', - 'pareto-scope-toggle', - 'update:queryMode', - 'update:containerInputType', - 'update:containerInput', + 'remove-chip', + 'pareto-scope-toggle', + 'pareto-display-scope-change', + 'update:queryMode', + 'update:containerInputType', + 'update:containerInput', 'supplementary-change', ]); @@ -191,6 +193,15 @@ function emitSupplementary(patch) { /> Pareto 僅顯示累計前 80% + +
diff --git a/frontend/src/reject-history/components/ParetoGrid.vue b/frontend/src/reject-history/components/ParetoGrid.vue new file mode 100644 index 0000000..74ffb16 --- /dev/null +++ b/frontend/src/reject-history/components/ParetoGrid.vue @@ -0,0 +1,45 @@ + + + diff --git a/frontend/src/reject-history/components/ParetoSection.vue b/frontend/src/reject-history/components/ParetoSection.vue index c23a492..cd78fa6 100644 --- a/frontend/src/reject-history/components/ParetoSection.vue +++ b/frontend/src/reject-history/components/ParetoSection.vue @@ -1,22 +1,14 @@ + {{ formatPct(item.cumPct) }} + + + No data + + + +
+
+ + diff --git a/frontend/src/reject-history/style.css b/frontend/src/reject-history/style.css index 9ad4054..6615004 100644 --- a/frontend/src/reject-history/style.css +++ b/frontend/src/reject-history/style.css @@ -54,6 +54,10 @@ } .supplementary-toolbar { + display: flex; + align-items: center; + gap: 10px; + flex-wrap: wrap; margin-bottom: 12px; } @@ -304,11 +308,18 @@ .chart-wrap, .pareto-chart-wrap { width: 100%; - height: 340px; + height: 260px; position: relative; overflow: hidden; } +.pareto-grid { + display: grid; + grid-template-columns: repeat(3, minmax(0, 1fr)); + gap: 14px; + margin-bottom: 14px; +} + .pareto-header { display: flex; justify-content: space-between; @@ -350,8 +361,8 @@ .pareto-layout { display: grid; - grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr); - gap: 12px; + grid-template-columns: minmax(0, 1fr); + gap: 10px; } .pareto-table-wrap { @@ -618,7 +629,7 @@ } } -@media (max-width: 1180px) { +@media (max-width: 1200px) { .filter-panel { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -631,12 +642,12 @@ grid-template-columns: repeat(2, minmax(0, 1fr)); } - .pareto-layout { - grid-template-columns: 1fr; + .pareto-grid { + grid-template-columns: repeat(2, minmax(0, 1fr)); } } -@media (max-width: 760px) { +@media (max-width: 768px) { .reject-summary-row { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -662,4 +673,8 @@ flex-direction: column; align-items: flex-start; } + + .pareto-grid { + grid-template-columns: 1fr; + } } diff --git a/frontend/tests/report-filter-strategy.test.js b/frontend/tests/report-filter-strategy.test.js index 519af24..3163bb3 100644 --- a/frontend/tests/report-filter-strategy.test.js +++ b/frontend/tests/report-filter-strategy.test.js @@ -3,6 +3,7 @@ import assert from 'node:assert/strict'; import { buildRejectOptionsRequestParams, + buildViewParams, pruneRejectFilterSelections, } from '../src/core/reject-history-filters.js'; import { @@ -62,6 +63,45 @@ test('reject-history prune removes invalid selected values', () => { assert.equal(pruned.removedCount, 3); }); +test('reject-history view params include multi-dimension pareto selections', () => { + const params = buildViewParams('qid-001', { + supplementaryFilters: { + packages: ['PKG-A'], + workcenterGroups: ['WB'], + reason: '001_A', + }, + trendDates: ['2026-02-01'], + paretoSelections: { + reason: ['001_A'], + type: ['TYPE-A', 'TYPE-B'], + workflow: ['WF-01'], + }, + page: 2, + perPage: 80, + policyFilters: { + includeExcludedScrap: true, + excludeMaterialScrap: false, + excludePbDiode: false, + }, + }); + + assert.deepEqual(params, { + query_id: 'qid-001', + packages: ['PKG-A'], + workcenter_groups: ['WB'], + reason: '001_A', + trend_dates: ['2026-02-01'], + sel_reason: ['001_A'], + sel_type: ['TYPE-A', 'TYPE-B'], + sel_workflow: ['WF-01'], + page: 2, + per_page: 80, + include_excluded_scrap: 'true', + exclude_material_scrap: 'false', + exclude_pb_diode: 'false', + }); +}); + test('resource-history derives families from upstream group and flags', () => { const resources = [ { id: 'R1', name: 'MC-01', family: 'FAM-A', workcenterGroup: 'WB', isProduction: true, isKey: true, isMonitor: false }, diff --git a/openspec/changes/archive/2026-03-02-reject-history-multi-pareto-layout/.openspec.yaml b/openspec/changes/archive/2026-03-02-reject-history-multi-pareto-layout/.openspec.yaml new file mode 100644 index 0000000..fd79bfc --- /dev/null +++ b/openspec/changes/archive/2026-03-02-reject-history-multi-pareto-layout/.openspec.yaml @@ -0,0 +1,2 @@ +schema: spec-driven +created: 2026-03-02 diff --git a/openspec/changes/archive/2026-03-02-reject-history-multi-pareto-layout/design.md b/openspec/changes/archive/2026-03-02-reject-history-multi-pareto-layout/design.md new file mode 100644 index 0000000..d85e8be --- /dev/null +++ b/openspec/changes/archive/2026-03-02-reject-history-multi-pareto-layout/design.md @@ -0,0 +1,59 @@ +## Context + +The reject history page displays a single Pareto chart with a dropdown to switch between 6 dimensions (reason, package, type, workflow, workcenter, equipment). Users lose cross-dimensional context when switching. The cached dataset in `reject_dataset_cache.py` already supports all 6 dimensions via `compute_dimension_pareto()` and the `_DIM_TO_DF_COLUMN` mapping. The goal is to show all 6 simultaneously in a 3×2 grid with cross-filter linkage. + +Current architecture: +- **Backend**: `compute_dimension_pareto()` computes one dimension at a time from a cached Pandas DataFrame (in-memory, populated by the primary Oracle query and keyed by `query_id`). All Pareto computation is performed on this cached DataFrame — no additional Oracle queries are made. +- **Frontend**: `App.vue` holds `paretoDimension` (single ref), `selectedParetoValues` (single array), and `paretoDisplayScope`. Client-side reason Pareto is computed via `allParetoItems` computed property from `analyticsRawItems`. Other dimensions call `fetchDimensionPareto()` which hits `GET /api/reject-history/reason-pareto?dimension=X`. + +## Goals / Non-Goals + +**Goals:** +- Display all 6 Pareto dimensions simultaneously in a 3-column responsive grid +- Cross-filter linkage: clicking items in dimension X filters all OTHER dimensions (exclude-self) +- Detail table reflects ALL dimension selections (AND logic) +- Single batch API call for all 6 dimensions (avoid 6 round-trips) +- Unify all Pareto computation on backend (remove client-side reason Pareto) + +**Non-Goals:** +- No changes to the primary query SQL or data model +- No changes to the cache infrastructure or TTL +- No new Oracle fallback paths (batch-pareto is cache-only) +- No drag-and-drop or reorderable grid layout + +## Decisions + +### 1. Single batch API vs 6 separate calls +**Decision**: Single `GET /api/reject-history/batch-pareto` endpoint returning all 6 dimensions. +**Rationale**: Cross-filter requires all dimensions to see each other's selections. A single call eliminates 6 round-trips and guarantees consistency across all 6 results. The cached DataFrame is shared in-memory so iterating 6 dimensions is negligible overhead. + +### 2. Cross-filter logic: exclude-self pattern +**Decision**: When computing dimension X's Pareto, apply selections from all OTHER dimensions but NOT X's own. +**Rationale**: If X's own selections were applied, the Pareto would only show selected items — defeating the purpose of showing the full distribution. Exclude-self lets users see "given these TYPE and WORKFLOW selections, what is the REASON distribution?" + +### 3. Remove client-side reason Pareto +**Decision**: Remove the `allParetoItems` computed property that builds reason Pareto client-side from `analyticsRawItems`. All 6 dimensions (including reason) computed by backend `compute_batch_pareto()`. +**Rationale**: Unifies computation path. The backend already has the logic; duplicating it client-side for one dimension creates divergence risk and doesn't support cross-filtering. + +### 4. Retain TOP20/ALL display scope toggle +**Decision**: Keep the TOP20/全部顯示 toggle for applicable dimensions (TYPE, WORKFLOW, 機台). Apply uniformly across all 6 charts via a global control (not per-chart selectors). +**Rationale**: Even after 80% cumulative filtering, dimensions like TYPE, WORKFLOW, and 機台 can still produce many items. The TOP20 truncation remains valuable for readability in the compact grid layout. Moving to a single global toggle (instead of per-chart selectors) reduces UI clutter while preserving the functionality. + +### 5. ParetoGrid wrapper vs inline loop +**Decision**: New `ParetoGrid.vue` component wrapping 6 `ParetoSection.vue` instances. +**Rationale**: Separates grid layout concerns from individual chart rendering. `ParetoSection.vue` is simplified to a pure display component. `ParetoGrid.vue` handles the dimension iteration and event delegation. + +### 6. Multi-dimension selection URL encoding +**Decision**: Use `sel_reason`, `sel_package`, `sel_type`, `sel_workflow`, `sel_workcenter`, `sel_equipment` as separate array params (replacing single `pareto_dimension` + `pareto_values`). +**Rationale**: Each dimension has independent selections. Encoding them as separate params is explicit, debuggable, and backward-compatible (old URLs with `pareto_dimension` simply won't have `sel_*` params). + +### 7. Multi-dimension detail/export filter +**Decision**: Extend `_apply_pareto_selection_filter()` to accept a `pareto_selections: dict` and apply all dimensions cumulatively (AND logic). Keep backward compat with single `pareto_dimension`/`pareto_values` for the existing single-dimension endpoints until they are removed. +**Rationale**: Detail table and CSV export need to filter by all 6 dimensions simultaneously. AND logic is the natural interpretation: "show rows matching these reasons AND these types AND these workflows." + +## Risks / Trade-offs + +- **6 charts on screen may feel crowded on smaller screens** → Responsive breakpoints: 3-col desktop, 2-col tablet, 1-col mobile. Chart height reduced from 340px to ~240px. +- **Batch pareto payload is larger (6× single)** → Still small (6 arrays of ≤20 items each). Negligible compared to the primary query. +- **Per-chart scope toggles removed in favor of global toggle** → Individual TOP20/ALL selectors inside each chart would clutter the compact grid. A single global TOP20/ALL control in supplementary filters provides the same functionality with cleaner UX. +- **Cross-filter causes re-fetch on every click** → Mitigated by cache-only computation (no Oracle queries). Typical response time < 50ms. diff --git a/openspec/changes/archive/2026-03-02-reject-history-multi-pareto-layout/proposal.md b/openspec/changes/archive/2026-03-02-reject-history-multi-pareto-layout/proposal.md new file mode 100644 index 0000000..8490249 --- /dev/null +++ b/openspec/changes/archive/2026-03-02-reject-history-multi-pareto-layout/proposal.md @@ -0,0 +1,43 @@ +## Why + +目前報廢歷史的柏拉圖一次只顯示一個維度,需透過下拉選單切換,無法同時看到跨維度的分佈與交叉關係。改為同時顯示 6 個柏拉圖(3×2 grid),並支援跨圖即時聯動篩選——點擊任一柏拉圖的項目後,其餘 5 個柏拉圖即時重新計算(排除自身維度的篩選),下方明細表則套用所有維度的篩選結果。 + +## What Changes + +### 前端 +- 移除維度切換下拉選單(`ParetoSection.vue` 的 dimension selector) +- 新增 `ParetoGrid.vue` 元件,以 3 欄 grid 同時渲染 6 個獨立柏拉圖(不良原因、PACKAGE、TYPE、WORKFLOW、站點、機台) +- 每個柏拉圖支援多選(現有行為),點擊後即時聯動: + - 其他 5 個柏拉圖重新計算(套用來自其他維度的選取,但不套用自身維度的選取) + - 明細表套用所有 6 個維度的選取結果 +- 選取狀態以 chip 顯示在明細表上方,按維度分組 + +### 後端 +- 新增批次柏拉圖 API endpoint(`GET /api/reject-history/batch-pareto`),對快取中的 Pandas DataFrame 進行重算,一次回傳 6 個維度的柏拉圖資料(不重查 Oracle 資料庫) +- 每個維度的計算套用「排除自身」的交叉篩選邏輯:計算 Reason Pareto 時套用其他 5 維度的選取,但不套用 Reason 自身的選取 +- 移除前端 client-side 的 reason Pareto 計算(統一由後端從快取計算) + +### 移除 +- 移除維度切換選單和 `onDimensionChange` 邏輯 +- 移除現有的單維度 `fetchDimensionPareto` 流程 + +### 保留 +- 保留 TOP20/全部顯示切換功能(TYPE、WORKFLOW、機台維度在 80% 過濾後仍可能有大量項目,TOP20 截斷對使用者仍有價值) + +## Capabilities + +### New Capabilities + +(none) + +### Modified Capabilities + +- `reject-history-api`: 新增批次柏拉圖 endpoint,支援跨維度交叉篩選 +- `reject-history-page`: 柏拉圖從單維度切換改為 6 圖同時顯示 + 即時聯動 + +## Impact + +- 前端:`App.vue`(狀態管理重構)、`ParetoSection.vue`(改為純展示元件)、新增 `ParetoGrid.vue` +- 後端:`reject_dataset_cache.py`(新增批次計算,資料來源為快取的 Pandas DataFrame)、`reject_history_routes.py`(新增 endpoint) +- API:新增 `GET /api/reject-history/batch-pareto` endpoint(cache-only,不查 Oracle) +- 無資料庫/SQL 變更 diff --git a/openspec/changes/archive/2026-03-02-reject-history-multi-pareto-layout/specs/reject-history-api/spec.md b/openspec/changes/archive/2026-03-02-reject-history-multi-pareto-layout/specs/reject-history-api/spec.md new file mode 100644 index 0000000..f35eaad --- /dev/null +++ b/openspec/changes/archive/2026-03-02-reject-history-multi-pareto-layout/specs/reject-history-api/spec.md @@ -0,0 +1,78 @@ +## ADDED Requirements + +### Requirement: Reject History API SHALL provide batch Pareto endpoint with cross-filter +The API SHALL provide a batch Pareto endpoint that returns all 6 dimension Pareto results in a single response, supporting cross-dimension filtering with exclude-self logic. + +#### Scenario: Batch Pareto response structure +- **WHEN** `GET /api/reject-history/batch-pareto` is called with valid `query_id` +- **THEN** response SHALL be `{ success: true, data: { dimensions: { reason: {...}, package: {...}, type: {...}, workflow: {...}, workcenter: {...}, equipment: {...} } } }` +- **THEN** each dimension object SHALL include `items` array with same schema as reason-pareto items (`reason`, `metric_value`, `pct`, `cumPct`, `MOVEIN_QTY`, `REJECT_TOTAL_QTY`, `DEFECT_QTY`, `count`) + +#### Scenario: Cross-filter exclude-self logic +- **WHEN** `sel_reason=A&sel_type=X` is provided +- **THEN** reason Pareto SHALL be computed with type=X filter applied (but NOT reason=A filter) +- **THEN** type Pareto SHALL be computed with reason=A filter applied (but NOT type=X filter) +- **THEN** package/workflow/workcenter/equipment Paretos SHALL be computed with both reason=A AND type=X filters applied + +#### Scenario: Empty selections return unfiltered Paretos +- **WHEN** batch-pareto is called with no `sel_*` parameters +- **THEN** all 6 dimensions SHALL return their full Pareto distribution (same as calling reason-pareto individually with no cross-filter) + +#### Scenario: Cache-only computation +- **WHEN** `query_id` does not exist in cache +- **THEN** the endpoint SHALL return HTTP 400 with error message indicating cache miss +- **THEN** the endpoint SHALL NOT fall back to Oracle query + +#### Scenario: Supplementary and policy filters apply +- **WHEN** batch-pareto is called with supplementary filters (packages, workcenter_groups, reason) and policy toggles +- **THEN** all 6 dimension Paretos SHALL be computed after applying policy and supplementary filters first (before cross-filter) + +#### Scenario: Data source is cached DataFrame only +- **WHEN** batch-pareto computes dimension Paretos +- **THEN** computation SHALL operate on the in-memory cached Pandas DataFrame (populated by the primary query) +- **THEN** the endpoint SHALL NOT issue any additional Oracle database queries +- **THEN** response time SHALL be sub-100ms since all computation is in-memory + +#### Scenario: Display scope (TOP20) support +- **WHEN** `pareto_display_scope=top20` is provided +- **THEN** applicable dimensions (type, workflow, equipment) SHALL truncate results to top 20 items after sorting +- **WHEN** `pareto_display_scope` is omitted or `all` +- **THEN** all items SHALL be returned (subject to pareto_scope 80% filter if active) + +### Requirement: Reject History API SHALL support multi-dimension Pareto selection in view and export +The detail view and export endpoints SHALL accept multiple dimension selections simultaneously and apply them with AND logic. + +#### Scenario: Multi-dimension filter on view endpoint +- **WHEN** `GET /api/reject-history/view` is called with `sel_reason=A&sel_type=X` +- **THEN** returned rows SHALL match reason=A AND type=X (both filters applied simultaneously) + +#### Scenario: Multi-dimension filter on export endpoint +- **WHEN** `GET /api/reject-history/export-cached` is called with `sel_reason=A&sel_type=X` +- **THEN** exported CSV SHALL contain only rows matching reason=A AND type=X + +#### Scenario: Backward compatibility with single-dimension params +- **WHEN** `pareto_dimension` and `pareto_values` are provided (legacy format) +- **THEN** the API SHALL still accept and apply them as before +- **WHEN** both `sel_*` params and legacy params are provided +- **THEN** `sel_*` params SHALL take precedence + +## MODIFIED Requirements + +### Requirement: Reject History API SHALL provide reason Pareto endpoint +The API SHALL return sorted reason distribution data with cumulative percentages. The endpoint supports dimension selection via `dimension` parameter for single-dimension queries. + +#### Scenario: Pareto response payload +- **WHEN** `GET /api/reject-history/reason-pareto` is called +- **THEN** each item SHALL include `reason`, `category`, selected metric value, `pct`, and `cumPct` +- **THEN** items SHALL be sorted descending by selected metric + +#### Scenario: Metric mode validation +- **WHEN** `metric_mode` is provided +- **THEN** accepted values SHALL be `reject_total` or `defect` +- **THEN** invalid `metric_mode` SHALL return HTTP 400 + +#### Scenario: Dimension selection +- **WHEN** `dimension` parameter is provided with a valid value (reason, package, type, workflow, workcenter, equipment) +- **THEN** the endpoint SHALL return Pareto data for that dimension +- **WHEN** `dimension` is not provided +- **THEN** the endpoint SHALL default to `reason` diff --git a/openspec/changes/archive/2026-03-02-reject-history-multi-pareto-layout/specs/reject-history-page/spec.md b/openspec/changes/archive/2026-03-02-reject-history-multi-pareto-layout/specs/reject-history-page/spec.md new file mode 100644 index 0000000..8dbfba9 --- /dev/null +++ b/openspec/changes/archive/2026-03-02-reject-history-multi-pareto-layout/specs/reject-history-page/spec.md @@ -0,0 +1,92 @@ +## MODIFIED Requirements + +### Requirement: Reject History page SHALL provide reason Pareto analysis +The page SHALL display 6 Pareto charts simultaneously (不良原因, PACKAGE, TYPE, WORKFLOW, 站點, 機台) in a 3-column grid layout with cross-filter linkage, replacing the single-dimension dropdown switcher. + +#### Scenario: Multi-Pareto grid layout +- **WHEN** Pareto data is loaded +- **THEN** 6 Pareto charts SHALL be rendered simultaneously in a 3-column grid (3×2) +- **THEN** each chart SHALL display one dimension: 不良原因, PACKAGE, TYPE, WORKFLOW, 站點, 機台 +- **THEN** there SHALL be no dimension dropdown selector + +#### Scenario: Pareto rendering and ordering +- **WHEN** Pareto data is loaded +- **THEN** items in each Pareto SHALL be sorted by selected metric descending +- **THEN** each Pareto SHALL show a cumulative percentage line + +#### Scenario: Pareto 80% filter is managed in supplementary filters +- **WHEN** the page first loads Pareto +- **THEN** supplementary filters SHALL include "Pareto 僅顯示累計前 80%" control +- **THEN** the control SHALL default to enabled +- **THEN** the 80% filter SHALL apply uniformly to all 6 Pareto charts + +#### Scenario: Cross-filter linkage between Pareto charts +- **WHEN** user clicks an item in one Pareto chart (e.g., selects reason "A") +- **THEN** the other 5 Pareto charts SHALL recalculate with the selection applied as a filter +- **THEN** the clicked Pareto chart itself SHALL NOT be filtered by its own selections +- **THEN** the detail table below SHALL apply ALL selections from ALL dimensions + +#### Scenario: Pareto click filtering supports multi-select +- **WHEN** user clicks Pareto bars or table rows in any dimension +- **THEN** clicked items SHALL become active selected chips +- **THEN** multiple selected items SHALL be supported within each dimension +- **THEN** multiple dimensions SHALL support simultaneous selections + +#### Scenario: Re-click clears selected item only +- **WHEN** user clicks an already selected Pareto item +- **THEN** only that item SHALL be removed from selection +- **THEN** remaining selected items across all dimensions SHALL stay active +- **THEN** all Pareto charts SHALL recalculate to reflect the updated selections + +#### Scenario: Filter chips display all dimension selections +- **WHEN** items are selected across multiple Pareto dimensions +- **THEN** selected items SHALL be displayed as chips grouped by dimension label +- **THEN** each chip SHALL show the dimension label and selected value (e.g., "TYPE: X") +- **THEN** clicking a chip's remove button SHALL deselect that item and trigger recalculation + +#### Scenario: Responsive grid layout +- **WHEN** viewport is desktop width (>1200px) +- **THEN** Pareto charts SHALL render in a 3-column grid +- **WHEN** viewport is medium width (768px–1200px) +- **THEN** Pareto charts SHALL render in a 2-column grid +- **WHEN** viewport is below 768px +- **THEN** Pareto charts SHALL stack in a single column + +#### Scenario: TOP20/ALL display scope control +- **WHEN** Pareto grid is displayed +- **THEN** supplementary filters SHALL include a global "只顯示 TOP 20" toggle +- **THEN** when enabled, applicable dimensions (TYPE, WORKFLOW, 機台) SHALL truncate to top 20 items +- **THEN** the toggle SHALL apply uniformly to all applicable Pareto charts (not per-chart selectors) +- **THEN** dimensions not in the applicable set (不良原因, PACKAGE, 站點) SHALL be unaffected by this toggle + +## MODIFIED Requirements + +### Requirement: Reject History page SHALL be structured as modular sub-components +The page template SHALL delegate sections to focused sub-components, following the hold-history architecture pattern. + +#### Scenario: Component decomposition +- **WHEN** the page source is examined +- **THEN** the filter panel SHALL be a separate `FilterPanel.vue` component +- **THEN** the KPI summary cards SHALL be a separate `SummaryCards.vue` component +- **THEN** the trend chart SHALL be a separate `TrendChart.vue` component +- **THEN** the pareto grid SHALL be a separate `ParetoGrid.vue` component containing 6 `ParetoSection.vue` instances +- **THEN** each individual pareto chart+table SHALL be a `ParetoSection.vue` component +- **THEN** the detail table with pagination SHALL be a separate `DetailTable.vue` component + +#### Scenario: App.vue acts as orchestrator +- **WHEN** the page runs +- **THEN** `App.vue` SHALL hold all reactive state and API logic +- **THEN** sub-components SHALL receive data via props and communicate via events + +### Requirement: Reject History page SHALL support CSV export from current filter context +The page SHALL allow users to export records using the exact active filters. + +#### Scenario: Export with all active filters +- **WHEN** user clicks "匯出 CSV" +- **THEN** export request SHALL include current primary filters, supplementary filters, trend-date filters, metric filters, and all Pareto-selected items from all 6 dimensions +- **THEN** downloaded file SHALL contain exactly the same rows currently represented by the detail list filter context + +#### Scenario: Export remains UTF-8 Excel compatible +- **WHEN** CSV export is downloaded +- **THEN** the file SHALL be encoded in UTF-8 with BOM +- **THEN** Chinese headers and values SHALL render correctly in common spreadsheet tools diff --git a/openspec/changes/archive/2026-03-02-reject-history-multi-pareto-layout/tasks.md b/openspec/changes/archive/2026-03-02-reject-history-multi-pareto-layout/tasks.md new file mode 100644 index 0000000..1886ecf --- /dev/null +++ b/openspec/changes/archive/2026-03-02-reject-history-multi-pareto-layout/tasks.md @@ -0,0 +1,51 @@ +## 1. Backend — Batch Pareto with Cross-Filter + +- [x] 1.1 Add `_apply_cross_filter(df, selections, exclude_dim)` helper in `reject_dataset_cache.py` — applies all dimension selections except `exclude_dim` using `_DIM_TO_DF_COLUMN` mapping +- [x] 1.2 Add `compute_batch_pareto()` function in `reject_dataset_cache.py` — iterates all 6 dimensions from cached DataFrame (no Oracle query), applies policy → supplementary → trend-date → cross-filter, supports `pareto_display_scope=top20` truncation for applicable dimensions, returns `{"dimensions": {...}}` +- [x] 1.3 Add `_parse_multi_pareto_selections()` helper in `reject_history_routes.py` — parses `sel_reason`, `sel_package`, `sel_type`, `sel_workflow`, `sel_workcenter`, `sel_equipment` from query params +- [x] 1.4 Add `GET /api/reject-history/batch-pareto` endpoint in `reject_history_routes.py` — cache-only (no Oracle fallback), accepts `pareto_display_scope` param, calls `compute_batch_pareto()` + +## 2. Backend — Multi-Dimension Detail/Export Filter + +- [x] 2.1 Extend `_apply_pareto_selection_filter()` in `reject_dataset_cache.py` to accept `pareto_selections: dict` (multi-dimension AND logic), keeping backward compat with single `pareto_dimension`/`pareto_values` +- [x] 2.2 Update `apply_view()` and `export_csv_from_cache()` in `reject_dataset_cache.py` to pass multi-dimension selections +- [x] 2.3 Update `view` and `export-cached` endpoints in `reject_history_routes.py` to parse and forward `sel_*` params + +## 3. Frontend — State Refactor (App.vue) + +- [x] 3.1 Replace single-dimension state (`paretoDimension`, `selectedParetoValues`, `dimensionParetoItems`, `dimensionParetoLoading`) with `paretoSelections` reactive object and `paretoData` reactive object; keep `paretoDisplayScope` ref for global TOP20/ALL toggle +- [x] 3.2 Add `fetchBatchPareto()` function — calls `GET /api/reject-history/batch-pareto` with `sel_*` params, updates all 6 `paretoData` entries +- [x] 3.3 Rewrite `onParetoItemToggle(dimension, value)` — toggle in `paretoSelections[dimension]`, call `fetchBatchPareto()` + `refreshView()`, reset page +- [x] 3.4 Remove dead code: `allParetoItems`, `filteredParetoItems`, `activeParetoItems` computed, `fetchDimensionPareto()`, `refreshDimensionParetoIfActive()`, `onDimensionChange()`, `PARETO_DIMENSION_LABELS`; keep `PARETO_TOP20_DIMENSIONS` and `paretoDisplayScope` for global TOP20/ALL toggle +- [x] 3.5 Update `activeFilterChips` computed — loop all 6 dimensions, generate chip per selected value with dimension label +- [x] 3.6 Update chip removal handler to call `onParetoItemToggle(dim, value)` + +## 4. Frontend — URL State + +- [x] 4.1 Update `updateUrlState()` — replace `pareto_dimension`/`pareto_values` with `sel_reason`, `sel_package`, etc. array params; keep `pareto_display_scope` for TOP20/ALL +- [x] 4.2 Update `restoreFromUrl()` — parse `sel_*` params into `paretoSelections` object +- [x] 4.3 Update `buildViewParams()` in `reject-history-filters.js` — replace `paretoDimension`/`paretoValues` with `paretoSelections` dict, emit `sel_*` params + +## 5. Frontend — Components + +- [x] 5.1 Simplify `ParetoSection.vue` — remove dimension selector dropdown and `DIMENSION_OPTIONS`; keep per-chart TOP20 truncation logic (controlled by parent via `displayScope` prop); add dimension label map; emit `item-toggle` with value only (parent handles dimension routing) +- [x] 5.2 Create `ParetoGrid.vue` — 3-column grid container rendering 6 `ParetoSection` instances, props: `paretoData`, `paretoSelections`, `loading`, `metricLabel`, `selectedDates`; emit `item-toggle(dimension, value)` +- [x] 5.3 Update `App.vue` template — replace single `` with `` + +## 6. Frontend — Styling + +- [x] 6.1 Add `.pareto-grid` CSS class in `style.css` — 3-column grid with responsive breakpoints (2-col at ≤1200px, 1-col at ≤768px) +- [x] 6.2 Adjust `.pareto-chart-wrap` height from 340px to ~240px for compact multi-chart display +- [x] 6.3 Adjust `.pareto-layout` for vertical stack (chart above table) in grid context + +## 7. Integration & Wire-Up + +- [x] 7.1 Wire `fetchBatchPareto()` into `loadAllData()` flow — call after primary query completes +- [x] 7.2 Wire supplementary filter changes and trend-date changes to trigger `fetchBatchPareto()` +- [x] 7.3 Wire export button to include all 6 dimension selections in export request + +## 8. Testing + +- [x] 8.1 Add unit tests for `compute_batch_pareto()` cross-filter logic in `tests/test_reject_dataset_cache.py` +- [x] 8.2 Add route tests for `GET /api/reject-history/batch-pareto` endpoint in `tests/test_reject_history_routes.py` +- [x] 8.3 Add tests for multi-dimension `_apply_pareto_selection_filter()` in `tests/test_reject_dataset_cache.py` diff --git a/openspec/specs/reject-history-api/spec.md b/openspec/specs/reject-history-api/spec.md index d5cc894..2405d0a 100644 --- a/openspec/specs/reject-history-api/spec.md +++ b/openspec/specs/reject-history-api/spec.md @@ -53,7 +53,7 @@ The API SHALL return time-series trend data for quantity and rate metrics. - **THEN** invalid granularity SHALL return HTTP 400 ### Requirement: Reject History API SHALL provide reason Pareto endpoint -The API SHALL return sorted reason distribution data with cumulative percentages. +The API SHALL return sorted reason distribution data with cumulative percentages. The endpoint supports dimension selection via `dimension` parameter for single-dimension queries. #### Scenario: Pareto response payload - **WHEN** `GET /api/reject-history/reason-pareto` is called @@ -65,6 +65,68 @@ The API SHALL return sorted reason distribution data with cumulative percentages - **THEN** accepted values SHALL be `reject_total` or `defect` - **THEN** invalid `metric_mode` SHALL return HTTP 400 +#### Scenario: Dimension selection +- **WHEN** `dimension` parameter is provided with a valid value (reason, package, type, workflow, workcenter, equipment) +- **THEN** the endpoint SHALL return Pareto data for that dimension +- **WHEN** `dimension` is not provided +- **THEN** the endpoint SHALL default to `reason` + +### Requirement: Reject History API SHALL provide batch Pareto endpoint with cross-filter +The API SHALL provide a batch Pareto endpoint that returns all 6 dimension Pareto results in a single response, supporting cross-dimension filtering with exclude-self logic. + +#### Scenario: Batch Pareto response structure +- **WHEN** `GET /api/reject-history/batch-pareto` is called with valid `query_id` +- **THEN** response SHALL be `{ success: true, data: { dimensions: { reason: {...}, package: {...}, type: {...}, workflow: {...}, workcenter: {...}, equipment: {...} } } }` +- **THEN** each dimension object SHALL include `items` array with same schema as reason-pareto items (`reason`, `metric_value`, `pct`, `cumPct`, `MOVEIN_QTY`, `REJECT_TOTAL_QTY`, `DEFECT_QTY`, `count`) + +#### Scenario: Cross-filter exclude-self logic +- **WHEN** `sel_reason=A&sel_type=X` is provided +- **THEN** reason Pareto SHALL be computed with type=X filter applied (but NOT reason=A filter) +- **THEN** type Pareto SHALL be computed with reason=A filter applied (but NOT type=X filter) +- **THEN** package/workflow/workcenter/equipment Paretos SHALL be computed with both reason=A AND type=X filters applied + +#### Scenario: Empty selections return unfiltered Paretos +- **WHEN** batch-pareto is called with no `sel_*` parameters +- **THEN** all 6 dimensions SHALL return their full Pareto distribution (same as calling reason-pareto individually with no cross-filter) + +#### Scenario: Cache-only computation +- **WHEN** `query_id` does not exist in cache +- **THEN** the endpoint SHALL return HTTP 400 with error message indicating cache miss +- **THEN** the endpoint SHALL NOT fall back to Oracle query + +#### Scenario: Supplementary and policy filters apply +- **WHEN** batch-pareto is called with supplementary filters (packages, workcenter_groups, reason) and policy toggles +- **THEN** all 6 dimension Paretos SHALL be computed after applying policy and supplementary filters first (before cross-filter) + +#### Scenario: Data source is cached DataFrame only +- **WHEN** batch-pareto computes dimension Paretos +- **THEN** computation SHALL operate on the in-memory cached Pandas DataFrame (populated by the primary query) +- **THEN** the endpoint SHALL NOT issue any additional Oracle database queries +- **THEN** response time SHALL be sub-100ms since all computation is in-memory + +#### Scenario: Display scope (TOP20) support +- **WHEN** `pareto_display_scope=top20` is provided +- **THEN** applicable dimensions (type, workflow, equipment) SHALL truncate results to top 20 items after sorting +- **WHEN** `pareto_display_scope` is omitted or `all` +- **THEN** all items SHALL be returned (subject to pareto_scope 80% filter if active) + +### Requirement: Reject History API SHALL support multi-dimension Pareto selection in view and export +The detail view and export endpoints SHALL accept multiple dimension selections simultaneously and apply them with AND logic. + +#### Scenario: Multi-dimension filter on view endpoint +- **WHEN** `GET /api/reject-history/view` is called with `sel_reason=A&sel_type=X` +- **THEN** returned rows SHALL match reason=A AND type=X (both filters applied simultaneously) + +#### Scenario: Multi-dimension filter on export endpoint +- **WHEN** `GET /api/reject-history/export-cached` is called with `sel_reason=A&sel_type=X` +- **THEN** exported CSV SHALL contain only rows matching reason=A AND type=X + +#### Scenario: Backward compatibility with single-dimension params +- **WHEN** `pareto_dimension` and `pareto_values` are provided (legacy format) +- **THEN** the API SHALL still accept and apply them as before +- **WHEN** both `sel_*` params and legacy params are provided +- **THEN** `sel_*` params SHALL take precedence + ### Requirement: Reject History API SHALL provide paginated detail endpoint The API SHALL return paginated detailed rows for the selected filter context. diff --git a/openspec/specs/reject-history-page/spec.md b/openspec/specs/reject-history-page/spec.md index 72b1d05..14b4627 100644 --- a/openspec/specs/reject-history-page/spec.md +++ b/openspec/specs/reject-history-page/spec.md @@ -74,33 +74,63 @@ The page SHALL show both quantity trend and rate trend to avoid mixing unit scal - **THEN** rate values SHALL be displayed as percentages ### Requirement: Reject History page SHALL provide reason Pareto analysis -The page SHALL provide a Pareto view for loss reasons and support downstream filtering. +The page SHALL display 6 Pareto charts simultaneously (不良原因, PACKAGE, TYPE, WORKFLOW, 站點, 機台) in a 3-column grid layout with cross-filter linkage, replacing the single-dimension dropdown switcher. + +#### Scenario: Multi-Pareto grid layout +- **WHEN** Pareto data is loaded +- **THEN** 6 Pareto charts SHALL be rendered simultaneously in a 3-column grid (3×2) +- **THEN** each chart SHALL display one dimension: 不良原因, PACKAGE, TYPE, WORKFLOW, 站點, 機台 +- **THEN** there SHALL be no dimension dropdown selector #### Scenario: Pareto rendering and ordering - **WHEN** Pareto data is loaded -- **THEN** items SHALL be sorted by selected metric descending -- **THEN** a cumulative percentage line SHALL be shown +- **THEN** items in each Pareto SHALL be sorted by selected metric descending +- **THEN** each Pareto SHALL show a cumulative percentage line #### Scenario: Pareto 80% filter is managed in supplementary filters - **WHEN** the page first loads Pareto - **THEN** supplementary filters SHALL include "Pareto 僅顯示累計前 80%" control - **THEN** the control SHALL default to enabled +- **THEN** the 80% filter SHALL apply uniformly to all 6 Pareto charts -#### Scenario: TYPE/WORKFLOW/機台 support display scope selector -- **WHEN** Pareto dimension is `TYPE`, `WORKFLOW`, or `機台` -- **THEN** the UI SHALL provide `全部顯示` and `只顯示 TOP 20` options -- **THEN** `全部顯示` SHALL still respect the current 80% cumulative filter setting +#### Scenario: Cross-filter linkage between Pareto charts +- **WHEN** user clicks an item in one Pareto chart (e.g., selects reason "A") +- **THEN** the other 5 Pareto charts SHALL recalculate with the selection applied as a filter +- **THEN** the clicked Pareto chart itself SHALL NOT be filtered by its own selections +- **THEN** the detail table below SHALL apply ALL selections from ALL dimensions #### Scenario: Pareto click filtering supports multi-select -- **WHEN** user clicks Pareto bars or table rows +- **WHEN** user clicks Pareto bars or table rows in any dimension - **THEN** clicked items SHALL become active selected chips -- **THEN** multiple selected items SHALL be supported at the same time -- **THEN** detail list SHALL reload using current selected Pareto items as additional filter criteria +- **THEN** multiple selected items SHALL be supported within each dimension +- **THEN** multiple dimensions SHALL support simultaneous selections #### Scenario: Re-click clears selected item only - **WHEN** user clicks an already selected Pareto item - **THEN** only that item SHALL be removed from selection -- **THEN** remaining selected items SHALL stay active +- **THEN** remaining selected items across all dimensions SHALL stay active +- **THEN** all Pareto charts SHALL recalculate to reflect the updated selections + +#### Scenario: Filter chips display all dimension selections +- **WHEN** items are selected across multiple Pareto dimensions +- **THEN** selected items SHALL be displayed as chips grouped by dimension label +- **THEN** each chip SHALL show the dimension label and selected value (e.g., "TYPE: X") +- **THEN** clicking a chip's remove button SHALL deselect that item and trigger recalculation + +#### Scenario: Responsive grid layout +- **WHEN** viewport is desktop width (>1200px) +- **THEN** Pareto charts SHALL render in a 3-column grid +- **WHEN** viewport is medium width (768px–1200px) +- **THEN** Pareto charts SHALL render in a 2-column grid +- **WHEN** viewport is below 768px +- **THEN** Pareto charts SHALL stack in a single column + +#### Scenario: TOP20/ALL display scope control +- **WHEN** Pareto grid is displayed +- **THEN** supplementary filters SHALL include a global "只顯示 TOP 20" toggle +- **THEN** when enabled, applicable dimensions (TYPE, WORKFLOW, 機台) SHALL truncate to top 20 items +- **THEN** the toggle SHALL apply uniformly to all applicable Pareto charts (not per-chart selectors) +- **THEN** dimensions not in the applicable set (不良原因, PACKAGE, 站點) SHALL be unaffected by this toggle ### Requirement: Reject History page SHALL show paginated detail rows The page SHALL provide a paginated detail table for investigation and traceability. @@ -119,7 +149,7 @@ The page SHALL allow users to export records using the exact active filters. #### Scenario: Export with all active filters - **WHEN** user clicks "匯出 CSV" -- **THEN** export request SHALL include current primary filters, supplementary filters, trend-date filters, metric filters, and Pareto-selected items +- **THEN** export request SHALL include current primary filters, supplementary filters, trend-date filters, metric filters, and all Pareto-selected items from all 6 dimensions - **THEN** downloaded file SHALL contain exactly the same rows currently represented by the detail list filter context #### Scenario: Export remains UTF-8 Excel compatible @@ -197,7 +227,8 @@ The page template SHALL delegate sections to focused sub-components, following t - **THEN** the filter panel SHALL be a separate `FilterPanel.vue` component - **THEN** the KPI summary cards SHALL be a separate `SummaryCards.vue` component - **THEN** the trend chart SHALL be a separate `TrendChart.vue` component -- **THEN** the pareto section (chart + table) SHALL be a separate `ParetoSection.vue` component +- **THEN** the pareto grid SHALL be a separate `ParetoGrid.vue` component containing 6 `ParetoSection.vue` instances +- **THEN** each individual pareto chart+table SHALL be a `ParetoSection.vue` component - **THEN** the detail table with pagination SHALL be a separate `DetailTable.vue` component #### Scenario: App.vue acts as orchestrator diff --git a/src/mes_dashboard/routes/reject_history_routes.py b/src/mes_dashboard/routes/reject_history_routes.py index 64a9409..dda6cc3 100644 --- a/src/mes_dashboard/routes/reject_history_routes.py +++ b/src/mes_dashboard/routes/reject_history_routes.py @@ -14,6 +14,7 @@ from mes_dashboard.core.rate_limit import configured_rate_limit from mes_dashboard.core.utils import parse_bool_query from mes_dashboard.services.reject_dataset_cache import ( apply_view, + compute_batch_pareto, compute_dimension_pareto, execute_primary_query, export_csv_from_cache, @@ -121,6 +122,14 @@ _VALID_PARETO_DIMENSIONS = { "workcenter", "equipment", } +_PARETO_SELECTION_PARAMS = { + "reason": "sel_reason", + "package": "sel_package", + "type": "sel_type", + "workflow": "sel_workflow", + "workcenter": "sel_workcenter", + "equipment": "sel_equipment", +} def _parse_common_bools() -> tuple[Optional[tuple[dict, int]], bool, bool, bool]: @@ -161,6 +170,15 @@ def _parse_pareto_selection() -> tuple[Optional[tuple[dict, int]], Optional[str] return None, (pareto_dimension or None), (pareto_values or None) +def _parse_multi_pareto_selections() -> dict[str, list[str]]: + selections: dict[str, list[str]] = {} + for dim, param_name in _PARETO_SELECTION_PARAMS.items(): + values = _parse_multi_param(param_name) + if values: + selections[dim] = values + return selections + + @reject_history_bp.route("/api/reject-history/options", methods=["GET"]) def api_reject_history_options(): start_date, end_date, date_error = _parse_date_range(required=False) @@ -363,6 +381,45 @@ def api_reject_history_reason_pareto(): return jsonify({"success": False, "error": "查詢柏拉圖資料失敗"}), 500 +@reject_history_bp.route("/api/reject-history/batch-pareto", methods=["GET"]) +def api_reject_history_batch_pareto(): + """Batch pareto view: compute all dimensions from cache only.""" + query_id = request.args.get("query_id", "").strip() + if not query_id: + return jsonify({"success": False, "error": "缺少必要參數: query_id"}), 400 + + bool_error, include_excluded_scrap, exclude_material_scrap, exclude_pb_diode = _parse_common_bools() + if bool_error: + return jsonify(bool_error[0]), bool_error[1] + + metric_mode = request.args.get("metric_mode", "reject_total").strip().lower() or "reject_total" + pareto_scope = request.args.get("pareto_scope", "top80").strip().lower() or "top80" + pareto_display_scope = request.args.get("pareto_display_scope", "all").strip().lower() or "all" + + try: + result = compute_batch_pareto( + query_id=query_id, + metric_mode=metric_mode, + pareto_scope=pareto_scope, + pareto_display_scope=pareto_display_scope, + packages=_parse_multi_param("packages") or None, + workcenter_groups=_parse_multi_param("workcenter_groups") or None, + reason=request.args.get("reason", "").strip() or None, + trend_dates=_parse_multi_param("trend_dates") or None, + pareto_selections=_parse_multi_pareto_selections(), + include_excluded_scrap=include_excluded_scrap, + exclude_material_scrap=exclude_material_scrap, + exclude_pb_diode=exclude_pb_diode, + ) + if result is None: + return jsonify({"success": False, "error": "cache_miss"}), 400 + return jsonify({"success": True, "data": result}) + except ValueError as exc: + return jsonify({"success": False, "error": str(exc)}), 400 + except Exception: + return jsonify({"success": False, "error": "查詢批次柏拉圖失敗"}), 500 + + @reject_history_bp.route("/api/reject-history/list", methods=["GET"]) @_REJECT_HISTORY_LIST_RATE_LIMIT def api_reject_history_list(): @@ -544,9 +601,13 @@ def api_reject_history_view(): metric_filter = request.args.get("metric_filter", "all").strip().lower() or "all" reason = request.args.get("reason", "").strip() or None detail_reason = request.args.get("detail_reason", "").strip() or None - pareto_error, pareto_dimension, pareto_values = _parse_pareto_selection() - if pareto_error: - return jsonify(pareto_error[0]), pareto_error[1] + pareto_selections = _parse_multi_pareto_selections() + pareto_dimension = None + pareto_values = None + if not pareto_selections: + pareto_error, pareto_dimension, pareto_values = _parse_pareto_selection() + if pareto_error: + return jsonify(pareto_error[0]), pareto_error[1] include_excluded_scrap = request.args.get("include_excluded_scrap", "false").lower() == "true" exclude_material_scrap = request.args.get("exclude_material_scrap", "true").lower() != "false" @@ -563,6 +624,7 @@ def api_reject_history_view(): detail_reason=detail_reason, pareto_dimension=pareto_dimension, pareto_values=pareto_values, + pareto_selections=pareto_selections or None, page=page, per_page=per_page, include_excluded_scrap=include_excluded_scrap, @@ -593,9 +655,13 @@ def api_reject_history_export_cached(): metric_filter = request.args.get("metric_filter", "all").strip().lower() or "all" reason = request.args.get("reason", "").strip() or None detail_reason = request.args.get("detail_reason", "").strip() or None - pareto_error, pareto_dimension, pareto_values = _parse_pareto_selection() - if pareto_error: - return jsonify(pareto_error[0]), pareto_error[1] + pareto_selections = _parse_multi_pareto_selections() + pareto_dimension = None + pareto_values = None + if not pareto_selections: + pareto_error, pareto_dimension, pareto_values = _parse_pareto_selection() + if pareto_error: + return jsonify(pareto_error[0]), pareto_error[1] include_excluded_scrap = request.args.get("include_excluded_scrap", "false").lower() == "true" exclude_material_scrap = request.args.get("exclude_material_scrap", "true").lower() != "false" @@ -612,6 +678,7 @@ def api_reject_history_export_cached(): detail_reason=detail_reason, pareto_dimension=pareto_dimension, pareto_values=pareto_values, + pareto_selections=pareto_selections or None, include_excluded_scrap=include_excluded_scrap, exclude_material_scrap=exclude_material_scrap, exclude_pb_diode=exclude_pb_diode, diff --git a/src/mes_dashboard/services/reject_dataset_cache.py b/src/mes_dashboard/services/reject_dataset_cache.py index d04a493..bba0924 100644 --- a/src/mes_dashboard/services/reject_dataset_cache.py +++ b/src/mes_dashboard/services/reject_dataset_cache.py @@ -398,6 +398,7 @@ def apply_view( detail_reason: Optional[str] = None, pareto_dimension: Optional[str] = None, pareto_values: Optional[List[str]] = None, + pareto_selections: Optional[Dict[str, List[str]]] = None, page: int = 1, per_page: int = 50, include_excluded_scrap: bool = False, @@ -446,6 +447,7 @@ def apply_view( detail_df, pareto_dimension=pareto_dimension, pareto_values=pareto_values, + pareto_selections=pareto_selections, ) detail_page = _paginate_detail(detail_df, page=page, per_page=per_page) @@ -514,11 +516,33 @@ def _apply_pareto_selection_filter( *, pareto_dimension: Optional[str] = None, pareto_values: Optional[List[str]] = None, + pareto_selections: Optional[Dict[str, List[str]]] = None, ) -> pd.DataFrame: """Apply Pareto multi-select filters on detail/export datasets.""" if df is None or df.empty: return df + normalized_selections = _normalize_pareto_selections(pareto_selections) + if normalized_selections: + filtered = df + for dim in _PARETO_DIMENSIONS: + selected_values = normalized_selections.get(dim) + if not selected_values: + continue + dim_col = _DIM_TO_DF_COLUMN.get(dim) + if not dim_col: + raise ValueError(f"不支援的 pareto_dimension: {dim}") + if dim_col not in filtered.columns: + return filtered.iloc[0:0] + value_set = set(selected_values) + normalized_dimension_values = filtered[dim_col].map( + lambda value: _normalize_text(value) or "(未知)" + ) + filtered = filtered[normalized_dimension_values.isin(value_set)] + if filtered.empty: + return filtered + return filtered + normalized_values = _normalize_pareto_values(pareto_values) if not normalized_values: return df @@ -769,6 +793,143 @@ _DIM_TO_DF_COLUMN = { "workcenter": "WORKCENTER_GROUP", "equipment": "PRIMARY_EQUIPMENTNAME", } +_PARETO_DIMENSIONS = tuple(_DIM_TO_DF_COLUMN.keys()) +_PARETO_TOP20_DIMENSIONS = {"type", "workflow", "equipment"} + + +def _normalize_metric_mode(metric_mode: str) -> str: + mode = _normalize_text(metric_mode).lower() + if mode not in {"reject_total", "defect"}: + raise ValueError("Invalid metric_mode, supported: reject_total, defect") + return mode + + +def _normalize_pareto_scope(pareto_scope: str) -> str: + scope = _normalize_text(pareto_scope).lower() or "top80" + if scope not in {"top80", "all"}: + raise ValueError("Invalid pareto_scope, supported: top80, all") + return scope + + +def _normalize_pareto_display_scope(display_scope: str) -> str: + scope = _normalize_text(display_scope).lower() or "all" + if scope not in {"all", "top20"}: + raise ValueError("Invalid pareto_display_scope, supported: all, top20") + return scope + + +def _normalize_pareto_selections( + pareto_selections: Optional[Dict[str, List[str]]], +) -> Dict[str, List[str]]: + normalized: Dict[str, List[str]] = {} + for dim, values in (pareto_selections or {}).items(): + dim_key = _normalize_text(dim).lower() + if not dim_key: + continue + if dim_key not in _DIM_TO_DF_COLUMN: + raise ValueError(f"不支援的 pareto_dimension: {dim}") + normalized_values = _normalize_pareto_values(values) + if normalized_values: + normalized[dim_key] = normalized_values + return normalized + + +def _build_dimension_pareto_items( + df: pd.DataFrame, + *, + dim_col: str, + metric_mode: str, + pareto_scope: str, +) -> List[Dict[str, Any]]: + if df is None or df.empty: + return [] + if dim_col not in df.columns: + return [] + + metric_col = "DEFECT_QTY" if metric_mode == "defect" else "REJECT_TOTAL_QTY" + if metric_col not in df.columns: + return [] + + agg_dict = {} + for col in ["MOVEIN_QTY", "REJECT_TOTAL_QTY", "DEFECT_QTY"]: + if col in df.columns: + agg_dict[col] = (col, "sum") + + grouped = df.groupby(dim_col, sort=False).agg(**agg_dict).reset_index() + if grouped.empty: + return [] + + if "CONTAINERID" in df.columns: + lot_counts = ( + df.groupby(dim_col)["CONTAINERID"] + .nunique() + .reset_index() + .rename(columns={"CONTAINERID": "AFFECTED_LOT_COUNT"}) + ) + grouped = grouped.merge(lot_counts, on=dim_col, how="left") + else: + grouped["AFFECTED_LOT_COUNT"] = 0 + + grouped["METRIC_VALUE"] = grouped[metric_col].fillna(0) + grouped = grouped[grouped["METRIC_VALUE"] > 0].sort_values( + "METRIC_VALUE", ascending=False + ) + if grouped.empty: + return [] + + total_metric = grouped["METRIC_VALUE"].sum() + grouped["PCT"] = (grouped["METRIC_VALUE"] / total_metric * 100).round(4) + grouped["CUM_PCT"] = grouped["PCT"].cumsum().round(4) + + items: List[Dict[str, Any]] = [] + for _, row in grouped.iterrows(): + items.append({ + "reason": _normalize_text(row.get(dim_col)) or "(未知)", + "metric_value": _as_float(row.get("METRIC_VALUE")), + "MOVEIN_QTY": _as_int(row.get("MOVEIN_QTY")), + "REJECT_TOTAL_QTY": _as_int(row.get("REJECT_TOTAL_QTY")), + "DEFECT_QTY": _as_int(row.get("DEFECT_QTY")), + "count": _as_int(row.get("AFFECTED_LOT_COUNT")), + "pct": round(_as_float(row.get("PCT")), 4), + "cumPct": round(_as_float(row.get("CUM_PCT")), 4), + }) + + if pareto_scope == "top80" and items: + top_items = [item for item in items if _as_float(item.get("cumPct")) <= 80.0] + if not top_items: + top_items = [items[0]] + return top_items + return items + + +def _apply_cross_filter( + df: pd.DataFrame, + selections: Dict[str, List[str]], + exclude_dim: str, +) -> pd.DataFrame: + if df is None or df.empty or not selections: + return df + + filtered = df + for dim in _PARETO_DIMENSIONS: + if dim == exclude_dim: + continue + selected_values = selections.get(dim) + if not selected_values: + continue + dim_col = _DIM_TO_DF_COLUMN.get(dim) + if not dim_col: + raise ValueError(f"不支援的 pareto_dimension: {dim}") + if dim_col not in filtered.columns: + return filtered.iloc[0:0] + value_set = set(selected_values) + normalized_dimension_values = filtered[dim_col].map( + lambda value: _normalize_text(value) or "(未知)" + ) + filtered = filtered[normalized_dimension_values.isin(value_set)] + if filtered.empty: + return filtered + return filtered def compute_dimension_pareto( @@ -786,6 +947,14 @@ def compute_dimension_pareto( exclude_pb_diode: bool = True, ) -> Optional[Dict[str, Any]]: """Compute dimension pareto from cached DataFrame (no Oracle query).""" + metric_mode = _normalize_metric_mode(metric_mode) + pareto_scope = _normalize_pareto_scope(pareto_scope) + dimension = _normalize_text(dimension).lower() or "reason" + if dimension not in _DIM_TO_DF_COLUMN: + raise ValueError( + f"Invalid dimension, supported: {', '.join(sorted(_DIM_TO_DF_COLUMN.keys()))}" + ) + df = _get_cached_df(query_id) if df is None: return None @@ -800,7 +969,7 @@ def compute_dimension_pareto( if df is None or df.empty: return {"items": [], "dimension": dimension, "metric_mode": metric_mode} - dim_col = _DIM_TO_DF_COLUMN.get(dimension, "LOSSREASONNAME") + dim_col = _DIM_TO_DF_COLUMN.get(dimension) if dim_col not in df.columns: return {"items": [], "dimension": dimension, "metric_mode": metric_mode} @@ -823,72 +992,103 @@ def compute_dimension_pareto( if filtered.empty: return {"items": [], "dimension": dimension, "metric_mode": metric_mode} - # Determine metric column - if metric_mode == "defect": - metric_col = "DEFECT_QTY" - else: - metric_col = "REJECT_TOTAL_QTY" - - if metric_col not in filtered.columns: - return {"items": [], "dimension": dimension, "metric_mode": metric_mode} - - # Group by dimension - agg_dict = {} - for col in ["MOVEIN_QTY", "REJECT_TOTAL_QTY", "DEFECT_QTY"]: - if col in filtered.columns: - agg_dict[col] = (col, "sum") - - grouped = filtered.groupby(dim_col, sort=False).agg(**agg_dict).reset_index() - - # Count distinct lots - if "CONTAINERID" in filtered.columns: - lot_counts = ( - filtered.groupby(dim_col)["CONTAINERID"] - .nunique() - .reset_index() - .rename(columns={"CONTAINERID": "AFFECTED_LOT_COUNT"}) - ) - grouped = grouped.merge(lot_counts, on=dim_col, how="left") - else: - grouped["AFFECTED_LOT_COUNT"] = 0 - - # Compute metric and sort - grouped["METRIC_VALUE"] = grouped[metric_col].fillna(0) - grouped = grouped[grouped["METRIC_VALUE"] > 0].sort_values( - "METRIC_VALUE", ascending=False - ) - if grouped.empty: - return {"items": [], "dimension": dimension, "metric_mode": metric_mode} - - total_metric = grouped["METRIC_VALUE"].sum() - grouped["PCT"] = (grouped["METRIC_VALUE"] / total_metric * 100).round(4) - grouped["CUM_PCT"] = grouped["PCT"].cumsum().round(4) - - all_items = [] - for _, row in grouped.iterrows(): - all_items.append({ - "reason": _normalize_text(row.get(dim_col)) or "(未知)", - "metric_value": _as_float(row.get("METRIC_VALUE")), - "MOVEIN_QTY": _as_int(row.get("MOVEIN_QTY")), - "REJECT_TOTAL_QTY": _as_int(row.get("REJECT_TOTAL_QTY")), - "DEFECT_QTY": _as_int(row.get("DEFECT_QTY")), - "count": _as_int(row.get("AFFECTED_LOT_COUNT")), - "pct": round(_as_float(row.get("PCT")), 4), - "cumPct": round(_as_float(row.get("CUM_PCT")), 4), - }) - - items = list(all_items) - if pareto_scope == "top80" and items: - top_items = [item for item in items if _as_float(item.get("cumPct")) <= 80.0] - if not top_items: - top_items = [items[0]] - items = top_items - - return { - "items": items, - "dimension": dimension, - "metric_mode": metric_mode, - } + items = _build_dimension_pareto_items( + filtered, + dim_col=dim_col, + metric_mode=metric_mode, + pareto_scope=pareto_scope, + ) + + return { + "items": items, + "dimension": dimension, + "metric_mode": metric_mode, + } + + +def compute_batch_pareto( + *, + query_id: str, + metric_mode: str = "reject_total", + pareto_scope: str = "top80", + pareto_display_scope: str = "all", + packages: Optional[List[str]] = None, + workcenter_groups: Optional[List[str]] = None, + reason: Optional[str] = None, + trend_dates: Optional[List[str]] = None, + pareto_selections: Optional[Dict[str, List[str]]] = None, + include_excluded_scrap: bool = False, + exclude_material_scrap: bool = True, + exclude_pb_diode: bool = True, +) -> Optional[Dict[str, Any]]: + """Compute all six Pareto dimensions from cached DataFrame (no Oracle query).""" + metric_mode = _normalize_metric_mode(metric_mode) + pareto_scope = _normalize_pareto_scope(pareto_scope) + pareto_display_scope = _normalize_pareto_display_scope(pareto_display_scope) + normalized_selections = _normalize_pareto_selections(pareto_selections) + + df = _get_cached_df(query_id) + if df is None: + return None + + df = _apply_policy_filters( + df, + include_excluded_scrap=include_excluded_scrap, + exclude_material_scrap=exclude_material_scrap, + exclude_pb_diode=exclude_pb_diode, + ) + if df is None or df.empty: + return { + "dimensions": { + dim: {"items": [], "dimension": dim, "metric_mode": metric_mode} + for dim in _PARETO_DIMENSIONS + } + } + + filtered = _apply_supplementary_filters( + df, + packages=packages, + workcenter_groups=workcenter_groups, + reason=reason, + ) + if filtered is None or filtered.empty: + return { + "dimensions": { + dim: {"items": [], "dimension": dim, "metric_mode": metric_mode} + for dim in _PARETO_DIMENSIONS + } + } + + if trend_dates and "TXN_DAY" in filtered.columns: + date_set = set(trend_dates) + filtered = filtered[ + filtered["TXN_DAY"].apply(lambda d: _to_date_str(d) in date_set) + ] + + dimensions: Dict[str, Dict[str, Any]] = {} + for dim in _PARETO_DIMENSIONS: + dim_col = _DIM_TO_DF_COLUMN.get(dim) + dim_df = _apply_cross_filter(filtered, normalized_selections, exclude_dim=dim) + items = _build_dimension_pareto_items( + dim_df, + dim_col=dim_col, + metric_mode=metric_mode, + pareto_scope=pareto_scope, + ) + if pareto_display_scope == "top20" and dim in _PARETO_TOP20_DIMENSIONS: + items = items[:20] + dimensions[dim] = { + "items": items, + "dimension": dim, + "metric_mode": metric_mode, + } + + return { + "dimensions": dimensions, + "metric_mode": metric_mode, + "pareto_scope": pareto_scope, + "pareto_display_scope": pareto_display_scope, + } # ============================================================ @@ -907,6 +1107,7 @@ def export_csv_from_cache( detail_reason: Optional[str] = None, pareto_dimension: Optional[str] = None, pareto_values: Optional[List[str]] = None, + pareto_selections: Optional[Dict[str, List[str]]] = None, include_excluded_scrap: bool = False, exclude_material_scrap: bool = True, exclude_pb_diode: bool = True, @@ -944,6 +1145,7 @@ def export_csv_from_cache( filtered, pareto_dimension=pareto_dimension, pareto_values=pareto_values, + pareto_selections=pareto_selections, ) rows = [] diff --git a/tests/test_reject_dataset_cache.py b/tests/test_reject_dataset_cache.py index 68d7a31..f0fdf14 100644 --- a/tests/test_reject_dataset_cache.py +++ b/tests/test_reject_dataset_cache.py @@ -186,3 +186,109 @@ def test_apply_view_rejects_invalid_pareto_dimension(monkeypatch): pareto_dimension="invalid-dimension", pareto_values=["X"], ) + + +def test_compute_batch_pareto_applies_cross_filter_exclude_self(monkeypatch): + df = pd.DataFrame( + [ + { + "CONTAINERID": "C1", + "TXN_DAY": pd.Timestamp("2026-02-01"), + "LOSSREASONNAME": "R-A", + "PRODUCTLINENAME": "PKG-1", + "PJ_TYPE": "TYPE-1", + "WORKFLOWNAME": "WF-1", + "WORKCENTER_GROUP": "WB-1", + "PRIMARY_EQUIPMENTNAME": "EQ-1", + "SCRAP_OBJECTTYPE": "LOT", + "LOSSREASON_CODE": "001_A", + "MOVEIN_QTY": 100, + "REJECT_TOTAL_QTY": 100, + "DEFECT_QTY": 0, + }, + { + "CONTAINERID": "C2", + "TXN_DAY": pd.Timestamp("2026-02-01"), + "LOSSREASONNAME": "R-A", + "PRODUCTLINENAME": "PKG-2", + "PJ_TYPE": "TYPE-2", + "WORKFLOWNAME": "WF-2", + "WORKCENTER_GROUP": "WB-2", + "PRIMARY_EQUIPMENTNAME": "EQ-2", + "SCRAP_OBJECTTYPE": "LOT", + "LOSSREASON_CODE": "001_A", + "MOVEIN_QTY": 100, + "REJECT_TOTAL_QTY": 50, + "DEFECT_QTY": 0, + }, + { + "CONTAINERID": "C3", + "TXN_DAY": pd.Timestamp("2026-02-01"), + "LOSSREASONNAME": "R-B", + "PRODUCTLINENAME": "PKG-1", + "PJ_TYPE": "TYPE-2", + "WORKFLOWNAME": "WF-2", + "WORKCENTER_GROUP": "WB-1", + "PRIMARY_EQUIPMENTNAME": "EQ-1", + "SCRAP_OBJECTTYPE": "LOT", + "LOSSREASON_CODE": "002_B", + "MOVEIN_QTY": 100, + "REJECT_TOTAL_QTY": 40, + "DEFECT_QTY": 0, + }, + { + "CONTAINERID": "C4", + "TXN_DAY": pd.Timestamp("2026-02-01"), + "LOSSREASONNAME": "R-B", + "PRODUCTLINENAME": "PKG-3", + "PJ_TYPE": "TYPE-3", + "WORKFLOWNAME": "WF-3", + "WORKCENTER_GROUP": "WB-3", + "PRIMARY_EQUIPMENTNAME": "EQ-3", + "SCRAP_OBJECTTYPE": "LOT", + "LOSSREASON_CODE": "002_B", + "MOVEIN_QTY": 100, + "REJECT_TOTAL_QTY": 30, + "DEFECT_QTY": 0, + }, + ] + ) + monkeypatch.setattr(cache_svc, "_get_cached_df", lambda _query_id: df) + monkeypatch.setattr( + "mes_dashboard.services.scrap_reason_exclusion_cache.get_excluded_reasons", + lambda: [], + ) + + result = cache_svc.compute_batch_pareto( + query_id="qid-batch-1", + metric_mode="reject_total", + pareto_scope="all", + include_excluded_scrap=True, + pareto_selections={ + "reason": ["R-A"], + "type": ["TYPE-2"], + }, + ) + + reason_items = result["dimensions"]["reason"]["items"] + type_items = result["dimensions"]["type"]["items"] + package_items = result["dimensions"]["package"]["items"] + + assert {item["reason"] for item in reason_items} == {"R-A", "R-B"} + assert {item["reason"] for item in type_items} == {"TYPE-1", "TYPE-2"} + assert [item["reason"] for item in package_items] == ["PKG-2"] + + +def test_apply_pareto_selection_filter_supports_multi_dimension_and_logic(): + df = _build_detail_filter_df() + + filtered = cache_svc._apply_pareto_selection_filter( + df, + pareto_selections={ + "reason": ["001_A"], + "type": ["TYPE-B"], + }, + ) + + assert len(filtered) == 1 + assert set(filtered["CONTAINERNAME"].tolist()) == {"LOT-002"} diff --git a/tests/test_reject_history_routes.py b/tests/test_reject_history_routes.py index ca154aa..9a38b71 100644 --- a/tests/test_reject_history_routes.py +++ b/tests/test_reject_history_routes.py @@ -256,6 +256,54 @@ class TestRejectHistoryApiRoutes(TestRejectHistoryRoutesBase): self.assertIs(kwargs['exclude_pb_diode'], False) mock_sql_pareto.assert_not_called() + @patch('mes_dashboard.routes.reject_history_routes.compute_batch_pareto') + def test_batch_pareto_passes_multi_dimension_selection_params(self, mock_batch_pareto): + mock_batch_pareto.return_value = { + 'dimensions': { + 'reason': {'items': []}, + 'package': {'items': []}, + 'type': {'items': []}, + 'workflow': {'items': []}, + 'workcenter': {'items': []}, + 'equipment': {'items': []}, + } + } + + response = self.client.get( + '/api/reject-history/batch-pareto' + '?query_id=qid-001' + '&metric_mode=reject_total' + '&pareto_scope=all' + '&pareto_display_scope=top20' + '&sel_reason=001_A' + '&sel_type=TYPE-A' + '&sel_type=TYPE-B' + '&include_excluded_scrap=true' + '&exclude_material_scrap=false' + '&exclude_pb_diode=false' + ) + payload = json.loads(response.data) + + self.assertEqual(response.status_code, 200) + self.assertTrue(payload['success']) + _, kwargs = mock_batch_pareto.call_args + self.assertEqual(kwargs['query_id'], 'qid-001') + self.assertEqual(kwargs['pareto_display_scope'], 'top20') + self.assertEqual(kwargs['pareto_scope'], 'all') + self.assertEqual(kwargs['pareto_selections'], {'reason': ['001_A'], 'type': ['TYPE-A', 'TYPE-B']}) + self.assertIs(kwargs['include_excluded_scrap'], True) + self.assertIs(kwargs['exclude_material_scrap'], False) + self.assertIs(kwargs['exclude_pb_diode'], False) + + @patch('mes_dashboard.routes.reject_history_routes.compute_batch_pareto', return_value=None) + def test_batch_pareto_cache_miss_returns_400(self, _mock_batch_pareto): + response = self.client.get('/api/reject-history/batch-pareto?query_id=missing-qid') + payload = json.loads(response.data) + + self.assertEqual(response.status_code, 400) + self.assertFalse(payload['success']) + self.assertEqual(payload['error'], 'cache_miss') + @patch('mes_dashboard.routes.reject_history_routes.apply_view') def test_view_passes_pareto_multi_select_filters(self, mock_apply_view): mock_apply_view.return_value = { @@ -296,6 +344,58 @@ class TestRejectHistoryApiRoutes(TestRejectHistoryRoutesBase): self.assertFalse(payload['success']) mock_apply_view.assert_not_called() + @patch('mes_dashboard.routes.reject_history_routes.apply_view') + def test_view_passes_multi_dimension_selection_filters(self, mock_apply_view): + mock_apply_view.return_value = { + 'analytics_raw': [], + 'summary': {}, + 'detail': {'items': [], 'pagination': {'page': 1, 'perPage': 50, 'total': 0, 'totalPages': 1}}, + } + + response = self.client.get( + '/api/reject-history/view' + '?query_id=qid-001' + '&sel_reason=001_A' + '&sel_type=TYPE-A' + '&sel_workflow=WF-01' + ) + payload = json.loads(response.data) + + self.assertEqual(response.status_code, 200) + self.assertTrue(payload['success']) + _, kwargs = mock_apply_view.call_args + self.assertEqual(kwargs['pareto_selections'], { + 'reason': ['001_A'], + 'type': ['TYPE-A'], + 'workflow': ['WF-01'], + }) + self.assertIsNone(kwargs['pareto_dimension']) + self.assertIsNone(kwargs['pareto_values']) + + @patch('mes_dashboard.routes.reject_history_routes.apply_view') + def test_view_sel_filters_take_precedence_over_legacy_dimension(self, mock_apply_view): + mock_apply_view.return_value = { + 'analytics_raw': [], + 'summary': {}, + 'detail': {'items': [], 'pagination': {'page': 1, 'perPage': 50, 'total': 0, 'totalPages': 1}}, + } + + response = self.client.get( + '/api/reject-history/view' + '?query_id=qid-001' + '&sel_reason=001_A' + '&pareto_dimension=invalid' + '&pareto_values=bad' + ) + payload = json.loads(response.data) + + self.assertEqual(response.status_code, 200) + self.assertTrue(payload['success']) + _, kwargs = mock_apply_view.call_args + self.assertEqual(kwargs['pareto_selections'], {'reason': ['001_A']}) + self.assertIsNone(kwargs['pareto_dimension']) + self.assertIsNone(kwargs['pareto_values']) + @patch('mes_dashboard.routes.reject_history_routes._list_to_csv') @patch('mes_dashboard.routes.reject_history_routes.export_csv_from_cache') def test_export_cached_passes_pareto_multi_select_filters( @@ -333,6 +433,34 @@ class TestRejectHistoryApiRoutes(TestRejectHistoryRoutesBase): self.assertFalse(payload['success']) mock_export_cached.assert_not_called() + @patch('mes_dashboard.routes.reject_history_routes._list_to_csv') + @patch('mes_dashboard.routes.reject_history_routes.export_csv_from_cache') + def test_export_cached_passes_multi_dimension_selection_filters( + self, + mock_export_cached, + mock_list_to_csv, + ): + mock_export_cached.return_value = [{'LOT': 'LOT-001'}] + mock_list_to_csv.return_value = iter(['A,B\n', '1,2\n']) + + response = self.client.get( + '/api/reject-history/export-cached' + '?query_id=qid-001' + '&sel_reason=001_A' + '&sel_type=TYPE-A' + '&sel_equipment=EQ-01' + ) + + self.assertEqual(response.status_code, 200) + _, kwargs = mock_export_cached.call_args + self.assertEqual(kwargs['pareto_selections'], { + 'reason': ['001_A'], + 'type': ['TYPE-A'], + 'equipment': ['EQ-01'], + }) + self.assertIsNone(kwargs['pareto_dimension']) + self.assertIsNone(kwargs['pareto_values']) + @patch('mes_dashboard.routes.reject_history_routes.query_list') @patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 6)) def test_list_rate_limited_returns_429(self, _mock_limit, mock_list):