feat(reject-history): multi-pareto 3×2 grid with cross-filter linkage

Replace single-dimension Pareto dropdown with 6 simultaneous Pareto charts
(不良原因, PACKAGE, TYPE, WORKFLOW, 站點, 機台) in a responsive 3-column grid.
Clicking items in one Pareto cross-filters the other 5 (exclude-self logic),
and the detail table applies all dimension selections with AND logic.

Backend:
- Add batch-pareto endpoint (cache-only, no Oracle queries)
- Add _apply_cross_filter() with exclude-self pattern
- Extend view/export endpoints for multi-dimension sel_* params

Frontend:
- New ParetoGrid.vue wrapping 6 ParetoSection instances
- Simplify ParetoSection: remove dimension dropdown, keep TOP20 toggle
- Replace single-dimension state with paretoSelections reactive object
- Adaptive x-axis labels (font size, rotation, hideOverlap) for compact grid
- Responsive grid: 3-col desktop, 2-col tablet, 1-col mobile

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
egg
2026-03-02 14:43:35 +08:00
parent e83d8e1a36
commit 2568fd836c
20 changed files with 1558 additions and 534 deletions

View File

@@ -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;

View File

@@ -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"
/>
<ParetoSection
:items="activeParetoItems"
:selected-values="selectedParetoValues"
<ParetoGrid
:pareto-data="paretoData"
:pareto-selections="paretoSelections"
:display-scope="paretoDisplayScope"
:selected-dates="selectedTrendDates"
:metric-label="paretoMetricLabel"
:loading="loading.querying || dimensionParetoLoading"
:dimension="paretoDimension"
:show-dimension-selector="committedPrimary.mode === 'date_range'"
:loading="loading.querying || loading.pareto"
@item-toggle="onParetoItemToggle"
@dimension-change="onDimensionChange"
@display-scope-change="onParetoDisplayScopeChange"
/>
<DetailTable
:items="detail.items"
:pagination="pagination"
:loading="loading.list"
:selected-pareto-values="selectedParetoValues"
:selected-pareto-dimension-label="selectedParetoDimensionLabel"
:selected-pareto-count="selectedParetoCount"
:selected-pareto-summary="selectedParetoSummary"
@go-to-page="goToPage"
@clear-pareto-selection="clearParetoSelection"
/>

View File

@@ -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) {
<div class="card-header">
<div class="card-title">
明細列表
<span v-if="selectedParetoValues.length > 0" class="detail-reason-badge">
{{ selectedParetoDimensionLabel || 'Pareto 篩選' }}:
{{
selectedParetoValues.length === 1
? selectedParetoValues[0]
: `${selectedParetoValues.length} `
}}
<span v-if="selectedParetoCount > 0" class="detail-reason-badge">
Pareto 篩選: {{ selectedParetoSummary || `${selectedParetoCount} ` }}
<button type="button" class="badge-clear" @click="$emit('clear-pareto-selection')">×</button>
</span>
</div>

View File

@@ -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%
</label>
<label class="filter-label">顯示範圍</label>
<select
class="dimension-select pareto-scope-select"
:value="paretoDisplayScope"
@change="$emit('pareto-display-scope-change', $event.target.value)"
>
<option value="all">全部顯示</option>
<option value="top20">只顯示 TOP 20</option>
</select>
</div>
<div class="supplementary-row">
<div class="filter-group">

View File

@@ -0,0 +1,45 @@
<script setup>
import ParetoSection from './ParetoSection.vue';
const DIMENSIONS = [
{ key: 'reason', label: '不良原因' },
{ key: 'package', label: 'PACKAGE' },
{ key: 'type', label: 'TYPE' },
{ key: 'workflow', label: 'WORKFLOW' },
{ key: 'workcenter', label: '站點' },
{ key: 'equipment', label: '機台' },
];
const props = defineProps({
paretoData: { type: Object, default: () => ({}) },
paretoSelections: { type: Object, default: () => ({}) },
loading: { type: Boolean, default: false },
metricLabel: { type: String, default: '報廢量' },
selectedDates: { type: Array, default: () => [] },
displayScope: { type: String, default: 'all' },
});
const emit = defineEmits(['item-toggle']);
function onItemToggle(dimension, value) {
emit('item-toggle', dimension, value);
}
</script>
<template>
<div class="pareto-grid">
<ParetoSection
v-for="dim in DIMENSIONS"
:key="dim.key"
:dimension="dim.key"
:dimension-label="dim.label"
:items="paretoData[dim.key]?.items || []"
:selected-values="paretoSelections[dim.key] || []"
:selected-dates="selectedDates"
:metric-label="metricLabel"
:display-scope="displayScope"
:loading="loading"
@item-toggle="onItemToggle(dim.key, $event)"
/>
</div>
</template>

View File

@@ -1,22 +1,14 @@
<script setup>
import { computed } from 'vue';
import { BarChart, LineChart } from 'echarts/charts';
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import VChart from 'vue-echarts';
use([CanvasRenderer, BarChart, LineChart, GridComponent, TooltipComponent, LegendComponent]);
const DIMENSION_OPTIONS = [
{ value: 'reason', label: '不良原因' },
{ value: 'package', label: 'PACKAGE' },
{ value: 'type', label: 'TYPE' },
{ value: 'workflow', label: 'WORKFLOW' },
{ value: 'workcenter', label: '站點' },
{ value: 'equipment', label: '機台' },
];
import { BarChart, LineChart } from 'echarts/charts';
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import VChart from 'vue-echarts';
use([CanvasRenderer, BarChart, LineChart, GridComponent, TooltipComponent, LegendComponent]);
const DISPLAY_SCOPE_TOP20_DIMENSIONS = new Set(['type', 'workflow', 'equipment']);
const props = defineProps({
@@ -26,90 +18,101 @@ const props = defineProps({
metricLabel: { type: String, default: '報廢量' },
loading: { type: Boolean, default: false },
dimension: { type: String, default: 'reason' },
showDimensionSelector: { type: Boolean, default: false },
dimensionLabel: { type: String, default: 'Pareto' },
displayScope: { type: String, default: 'all' },
});
const emit = defineEmits(['item-toggle', 'dimension-change', 'display-scope-change']);
const emit = defineEmits(['item-toggle']);
const hasData = computed(() => Array.isArray(props.items) && props.items.length > 0);
const selectedValueSet = computed(() => new Set((props.selectedValues || []).map((item) => String(item || '').trim())));
const showDisplayScopeSelector = computed(
() => DISPLAY_SCOPE_TOP20_DIMENSIONS.has(props.dimension),
);
const selectedValueSet = computed(
() => new Set((props.selectedValues || []).map((item) => String(item || '').trim()),
));
const dimensionLabel = computed(() => {
const opt = DIMENSION_OPTIONS.find((o) => o.value === props.dimension);
return opt ? opt.label : '報廢原因';
const displayItems = computed(() => {
const items = Array.isArray(props.items) ? props.items : [];
if (
props.displayScope === 'top20'
&& DISPLAY_SCOPE_TOP20_DIMENSIONS.has(props.dimension)
) {
return items.slice(0, 20);
}
return items;
});
const hasData = computed(() => displayItems.value.length > 0);
const showTop20Badge = computed(
() => props.displayScope === 'top20' && DISPLAY_SCOPE_TOP20_DIMENSIONS.has(props.dimension),
);
function isSelected(value) {
return selectedValueSet.value.has(String(value || '').trim());
}
function formatNumber(value) {
return Number(value || 0).toLocaleString('zh-TW');
}
function formatPct(value) {
return `${Number(value || 0).toFixed(2)}%`;
}
const chartOption = computed(() => {
const items = props.items || [];
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
formatter(params) {
const idx = Number(params?.[0]?.dataIndex || 0);
const item = items[idx] || {};
return [
`<b>${item.reason || '(未填寫)'}</b>`,
`${props.metricLabel}: ${formatNumber(item.metric_value || 0)}`,
`占比: ${Number(item.pct || 0).toFixed(2)}%`,
`累計: ${Number(item.cumPct || 0).toFixed(2)}%`,
].join('<br/>');
},
},
legend: {
data: [props.metricLabel, '累積%'],
bottom: 0,
},
grid: {
left: 52,
right: 52,
top: 20,
bottom: 96,
},
xAxis: {
type: 'category',
data: items.map((item) => item.reason || '(未填寫)'),
axisLabel: {
interval: 0,
rotate: items.length > 6 ? 35 : 0,
fontSize: 11,
overflow: 'truncate',
width: 100,
},
},
yAxis: [
{
type: 'value',
name: '',
},
{
type: 'value',
name: '%',
min: 0,
max: 100,
axisLabel: { formatter: '{value}%' },
},
],
series: [
{
name: props.metricLabel,
type: 'bar',
function formatNumber(value) {
return Number(value || 0).toLocaleString('zh-TW');
}
function formatPct(value) {
return `${Number(value || 0).toFixed(2)}%`;
}
const chartOption = computed(() => {
const items = displayItems.value;
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
formatter(params) {
const idx = Number(params?.[0]?.dataIndex || 0);
const item = items[idx] || {};
return [
`<b>${item.reason || '(未填寫)'}</b>`,
`${props.metricLabel}: ${formatNumber(item.metric_value || 0)}`,
`占比: ${Number(item.pct || 0).toFixed(2)}%`,
`累計: ${Number(item.cumPct || 0).toFixed(2)}%`,
].join('<br/>');
},
},
legend: {
data: [props.metricLabel, '累積%'],
bottom: 0,
},
grid: {
left: 52,
right: 52,
top: 20,
bottom: items.length > 10 ? 110 : 96,
},
xAxis: {
type: 'category',
data: items.map((item) => item.reason || '(未填寫)'),
axisLabel: {
interval: 0,
rotate: items.length > 10 ? 55 : items.length > 5 ? 35 : 0,
fontSize: items.length > 15 ? 9 : items.length > 8 ? 10 : 11,
overflow: 'truncate',
width: items.length > 10 ? 60 : 80,
hideOverlap: true,
},
},
yAxis: [
{
type: 'value',
name: '量',
},
{
type: 'value',
name: '%',
min: 0,
max: 100,
axisLabel: { formatter: '{value}%' },
},
],
series: [
{
name: props.metricLabel,
type: 'bar',
data: items.map((item) => Number(item.metric_value || 0)),
barMaxWidth: 34,
itemStyle: {
@@ -120,24 +123,24 @@ const chartOption = computed(() => {
borderRadius: [4, 4, 0, 0],
},
},
{
name: '累積%',
type: 'line',
yAxisIndex: 1,
data: items.map((item) => Number(item.cumPct || 0)),
lineStyle: { color: '#f59e0b', width: 2 },
itemStyle: { color: '#f59e0b' },
symbolSize: 6,
},
],
};
});
{
name: '累積%',
type: 'line',
yAxisIndex: 1,
data: items.map((item) => Number(item.cumPct || 0)),
lineStyle: { color: '#f59e0b', width: 2 },
itemStyle: { color: '#f59e0b' },
symbolSize: 6,
},
],
};
});
function handleChartClick(params) {
if (params?.seriesType !== 'bar') {
return;
}
const itemValue = props.items?.[params.dataIndex]?.reason;
const itemValue = displayItems.value?.[params.dataIndex]?.reason;
if (itemValue) {
emit('item-toggle', itemValue);
}
@@ -145,50 +148,32 @@ function handleChartClick(params) {
</script>
<template>
<section class="card">
<div class="card-header pareto-header">
<div class="card-title">
{{ metricLabel }} vs {{ dimensionLabel }}Pareto
<span v-for="d in selectedDates" :key="d" class="pareto-date-badge">{{ d }}</span>
</div>
<div class="pareto-controls">
<select
v-if="showDimensionSelector"
class="dimension-select"
:value="dimension"
@change="emit('dimension-change', $event.target.value)"
>
<option v-for="opt in DIMENSION_OPTIONS" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</select>
<select
v-if="showDisplayScopeSelector"
class="dimension-select pareto-scope-select"
:value="displayScope"
@change="emit('display-scope-change', $event.target.value)"
>
<option value="all">全部顯示</option>
<option value="top20">只顯示 TOP 20</option>
</select>
<section class="card">
<div class="card-header pareto-header">
<div class="card-title">
{{ metricLabel }} vs {{ dimensionLabel }}Pareto
<span v-if="showTop20Badge" class="pareto-date-badge">TOP 20</span>
<span v-for="d in selectedDates" :key="d" class="pareto-date-badge">{{ d }}</span>
</div>
</div>
<div class="card-body pareto-layout">
<div class="pareto-chart-wrap">
<VChart :option="chartOption" autoresize @click="handleChartClick" />
<div v-if="!hasData && !loading" class="placeholder chart-empty">No data</div>
</div>
<div class="pareto-table-wrap">
<table class="detail-table pareto-table">
<thead>
<tr>
<th>{{ dimensionLabel }}</th>
<th>{{ metricLabel }}</th>
<th>占比</th>
<th>累積</th>
</tr>
</thead>
<tbody>
<div v-if="!hasData && !loading" class="placeholder chart-empty">No data</div>
</div>
<div class="pareto-table-wrap">
<table class="detail-table pareto-table">
<thead>
<tr>
<th>{{ dimensionLabel }}</th>
<th>{{ metricLabel }}</th>
<th>占比</th>
<th>累積</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in items"
v-for="item in displayItems"
:key="item.reason"
:class="{ active: isSelected(item.reason) }"
>
@@ -199,14 +184,14 @@ function handleChartClick(params) {
</td>
<td>{{ formatNumber(item.metric_value) }}</td>
<td>{{ formatPct(item.pct) }}</td>
<td>{{ formatPct(item.cumPct) }}</td>
</tr>
<tr v-if="!items || items.length === 0">
<td colspan="4" class="placeholder">No data</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</template>
<td>{{ formatPct(item.cumPct) }}</td>
</tr>
<tr v-if="displayItems.length === 0">
<td colspan="4" class="placeholder">No data</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</template>

View File

@@ -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;
}
}

View File

@@ -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 },

View File

@@ -0,0 +1,2 @@
schema: spec-driven
created: 2026-03-02

View File

@@ -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.

View File

@@ -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` endpointcache-only不查 Oracle
- 無資料庫/SQL 變更

View File

@@ -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`

View File

@@ -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 (768px1200px)
- **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

View File

@@ -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 `<ParetoSection>` with `<ParetoGrid>`
## 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`

View File

@@ -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.

View File

@@ -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 (768px1200px)
- **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

View File

@@ -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,

View File

@@ -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 = []

View File

@@ -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"}

View File

@@ -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):