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:
@@ -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;
|
||||
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;
|
||||
}
|
||||
if (normalizedParetoValues.length > 0) {
|
||||
params.pareto_values = normalizedParetoValues;
|
||||
}
|
||||
params.page = page || 1;
|
||||
params.per_page = perPage || 50;
|
||||
|
||||
@@ -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 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 selectedParetoCount = computed(() => {
|
||||
let count = 0;
|
||||
for (const dimension of PARETO_DIMENSIONS) {
|
||||
count += (paretoSelections[dimension] || []).length;
|
||||
}
|
||||
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)),
|
||||
};
|
||||
});
|
||||
return count;
|
||||
});
|
||||
|
||||
const filteredParetoItems = computed(() => {
|
||||
const items = allParetoItems.value || [];
|
||||
if (!committedPrimary.paretoTop80 || items.length === 0) {
|
||||
return items;
|
||||
const selectedParetoSummary = computed(() => {
|
||||
const tokens = [];
|
||||
for (const dimension of PARETO_DIMENSIONS) {
|
||||
for (const value of paretoSelections[dimension] || []) {
|
||||
tokens.push(`${getDimensionLabel(dimension)}:${value}`);
|
||||
}
|
||||
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)));
|
||||
});
|
||||
|
||||
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;
|
||||
if (tokens.length <= 3) {
|
||||
return tokens.join(', ');
|
||||
}
|
||||
return `${tokens.slice(0, 3).join(', ')}... (${tokens.length} 項)`;
|
||||
});
|
||||
|
||||
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) => {
|
||||
for (const dimension of PARETO_DIMENSIONS) {
|
||||
for (const value of paretoSelections[dimension] || []) {
|
||||
chips.push({
|
||||
key: `pareto-value:${paretoDimension.value}:${value}`,
|
||||
label: `${selectedParetoDimensionLabel.value}: ${value}`,
|
||||
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();
|
||||
|
||||
// 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
|
||||
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';
|
||||
const urlParetoValues = readArrayParam(params, 'pareto_values');
|
||||
const urlParetoDisplayScope = String(params.get('pareto_display_scope') || '').trim().toLowerCase();
|
||||
const parsedPage = Number(params.get('page') || '1');
|
||||
restoredSelections[fallbackDimension] = legacyValues;
|
||||
}
|
||||
|
||||
paretoDimension.value = urlParetoDimension;
|
||||
selectedParetoValues.value = urlParetoValues;
|
||||
for (const dimension of PARETO_DIMENSIONS) {
|
||||
paretoSelections[dimension] = restoredSelections[dimension];
|
||||
}
|
||||
|
||||
const urlParetoDisplayScope = String(params.get('pareto_display_scope') || '').trim().toLowerCase();
|
||||
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"
|
||||
/>
|
||||
|
||||
@@ -8,8 +8,8 @@ defineProps({
|
||||
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>
|
||||
|
||||
@@ -12,6 +12,7 @@ const props = defineProps({
|
||||
resolutionInfo: { type: Object, default: null },
|
||||
loading: { type: Object, required: true },
|
||||
activeFilterChips: { type: Array, default: () => [] },
|
||||
paretoDisplayScope: { type: String, default: 'all' },
|
||||
});
|
||||
|
||||
const emit = defineEmits([
|
||||
@@ -20,6 +21,7 @@ const emit = defineEmits([
|
||||
'export-csv',
|
||||
'remove-chip',
|
||||
'pareto-scope-toggle',
|
||||
'pareto-display-scope-change',
|
||||
'update:queryMode',
|
||||
'update:containerInputType',
|
||||
'update:containerInput',
|
||||
@@ -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">
|
||||
|
||||
45
frontend/src/reject-history/components/ParetoGrid.vue
Normal file
45
frontend/src/reject-history/components/ParetoGrid.vue
Normal 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>
|
||||
@@ -9,14 +9,6 @@ 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: '機台' },
|
||||
];
|
||||
const DISPLAY_SCOPE_TOP20_DIMENSIONS = new Set(['type', 'workflow', 'equipment']);
|
||||
|
||||
const props = defineProps({
|
||||
@@ -26,23 +18,33 @@ 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());
|
||||
}
|
||||
@@ -56,7 +58,7 @@ function formatPct(value) {
|
||||
}
|
||||
|
||||
const chartOption = computed(() => {
|
||||
const items = props.items || [];
|
||||
const items = displayItems.value;
|
||||
return {
|
||||
tooltip: {
|
||||
trigger: 'axis',
|
||||
@@ -80,17 +82,18 @@ const chartOption = computed(() => {
|
||||
left: 52,
|
||||
right: 52,
|
||||
top: 20,
|
||||
bottom: 96,
|
||||
bottom: items.length > 10 ? 110 : 96,
|
||||
},
|
||||
xAxis: {
|
||||
type: 'category',
|
||||
data: items.map((item) => item.reason || '(未填寫)'),
|
||||
axisLabel: {
|
||||
interval: 0,
|
||||
rotate: items.length > 6 ? 35 : 0,
|
||||
fontSize: 11,
|
||||
rotate: items.length > 10 ? 55 : items.length > 5 ? 35 : 0,
|
||||
fontSize: items.length > 15 ? 9 : items.length > 8 ? 10 : 11,
|
||||
overflow: 'truncate',
|
||||
width: 100,
|
||||
width: items.length > 10 ? 60 : 80,
|
||||
hideOverlap: true,
|
||||
},
|
||||
},
|
||||
yAxis: [
|
||||
@@ -137,7 +140,7 @@ 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);
|
||||
}
|
||||
@@ -149,27 +152,9 @@ function handleChartClick(params) {
|
||||
<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 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>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body pareto-layout">
|
||||
<div class="pareto-chart-wrap">
|
||||
@@ -188,7 +173,7 @@ function handleChartClick(params) {
|
||||
</thead>
|
||||
<tbody>
|
||||
<tr
|
||||
v-for="item in items"
|
||||
v-for="item in displayItems"
|
||||
:key="item.reason"
|
||||
:class="{ active: isSelected(item.reason) }"
|
||||
>
|
||||
@@ -201,7 +186,7 @@ function handleChartClick(params) {
|
||||
<td>{{ formatPct(item.pct) }}</td>
|
||||
<td>{{ formatPct(item.cumPct) }}</td>
|
||||
</tr>
|
||||
<tr v-if="!items || items.length === 0">
|
||||
<tr v-if="displayItems.length === 0">
|
||||
<td colspan="4" class="placeholder">No data</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 },
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-03-02
|
||||
@@ -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.
|
||||
@@ -0,0 +1,43 @@
|
||||
## Why
|
||||
|
||||
目前報廢歷史的柏拉圖一次只顯示一個維度,需透過下拉選單切換,無法同時看到跨維度的分佈與交叉關係。改為同時顯示 6 個柏拉圖(3×2 grid),並支援跨圖即時聯動篩選——點擊任一柏拉圖的項目後,其餘 5 個柏拉圖即時重新計算(排除自身維度的篩選),下方明細表則套用所有維度的篩選結果。
|
||||
|
||||
## What Changes
|
||||
|
||||
### 前端
|
||||
- 移除維度切換下拉選單(`ParetoSection.vue` 的 dimension selector)
|
||||
- 新增 `ParetoGrid.vue` 元件,以 3 欄 grid 同時渲染 6 個獨立柏拉圖(不良原因、PACKAGE、TYPE、WORKFLOW、站點、機台)
|
||||
- 每個柏拉圖支援多選(現有行為),點擊後即時聯動:
|
||||
- 其他 5 個柏拉圖重新計算(套用來自其他維度的選取,但不套用自身維度的選取)
|
||||
- 明細表套用所有 6 個維度的選取結果
|
||||
- 選取狀態以 chip 顯示在明細表上方,按維度分組
|
||||
|
||||
### 後端
|
||||
- 新增批次柏拉圖 API endpoint(`GET /api/reject-history/batch-pareto`),對快取中的 Pandas DataFrame 進行重算,一次回傳 6 個維度的柏拉圖資料(不重查 Oracle 資料庫)
|
||||
- 每個維度的計算套用「排除自身」的交叉篩選邏輯:計算 Reason Pareto 時套用其他 5 維度的選取,但不套用 Reason 自身的選取
|
||||
- 移除前端 client-side 的 reason Pareto 計算(統一由後端從快取計算)
|
||||
|
||||
### 移除
|
||||
- 移除維度切換選單和 `onDimensionChange` 邏輯
|
||||
- 移除現有的單維度 `fetchDimensionPareto` 流程
|
||||
|
||||
### 保留
|
||||
- 保留 TOP20/全部顯示切換功能(TYPE、WORKFLOW、機台維度在 80% 過濾後仍可能有大量項目,TOP20 截斷對使用者仍有價值)
|
||||
|
||||
## Capabilities
|
||||
|
||||
### New Capabilities
|
||||
|
||||
(none)
|
||||
|
||||
### Modified Capabilities
|
||||
|
||||
- `reject-history-api`: 新增批次柏拉圖 endpoint,支援跨維度交叉篩選
|
||||
- `reject-history-page`: 柏拉圖從單維度切換改為 6 圖同時顯示 + 即時聯動
|
||||
|
||||
## Impact
|
||||
|
||||
- 前端:`App.vue`(狀態管理重構)、`ParetoSection.vue`(改為純展示元件)、新增 `ParetoGrid.vue`
|
||||
- 後端:`reject_dataset_cache.py`(新增批次計算,資料來源為快取的 Pandas DataFrame)、`reject_history_routes.py`(新增 endpoint)
|
||||
- API:新增 `GET /api/reject-history/batch-pareto` endpoint(cache-only,不查 Oracle)
|
||||
- 無資料庫/SQL 變更
|
||||
@@ -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`
|
||||
@@ -0,0 +1,92 @@
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Reject History page SHALL provide reason Pareto analysis
|
||||
The page SHALL display 6 Pareto charts simultaneously (不良原因, PACKAGE, TYPE, WORKFLOW, 站點, 機台) in a 3-column grid layout with cross-filter linkage, replacing the single-dimension dropdown switcher.
|
||||
|
||||
#### Scenario: Multi-Pareto grid layout
|
||||
- **WHEN** Pareto data is loaded
|
||||
- **THEN** 6 Pareto charts SHALL be rendered simultaneously in a 3-column grid (3×2)
|
||||
- **THEN** each chart SHALL display one dimension: 不良原因, PACKAGE, TYPE, WORKFLOW, 站點, 機台
|
||||
- **THEN** there SHALL be no dimension dropdown selector
|
||||
|
||||
#### Scenario: Pareto rendering and ordering
|
||||
- **WHEN** Pareto data is loaded
|
||||
- **THEN** items in each Pareto SHALL be sorted by selected metric descending
|
||||
- **THEN** each Pareto SHALL show a cumulative percentage line
|
||||
|
||||
#### Scenario: Pareto 80% filter is managed in supplementary filters
|
||||
- **WHEN** the page first loads Pareto
|
||||
- **THEN** supplementary filters SHALL include "Pareto 僅顯示累計前 80%" control
|
||||
- **THEN** the control SHALL default to enabled
|
||||
- **THEN** the 80% filter SHALL apply uniformly to all 6 Pareto charts
|
||||
|
||||
#### Scenario: Cross-filter linkage between Pareto charts
|
||||
- **WHEN** user clicks an item in one Pareto chart (e.g., selects reason "A")
|
||||
- **THEN** the other 5 Pareto charts SHALL recalculate with the selection applied as a filter
|
||||
- **THEN** the clicked Pareto chart itself SHALL NOT be filtered by its own selections
|
||||
- **THEN** the detail table below SHALL apply ALL selections from ALL dimensions
|
||||
|
||||
#### Scenario: Pareto click filtering supports multi-select
|
||||
- **WHEN** user clicks Pareto bars or table rows in any dimension
|
||||
- **THEN** clicked items SHALL become active selected chips
|
||||
- **THEN** multiple selected items SHALL be supported within each dimension
|
||||
- **THEN** multiple dimensions SHALL support simultaneous selections
|
||||
|
||||
#### Scenario: Re-click clears selected item only
|
||||
- **WHEN** user clicks an already selected Pareto item
|
||||
- **THEN** only that item SHALL be removed from selection
|
||||
- **THEN** remaining selected items across all dimensions SHALL stay active
|
||||
- **THEN** all Pareto charts SHALL recalculate to reflect the updated selections
|
||||
|
||||
#### Scenario: Filter chips display all dimension selections
|
||||
- **WHEN** items are selected across multiple Pareto dimensions
|
||||
- **THEN** selected items SHALL be displayed as chips grouped by dimension label
|
||||
- **THEN** each chip SHALL show the dimension label and selected value (e.g., "TYPE: X")
|
||||
- **THEN** clicking a chip's remove button SHALL deselect that item and trigger recalculation
|
||||
|
||||
#### Scenario: Responsive grid layout
|
||||
- **WHEN** viewport is desktop width (>1200px)
|
||||
- **THEN** Pareto charts SHALL render in a 3-column grid
|
||||
- **WHEN** viewport is medium width (768px–1200px)
|
||||
- **THEN** Pareto charts SHALL render in a 2-column grid
|
||||
- **WHEN** viewport is below 768px
|
||||
- **THEN** Pareto charts SHALL stack in a single column
|
||||
|
||||
#### Scenario: TOP20/ALL display scope control
|
||||
- **WHEN** Pareto grid is displayed
|
||||
- **THEN** supplementary filters SHALL include a global "只顯示 TOP 20" toggle
|
||||
- **THEN** when enabled, applicable dimensions (TYPE, WORKFLOW, 機台) SHALL truncate to top 20 items
|
||||
- **THEN** the toggle SHALL apply uniformly to all applicable Pareto charts (not per-chart selectors)
|
||||
- **THEN** dimensions not in the applicable set (不良原因, PACKAGE, 站點) SHALL be unaffected by this toggle
|
||||
|
||||
## MODIFIED Requirements
|
||||
|
||||
### Requirement: Reject History page SHALL be structured as modular sub-components
|
||||
The page template SHALL delegate sections to focused sub-components, following the hold-history architecture pattern.
|
||||
|
||||
#### Scenario: Component decomposition
|
||||
- **WHEN** the page source is examined
|
||||
- **THEN** the filter panel SHALL be a separate `FilterPanel.vue` component
|
||||
- **THEN** the KPI summary cards SHALL be a separate `SummaryCards.vue` component
|
||||
- **THEN** the trend chart SHALL be a separate `TrendChart.vue` component
|
||||
- **THEN** the pareto grid SHALL be a separate `ParetoGrid.vue` component containing 6 `ParetoSection.vue` instances
|
||||
- **THEN** each individual pareto chart+table SHALL be a `ParetoSection.vue` component
|
||||
- **THEN** the detail table with pagination SHALL be a separate `DetailTable.vue` component
|
||||
|
||||
#### Scenario: App.vue acts as orchestrator
|
||||
- **WHEN** the page runs
|
||||
- **THEN** `App.vue` SHALL hold all reactive state and API logic
|
||||
- **THEN** sub-components SHALL receive data via props and communicate via events
|
||||
|
||||
### Requirement: Reject History page SHALL support CSV export from current filter context
|
||||
The page SHALL allow users to export records using the exact active filters.
|
||||
|
||||
#### Scenario: Export with all active filters
|
||||
- **WHEN** user clicks "匯出 CSV"
|
||||
- **THEN** export request SHALL include current primary filters, supplementary filters, trend-date filters, metric filters, and all Pareto-selected items from all 6 dimensions
|
||||
- **THEN** downloaded file SHALL contain exactly the same rows currently represented by the detail list filter context
|
||||
|
||||
#### Scenario: Export remains UTF-8 Excel compatible
|
||||
- **WHEN** CSV export is downloaded
|
||||
- **THEN** the file SHALL be encoded in UTF-8 with BOM
|
||||
- **THEN** Chinese headers and values SHALL render correctly in common spreadsheet tools
|
||||
@@ -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`
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -74,33 +74,63 @@ The page SHALL show both quantity trend and rate trend to avoid mixing unit scal
|
||||
- **THEN** rate values SHALL be displayed as percentages
|
||||
|
||||
### Requirement: Reject History page SHALL provide reason Pareto analysis
|
||||
The page SHALL provide a Pareto view for loss reasons and support downstream filtering.
|
||||
The page SHALL display 6 Pareto charts simultaneously (不良原因, PACKAGE, TYPE, WORKFLOW, 站點, 機台) in a 3-column grid layout with cross-filter linkage, replacing the single-dimension dropdown switcher.
|
||||
|
||||
#### Scenario: Multi-Pareto grid layout
|
||||
- **WHEN** Pareto data is loaded
|
||||
- **THEN** 6 Pareto charts SHALL be rendered simultaneously in a 3-column grid (3×2)
|
||||
- **THEN** each chart SHALL display one dimension: 不良原因, PACKAGE, TYPE, WORKFLOW, 站點, 機台
|
||||
- **THEN** there SHALL be no dimension dropdown selector
|
||||
|
||||
#### Scenario: Pareto rendering and ordering
|
||||
- **WHEN** Pareto data is loaded
|
||||
- **THEN** items SHALL be sorted by selected metric descending
|
||||
- **THEN** a cumulative percentage line SHALL be shown
|
||||
- **THEN** items in each Pareto SHALL be sorted by selected metric descending
|
||||
- **THEN** each Pareto SHALL show a cumulative percentage line
|
||||
|
||||
#### Scenario: Pareto 80% filter is managed in supplementary filters
|
||||
- **WHEN** the page first loads Pareto
|
||||
- **THEN** supplementary filters SHALL include "Pareto 僅顯示累計前 80%" control
|
||||
- **THEN** the control SHALL default to enabled
|
||||
- **THEN** the 80% filter SHALL apply uniformly to all 6 Pareto charts
|
||||
|
||||
#### Scenario: TYPE/WORKFLOW/機台 support display scope selector
|
||||
- **WHEN** Pareto dimension is `TYPE`, `WORKFLOW`, or `機台`
|
||||
- **THEN** the UI SHALL provide `全部顯示` and `只顯示 TOP 20` options
|
||||
- **THEN** `全部顯示` SHALL still respect the current 80% cumulative filter setting
|
||||
#### Scenario: Cross-filter linkage between Pareto charts
|
||||
- **WHEN** user clicks an item in one Pareto chart (e.g., selects reason "A")
|
||||
- **THEN** the other 5 Pareto charts SHALL recalculate with the selection applied as a filter
|
||||
- **THEN** the clicked Pareto chart itself SHALL NOT be filtered by its own selections
|
||||
- **THEN** the detail table below SHALL apply ALL selections from ALL dimensions
|
||||
|
||||
#### Scenario: Pareto click filtering supports multi-select
|
||||
- **WHEN** user clicks Pareto bars or table rows
|
||||
- **WHEN** user clicks Pareto bars or table rows in any dimension
|
||||
- **THEN** clicked items SHALL become active selected chips
|
||||
- **THEN** multiple selected items SHALL be supported at the same time
|
||||
- **THEN** detail list SHALL reload using current selected Pareto items as additional filter criteria
|
||||
- **THEN** multiple selected items SHALL be supported within each dimension
|
||||
- **THEN** multiple dimensions SHALL support simultaneous selections
|
||||
|
||||
#### Scenario: Re-click clears selected item only
|
||||
- **WHEN** user clicks an already selected Pareto item
|
||||
- **THEN** only that item SHALL be removed from selection
|
||||
- **THEN** remaining selected items SHALL stay active
|
||||
- **THEN** remaining selected items across all dimensions SHALL stay active
|
||||
- **THEN** all Pareto charts SHALL recalculate to reflect the updated selections
|
||||
|
||||
#### Scenario: Filter chips display all dimension selections
|
||||
- **WHEN** items are selected across multiple Pareto dimensions
|
||||
- **THEN** selected items SHALL be displayed as chips grouped by dimension label
|
||||
- **THEN** each chip SHALL show the dimension label and selected value (e.g., "TYPE: X")
|
||||
- **THEN** clicking a chip's remove button SHALL deselect that item and trigger recalculation
|
||||
|
||||
#### Scenario: Responsive grid layout
|
||||
- **WHEN** viewport is desktop width (>1200px)
|
||||
- **THEN** Pareto charts SHALL render in a 3-column grid
|
||||
- **WHEN** viewport is medium width (768px–1200px)
|
||||
- **THEN** Pareto charts SHALL render in a 2-column grid
|
||||
- **WHEN** viewport is below 768px
|
||||
- **THEN** Pareto charts SHALL stack in a single column
|
||||
|
||||
#### Scenario: TOP20/ALL display scope control
|
||||
- **WHEN** Pareto grid is displayed
|
||||
- **THEN** supplementary filters SHALL include a global "只顯示 TOP 20" toggle
|
||||
- **THEN** when enabled, applicable dimensions (TYPE, WORKFLOW, 機台) SHALL truncate to top 20 items
|
||||
- **THEN** the toggle SHALL apply uniformly to all applicable Pareto charts (not per-chart selectors)
|
||||
- **THEN** dimensions not in the applicable set (不良原因, PACKAGE, 站點) SHALL be unaffected by this toggle
|
||||
|
||||
### Requirement: Reject History page SHALL show paginated detail rows
|
||||
The page SHALL provide a paginated detail table for investigation and traceability.
|
||||
@@ -119,7 +149,7 @@ The page SHALL allow users to export records using the exact active filters.
|
||||
|
||||
#### Scenario: Export with all active filters
|
||||
- **WHEN** user clicks "匯出 CSV"
|
||||
- **THEN** export request SHALL include current primary filters, supplementary filters, trend-date filters, metric filters, and Pareto-selected items
|
||||
- **THEN** export request SHALL include current primary filters, supplementary filters, trend-date filters, metric filters, and all Pareto-selected items from all 6 dimensions
|
||||
- **THEN** downloaded file SHALL contain exactly the same rows currently represented by the detail list filter context
|
||||
|
||||
#### Scenario: Export remains UTF-8 Excel compatible
|
||||
@@ -197,7 +227,8 @@ The page template SHALL delegate sections to focused sub-components, following t
|
||||
- **THEN** the filter panel SHALL be a separate `FilterPanel.vue` component
|
||||
- **THEN** the KPI summary cards SHALL be a separate `SummaryCards.vue` component
|
||||
- **THEN** the trend chart SHALL be a separate `TrendChart.vue` component
|
||||
- **THEN** the pareto section (chart + table) SHALL be a separate `ParetoSection.vue` component
|
||||
- **THEN** the pareto grid SHALL be a separate `ParetoGrid.vue` component containing 6 `ParetoSection.vue` instances
|
||||
- **THEN** each individual pareto chart+table SHALL be a `ParetoSection.vue` component
|
||||
- **THEN** the detail table with pagination SHALL be a separate `DetailTable.vue` component
|
||||
|
||||
#### Scenario: App.vue acts as orchestrator
|
||||
|
||||
@@ -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,6 +601,10 @@ 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_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]
|
||||
@@ -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,6 +655,10 @@ 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_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]
|
||||
@@ -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,
|
||||
|
||||
@@ -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,66 +992,12 @@ 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"})
|
||||
items = _build_dimension_pareto_items(
|
||||
filtered,
|
||||
dim_col=dim_col,
|
||||
metric_mode=metric_mode,
|
||||
pareto_scope=pareto_scope,
|
||||
)
|
||||
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,
|
||||
@@ -891,6 +1006,91 @@ def compute_dimension_pareto(
|
||||
}
|
||||
|
||||
|
||||
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,
|
||||
}
|
||||
|
||||
|
||||
# ============================================================
|
||||
# CSV export from cache
|
||||
# ============================================================
|
||||
@@ -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 = []
|
||||
|
||||
@@ -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"}
|
||||
|
||||
@@ -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):
|
||||
|
||||
Reference in New Issue
Block a user