From e83d8e1a3627593b871dc34dd14dba80a8fd1cbb Mon Sep 17 00:00:00 2001 From: egg Date: Mon, 2 Mar 2026 13:23:16 +0800 Subject: [PATCH] feat(reject-history): fix Pareto datasources, multi-select filtering, and UX enhancements MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Fix dimension Pareto datasources: PJ_TYPE/PRODUCTLINENAME from DW_MES_CONTAINER, WORKFLOWNAME from DW_MES_LOTWIPHISTORY via WIPTRACKINGGROUPKEYID, EQUIPMENTNAME from LOTREJECTHISTORY only (no WIP fallback), workcenter dimension uses WORKCENTER_GROUP - Add multi-select Pareto click filtering with chip display and detail list integration - Add TOP 20 display scope selector for TYPE/WORKFLOW/機台 dimensions - Pass Pareto selection (dimension + values) through to list/export endpoints - Enable TRACE_WORKER_ENABLED=true by default in start_server.sh and .env.example - Archive reject-history-pareto-datasource-fix and reject-history-pareto-ux-enhancements - Update reject-history-api and reject-history-page specs with new Pareto behaviors Co-Authored-By: Claude Opus 4.6 --- .env.example | 2 +- frontend/src/core/reject-history-filters.js | 10 + frontend/src/reject-history/App.vue | 125 ++++++++--- .../reject-history/components/DetailTable.vue | 38 ++-- .../reject-history/components/FilterPanel.vue | 40 ++-- .../components/ParetoSection.vue | 172 +++++++------- frontend/src/reject-history/style.css | 16 +- .../.openspec.yaml | 2 + .../design.md | 59 +++++ .../proposal.md | 28 +++ .../specs/reject-history-api/spec.md | 42 ++++ .../tasks.md | 22 ++ .../.openspec.yaml | 2 + .../README.md | 3 + .../design.md | 73 ++++++ .../proposal.md | 26 +++ .../specs/reject-history-api/spec.md | 19 ++ .../spec.md | 18 ++ .../specs/reject-history-page/spec.md | 43 ++++ .../tasks.md | 19 ++ openspec/specs/reject-history-api/spec.md | 10 + .../spec.md | 22 ++ openspec/specs/reject-history-page/spec.md | 42 ++-- scripts/start_server.sh | 2 +- .../routes/reject_history_routes.py | 37 +++ .../services/reject_dataset_cache.py | 211 ++++++++++++------ src/mes_dashboard/sql/reject_history/list.sql | 1 + .../sql/reject_history/performance_daily.sql | 46 ++-- .../reject_history/performance_daily_lot.sql | 104 +++++---- tests/test_reject_dataset_cache.py | 188 ++++++++++++++++ tests/test_reject_history_routes.py | 115 ++++++++++ 31 files changed, 1251 insertions(+), 286 deletions(-) create mode 100644 openspec/changes/archive/2026-03-02-reject-history-pareto-datasource-fix/.openspec.yaml create mode 100644 openspec/changes/archive/2026-03-02-reject-history-pareto-datasource-fix/design.md create mode 100644 openspec/changes/archive/2026-03-02-reject-history-pareto-datasource-fix/proposal.md create mode 100644 openspec/changes/archive/2026-03-02-reject-history-pareto-datasource-fix/specs/reject-history-api/spec.md create mode 100644 openspec/changes/archive/2026-03-02-reject-history-pareto-datasource-fix/tasks.md create mode 100644 openspec/changes/archive/2026-03-02-reject-history-pareto-ux-enhancements/.openspec.yaml create mode 100644 openspec/changes/archive/2026-03-02-reject-history-pareto-ux-enhancements/README.md create mode 100644 openspec/changes/archive/2026-03-02-reject-history-pareto-ux-enhancements/design.md create mode 100644 openspec/changes/archive/2026-03-02-reject-history-pareto-ux-enhancements/proposal.md create mode 100644 openspec/changes/archive/2026-03-02-reject-history-pareto-ux-enhancements/specs/reject-history-api/spec.md create mode 100644 openspec/changes/archive/2026-03-02-reject-history-pareto-ux-enhancements/specs/reject-history-detail-export-parity/spec.md create mode 100644 openspec/changes/archive/2026-03-02-reject-history-pareto-ux-enhancements/specs/reject-history-page/spec.md create mode 100644 openspec/changes/archive/2026-03-02-reject-history-pareto-ux-enhancements/tasks.md create mode 100644 openspec/specs/reject-history-detail-export-parity/spec.md create mode 100644 tests/test_reject_dataset_cache.py diff --git a/.env.example b/.env.example index 295dc43..876d203 100644 --- a/.env.example +++ b/.env.example @@ -197,7 +197,7 @@ EVENT_FETCHER_CACHE_SKIP_CID_THRESHOLD=10000 # --- Async Job Queue (提案 2: trace-async-job-queue) --- # Enable RQ trace worker for async large query processing # Set to true and start the worker: rq worker trace-events -TRACE_WORKER_ENABLED=false +TRACE_WORKER_ENABLED=true # CID threshold for automatic async job routing (requires RQ worker). # Requests with CID count > threshold are queued instead of processed synchronously. diff --git a/frontend/src/core/reject-history-filters.js b/frontend/src/core/reject-history-filters.js index fcfc45b..daa1ccd 100644 --- a/frontend/src/core/reject-history-filters.js +++ b/frontend/src/core/reject-history-filters.js @@ -173,6 +173,8 @@ export function buildViewParams(queryId, { metricFilter = 'all', trendDates = [], detailReason = '', + paretoDimension = '', + paretoValues = [], page = 1, perPage = 50, policyFilters = {}, @@ -196,6 +198,14 @@ 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; + } 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 1747479..5a5e8b7 100644 --- a/frontend/src/reject-history/App.vue +++ b/frontend/src/reject-history/App.vue @@ -5,7 +5,6 @@ import { apiGet, apiPost } from '../core/api.js'; import { buildViewParams, parseMultiLineInput, - toRejectFilterSnapshot, } from '../core/reject-history-filters.js'; import { replaceRuntimeHistory } from '../core/shell-navigation.js'; @@ -17,6 +16,15 @@ 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: '機台', +}; // ---- Primary query form state ---- const queryMode = ref('date_range'); @@ -59,10 +67,11 @@ const supplementaryFilters = reactive({ // ---- Interactive state ---- const page = ref(1); -const detailReason = ref(''); 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); @@ -198,8 +207,9 @@ async function executePrimaryQuery() { supplementaryFilters.workcenterGroups = []; supplementaryFilters.reason = ''; page.value = 1; - detailReason.value = ''; selectedTrendDates.value = []; + selectedParetoValues.value = []; + paretoDisplayScope.value = 'all'; paretoDimension.value = 'reason'; dimensionParetoItems.value = []; @@ -239,7 +249,8 @@ async function refreshView() { supplementaryFilters, metricFilter: metricFilterParam(), trendDates: selectedTrendDates.value, - detailReason: detailReason.value, + paretoDimension: paretoDimension.value, + paretoValues: selectedParetoValues.value, page: page.value, perPage: DEFAULT_PER_PAGE, policyFilters: { @@ -330,10 +341,18 @@ function onTrendLegendChange(selected) { refreshDimensionParetoIfActive(); } -function onParetoClick(reason) { - if (!reason) return; - detailReason.value = detailReason.value === reason ? '' : reason; +function onParetoItemToggle(itemValue) { + const normalized = String(itemValue || '').trim(); + if (!normalized) return; + if (selectedParetoValues.value.includes(normalized)) { + selectedParetoValues.value = selectedParetoValues.value.filter( + (item) => item !== normalized, + ); + } else { + selectedParetoValues.value = [...selectedParetoValues.value, normalized]; + } page.value = 1; + updateUrlState(); void refreshView(); } @@ -341,6 +360,7 @@ function handleParetoScopeToggle(checked) { draftFilters.paretoTop80 = Boolean(checked); committedPrimary.paretoTop80 = Boolean(checked); updateUrlState(); + refreshDimensionParetoIfActive(); } let activeDimRequestId = 0; @@ -391,20 +411,37 @@ function refreshDimensionParetoIfActive() { 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(); } } +function onParetoDisplayScopeChange(scope) { + paretoDisplayScope.value = scope === 'top20' ? 'top20' : 'all'; + updateUrlState(); +} + +function clearParetoSelection() { + selectedParetoValues.value = []; + page.value = 1; + updateUrlState(); + void refreshView(); +} + function onSupplementaryChange(filters) { supplementaryFilters.packages = filters.packages || []; supplementaryFilters.workcenterGroups = filters.workcenterGroups || []; supplementaryFilters.reason = filters.reason || ''; page.value = 1; - detailReason.value = ''; selectedTrendDates.value = []; + selectedParetoValues.value = []; void refreshView(); refreshDimensionParetoIfActive(); } @@ -412,9 +449,12 @@ function onSupplementaryChange(filters) { function removeFilterChip(chip) { if (!chip?.removable) return; - if (chip.type === 'detail-reason') { - detailReason.value = ''; + if (chip.type === 'pareto-value') { + selectedParetoValues.value = selectedParetoValues.value.filter( + (value) => value !== chip.value, + ); page.value = 1; + updateUrlState(); void refreshView(); return; } @@ -471,7 +511,8 @@ 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); - if (detailReason.value) params.set('detail_reason', detailReason.value); + params.set('pareto_dimension', paretoDimension.value); + for (const value of selectedParetoValues.value) params.append('pareto_values', value); // Policy filters (applied in-memory on cached data) if (committedPrimary.includeExcludedScrap) params.set('include_excluded_scrap', 'true'); @@ -642,10 +683,24 @@ const filteredParetoItems = computed(() => { }); const activeParetoItems = computed(() => { - if (paretoDimension.value !== 'reason') return dimensionParetoItems.value; - return filteredParetoItems.value; + 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 = []; @@ -742,15 +797,15 @@ const activeFilterChips = computed(() => { }); } - if (detailReason.value) { + selectedParetoValues.value.forEach((value) => { chips.push({ - key: `detail-reason:${detailReason.value}`, - label: `明細原因: ${detailReason.value}`, + key: `pareto-value:${paretoDimension.value}:${value}`, + label: `${selectedParetoDimensionLabel.value}: ${value}`, removable: true, - type: 'detail-reason', - value: detailReason.value, + type: 'pareto-value', + value, }); - } + }); return chips; }); @@ -809,9 +864,9 @@ function updateUrlState() { // Interactive appendArrayParams(params, 'trend_dates', selectedTrendDates.value); - if (detailReason.value) { - params.set('detail_reason', detailReason.value); - } + params.set('pareto_dimension', paretoDimension.value); + appendArrayParams(params, 'pareto_values', selectedParetoValues.value); + if (paretoDisplayScope.value !== 'all') params.set('pareto_display_scope', paretoDisplayScope.value); if (!committedPrimary.paretoTop80) { params.set('pareto_scope_all', 'true'); } @@ -886,15 +941,26 @@ function restoreFromUrl() { // Interactive const urlTrendDates = readArrayParam(params, 'trend_dates'); - const urlDetailReason = String(params.get('detail_reason') || '').trim(); + 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, - detailReason: urlDetailReason, + paretoDimension: urlParetoDimension, + paretoValues: urlParetoValues, + paretoDisplayScope: paretoDisplayScope.value, page: Number.isFinite(parsedPage) && parsedPage > 0 ? parsedPage : 1, }; } @@ -962,23 +1028,26 @@ onMounted(() => { diff --git a/frontend/src/reject-history/components/DetailTable.vue b/frontend/src/reject-history/components/DetailTable.vue index 09ac112..44ce996 100644 --- a/frontend/src/reject-history/components/DetailTable.vue +++ b/frontend/src/reject-history/components/DetailTable.vue @@ -1,17 +1,18 @@ - -