feat(reject-history): fix Pareto datasources, multi-select filtering, and UX enhancements

- Fix dimension Pareto datasources: PJ_TYPE/PRODUCTLINENAME from DW_MES_CONTAINER,
  WORKFLOWNAME from DW_MES_LOTWIPHISTORY via WIPTRACKINGGROUPKEYID, EQUIPMENTNAME
  from LOTREJECTHISTORY only (no WIP fallback), workcenter dimension uses WORKCENTER_GROUP
- Add multi-select Pareto click filtering with chip display and detail list integration
- Add TOP 20 display scope selector for TYPE/WORKFLOW/機台 dimensions
- Pass Pareto selection (dimension + values) through to list/export endpoints
- Enable TRACE_WORKER_ENABLED=true by default in start_server.sh and .env.example
- Archive reject-history-pareto-datasource-fix and reject-history-pareto-ux-enhancements
- Update reject-history-api and reject-history-page specs with new Pareto behaviors

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
egg
2026-03-02 13:23:16 +08:00
parent ff37768a15
commit e83d8e1a36
31 changed files with 1251 additions and 286 deletions

View File

@@ -197,7 +197,7 @@ EVENT_FETCHER_CACHE_SKIP_CID_THRESHOLD=10000
# --- Async Job Queue (提案 2: trace-async-job-queue) --- # --- Async Job Queue (提案 2: trace-async-job-queue) ---
# Enable RQ trace worker for async large query processing # Enable RQ trace worker for async large query processing
# Set to true and start the worker: rq worker trace-events # Set to true and start the worker: rq worker trace-events
TRACE_WORKER_ENABLED=false TRACE_WORKER_ENABLED=true
# CID threshold for automatic async job routing (requires RQ worker). # CID threshold for automatic async job routing (requires RQ worker).
# Requests with CID count > threshold are queued instead of processed synchronously. # Requests with CID count > threshold are queued instead of processed synchronously.

View File

@@ -173,6 +173,8 @@ export function buildViewParams(queryId, {
metricFilter = 'all', metricFilter = 'all',
trendDates = [], trendDates = [],
detailReason = '', detailReason = '',
paretoDimension = '',
paretoValues = [],
page = 1, page = 1,
perPage = 50, perPage = 50,
policyFilters = {}, policyFilters = {},
@@ -196,6 +198,14 @@ export function buildViewParams(queryId, {
if (detailReason) { if (detailReason) {
params.detail_reason = detailReason; params.detail_reason = detailReason;
} }
const normalizedParetoDimension = normalizeText(paretoDimension).toLowerCase();
const normalizedParetoValues = normalizeArray(paretoValues);
if (normalizedParetoDimension) {
params.pareto_dimension = normalizedParetoDimension;
}
if (normalizedParetoValues.length > 0) {
params.pareto_values = normalizedParetoValues;
}
params.page = page || 1; params.page = page || 1;
params.per_page = perPage || 50; params.per_page = perPage || 50;

View File

@@ -5,7 +5,6 @@ import { apiGet, apiPost } from '../core/api.js';
import { import {
buildViewParams, buildViewParams,
parseMultiLineInput, parseMultiLineInput,
toRejectFilterSnapshot,
} from '../core/reject-history-filters.js'; } from '../core/reject-history-filters.js';
import { replaceRuntimeHistory } from '../core/shell-navigation.js'; import { replaceRuntimeHistory } from '../core/shell-navigation.js';
@@ -17,6 +16,15 @@ import TrendChart from './components/TrendChart.vue';
const API_TIMEOUT = 360000; const API_TIMEOUT = 360000;
const DEFAULT_PER_PAGE = 50; const DEFAULT_PER_PAGE = 50;
const PARETO_TOP20_DIMENSIONS = new Set(['type', 'workflow', 'equipment']);
const PARETO_DIMENSION_LABELS = {
reason: '不良原因',
package: 'PACKAGE',
type: 'TYPE',
workflow: 'WORKFLOW',
workcenter: '站點',
equipment: '機台',
};
// ---- Primary query form state ---- // ---- Primary query form state ----
const queryMode = ref('date_range'); const queryMode = ref('date_range');
@@ -59,10 +67,11 @@ const supplementaryFilters = reactive({
// ---- Interactive state ---- // ---- Interactive state ----
const page = ref(1); const page = ref(1);
const detailReason = ref('');
const selectedTrendDates = ref([]); const selectedTrendDates = ref([]);
const trendLegendSelected = ref({ '扣帳報廢量': true, '不扣帳報廢量': true }); const trendLegendSelected = ref({ '扣帳報廢量': true, '不扣帳報廢量': true });
const paretoDimension = ref('reason'); const paretoDimension = ref('reason');
const selectedParetoValues = ref([]);
const paretoDisplayScope = ref('all');
const dimensionParetoItems = ref([]); const dimensionParetoItems = ref([]);
const dimensionParetoLoading = ref(false); const dimensionParetoLoading = ref(false);
@@ -198,8 +207,9 @@ async function executePrimaryQuery() {
supplementaryFilters.workcenterGroups = []; supplementaryFilters.workcenterGroups = [];
supplementaryFilters.reason = ''; supplementaryFilters.reason = '';
page.value = 1; page.value = 1;
detailReason.value = '';
selectedTrendDates.value = []; selectedTrendDates.value = [];
selectedParetoValues.value = [];
paretoDisplayScope.value = 'all';
paretoDimension.value = 'reason'; paretoDimension.value = 'reason';
dimensionParetoItems.value = []; dimensionParetoItems.value = [];
@@ -239,7 +249,8 @@ async function refreshView() {
supplementaryFilters, supplementaryFilters,
metricFilter: metricFilterParam(), metricFilter: metricFilterParam(),
trendDates: selectedTrendDates.value, trendDates: selectedTrendDates.value,
detailReason: detailReason.value, paretoDimension: paretoDimension.value,
paretoValues: selectedParetoValues.value,
page: page.value, page: page.value,
perPage: DEFAULT_PER_PAGE, perPage: DEFAULT_PER_PAGE,
policyFilters: { policyFilters: {
@@ -330,10 +341,18 @@ function onTrendLegendChange(selected) {
refreshDimensionParetoIfActive(); refreshDimensionParetoIfActive();
} }
function onParetoClick(reason) { function onParetoItemToggle(itemValue) {
if (!reason) return; const normalized = String(itemValue || '').trim();
detailReason.value = detailReason.value === reason ? '' : reason; if (!normalized) return;
if (selectedParetoValues.value.includes(normalized)) {
selectedParetoValues.value = selectedParetoValues.value.filter(
(item) => item !== normalized,
);
} else {
selectedParetoValues.value = [...selectedParetoValues.value, normalized];
}
page.value = 1; page.value = 1;
updateUrlState();
void refreshView(); void refreshView();
} }
@@ -341,6 +360,7 @@ function handleParetoScopeToggle(checked) {
draftFilters.paretoTop80 = Boolean(checked); draftFilters.paretoTop80 = Boolean(checked);
committedPrimary.paretoTop80 = Boolean(checked); committedPrimary.paretoTop80 = Boolean(checked);
updateUrlState(); updateUrlState();
refreshDimensionParetoIfActive();
} }
let activeDimRequestId = 0; let activeDimRequestId = 0;
@@ -391,20 +411,37 @@ function refreshDimensionParetoIfActive() {
function onDimensionChange(dim) { function onDimensionChange(dim) {
paretoDimension.value = dim; paretoDimension.value = dim;
selectedParetoValues.value = [];
paretoDisplayScope.value = 'all';
page.value = 1;
if (dim === 'reason') { if (dim === 'reason') {
dimensionParetoItems.value = []; dimensionParetoItems.value = [];
void refreshView();
} else { } else {
void fetchDimensionPareto(dim); void fetchDimensionPareto(dim);
void refreshView();
} }
} }
function onParetoDisplayScopeChange(scope) {
paretoDisplayScope.value = scope === 'top20' ? 'top20' : 'all';
updateUrlState();
}
function clearParetoSelection() {
selectedParetoValues.value = [];
page.value = 1;
updateUrlState();
void refreshView();
}
function onSupplementaryChange(filters) { function onSupplementaryChange(filters) {
supplementaryFilters.packages = filters.packages || []; supplementaryFilters.packages = filters.packages || [];
supplementaryFilters.workcenterGroups = filters.workcenterGroups || []; supplementaryFilters.workcenterGroups = filters.workcenterGroups || [];
supplementaryFilters.reason = filters.reason || ''; supplementaryFilters.reason = filters.reason || '';
page.value = 1; page.value = 1;
detailReason.value = '';
selectedTrendDates.value = []; selectedTrendDates.value = [];
selectedParetoValues.value = [];
void refreshView(); void refreshView();
refreshDimensionParetoIfActive(); refreshDimensionParetoIfActive();
} }
@@ -412,9 +449,12 @@ function onSupplementaryChange(filters) {
function removeFilterChip(chip) { function removeFilterChip(chip) {
if (!chip?.removable) return; if (!chip?.removable) return;
if (chip.type === 'detail-reason') { if (chip.type === 'pareto-value') {
detailReason.value = ''; selectedParetoValues.value = selectedParetoValues.value.filter(
(value) => value !== chip.value,
);
page.value = 1; page.value = 1;
updateUrlState();
void refreshView(); void refreshView();
return; return;
} }
@@ -471,7 +511,8 @@ async function exportCsv() {
if (supplementaryFilters.reason) params.set('reason', supplementaryFilters.reason); if (supplementaryFilters.reason) params.set('reason', supplementaryFilters.reason);
params.set('metric_filter', metricFilterParam()); params.set('metric_filter', metricFilterParam());
for (const date of selectedTrendDates.value) params.append('trend_dates', date); for (const date of selectedTrendDates.value) params.append('trend_dates', date);
if (detailReason.value) params.set('detail_reason', detailReason.value); params.set('pareto_dimension', paretoDimension.value);
for (const value of selectedParetoValues.value) params.append('pareto_values', value);
// Policy filters (applied in-memory on cached data) // Policy filters (applied in-memory on cached data)
if (committedPrimary.includeExcludedScrap) params.set('include_excluded_scrap', 'true'); if (committedPrimary.includeExcludedScrap) params.set('include_excluded_scrap', 'true');
@@ -642,10 +683,24 @@ const filteredParetoItems = computed(() => {
}); });
const activeParetoItems = computed(() => { const activeParetoItems = computed(() => {
if (paretoDimension.value !== 'reason') return dimensionParetoItems.value; const baseItems =
return filteredParetoItems.value; paretoDimension.value === 'reason'
? filteredParetoItems.value
: (dimensionParetoItems.value || []);
if (
PARETO_TOP20_DIMENSIONS.has(paretoDimension.value)
&& paretoDisplayScope.value === 'top20'
) {
return baseItems.slice(0, 20);
}
return baseItems;
}); });
const selectedParetoDimensionLabel = computed(
() => PARETO_DIMENSION_LABELS[paretoDimension.value] || 'Pareto',
);
const activeFilterChips = computed(() => { const activeFilterChips = computed(() => {
const chips = []; const chips = [];
@@ -742,15 +797,15 @@ const activeFilterChips = computed(() => {
}); });
} }
if (detailReason.value) { selectedParetoValues.value.forEach((value) => {
chips.push({ chips.push({
key: `detail-reason:${detailReason.value}`, key: `pareto-value:${paretoDimension.value}:${value}`,
label: `明細原因: ${detailReason.value}`, label: `${selectedParetoDimensionLabel.value}: ${value}`,
removable: true, removable: true,
type: 'detail-reason', type: 'pareto-value',
value: detailReason.value, value,
}); });
} });
return chips; return chips;
}); });
@@ -809,9 +864,9 @@ function updateUrlState() {
// Interactive // Interactive
appendArrayParams(params, 'trend_dates', selectedTrendDates.value); appendArrayParams(params, 'trend_dates', selectedTrendDates.value);
if (detailReason.value) { params.set('pareto_dimension', paretoDimension.value);
params.set('detail_reason', detailReason.value); appendArrayParams(params, 'pareto_values', selectedParetoValues.value);
} if (paretoDisplayScope.value !== 'all') params.set('pareto_display_scope', paretoDisplayScope.value);
if (!committedPrimary.paretoTop80) { if (!committedPrimary.paretoTop80) {
params.set('pareto_scope_all', 'true'); params.set('pareto_scope_all', 'true');
} }
@@ -886,15 +941,26 @@ function restoreFromUrl() {
// Interactive // Interactive
const urlTrendDates = readArrayParam(params, 'trend_dates'); const urlTrendDates = readArrayParam(params, 'trend_dates');
const urlDetailReason = String(params.get('detail_reason') || '').trim(); const rawParetoDimension = String(params.get('pareto_dimension') || '').trim().toLowerCase();
const urlParetoDimension = Object.hasOwn(PARETO_DIMENSION_LABELS, rawParetoDimension)
? rawParetoDimension
: 'reason';
const urlParetoValues = readArrayParam(params, 'pareto_values');
const urlParetoDisplayScope = String(params.get('pareto_display_scope') || '').trim().toLowerCase();
const parsedPage = Number(params.get('page') || '1'); const parsedPage = Number(params.get('page') || '1');
paretoDimension.value = urlParetoDimension;
selectedParetoValues.value = urlParetoValues;
paretoDisplayScope.value = urlParetoDisplayScope === 'top20' ? 'top20' : 'all';
return { return {
packages: urlPackages, packages: urlPackages,
workcenterGroups: urlWcGroups, workcenterGroups: urlWcGroups,
reason: urlReason, reason: urlReason,
trendDates: urlTrendDates, trendDates: urlTrendDates,
detailReason: urlDetailReason, paretoDimension: urlParetoDimension,
paretoValues: urlParetoValues,
paretoDisplayScope: paretoDisplayScope.value,
page: Number.isFinite(parsedPage) && parsedPage > 0 ? parsedPage : 1, page: Number.isFinite(parsedPage) && parsedPage > 0 ? parsedPage : 1,
}; };
} }
@@ -962,23 +1028,26 @@ onMounted(() => {
<ParetoSection <ParetoSection
:items="activeParetoItems" :items="activeParetoItems"
:detail-reason="detailReason" :selected-values="selectedParetoValues"
:display-scope="paretoDisplayScope"
:selected-dates="selectedTrendDates" :selected-dates="selectedTrendDates"
:metric-label="paretoMetricLabel" :metric-label="paretoMetricLabel"
:loading="loading.querying || dimensionParetoLoading" :loading="loading.querying || dimensionParetoLoading"
:dimension="paretoDimension" :dimension="paretoDimension"
:show-dimension-selector="committedPrimary.mode === 'date_range'" :show-dimension-selector="committedPrimary.mode === 'date_range'"
@reason-click="onParetoClick" @item-toggle="onParetoItemToggle"
@dimension-change="onDimensionChange" @dimension-change="onDimensionChange"
@display-scope-change="onParetoDisplayScopeChange"
/> />
<DetailTable <DetailTable
:items="detail.items" :items="detail.items"
:pagination="pagination" :pagination="pagination"
:loading="loading.list" :loading="loading.list"
:detail-reason="detailReason" :selected-pareto-values="selectedParetoValues"
:selected-pareto-dimension-label="selectedParetoDimensionLabel"
@go-to-page="goToPage" @go-to-page="goToPage"
@clear-reason="onParetoClick(detailReason)" @clear-pareto-selection="clearParetoSelection"
/> />
</template> </template>
</div> </div>

View File

@@ -1,17 +1,18 @@
<script setup> <script setup>
import { ref } from 'vue'; import { ref } from 'vue';
defineProps({ defineProps({
items: { type: Array, default: () => [] }, items: { type: Array, default: () => [] },
pagination: { pagination: {
type: Object, type: Object,
default: () => ({ page: 1, perPage: 50, total: 0, totalPages: 1 }), default: () => ({ page: 1, perPage: 50, total: 0, totalPages: 1 }),
}, },
loading: { type: Boolean, default: false }, loading: { type: Boolean, default: false },
detailReason: { type: String, default: '' }, selectedParetoValues: { type: Array, default: () => [] },
}); selectedParetoDimensionLabel: { type: String, default: '' },
});
defineEmits(['go-to-page', 'clear-reason']);
defineEmits(['go-to-page', 'clear-pareto-selection']);
const showRejectBreakdown = ref(false); const showRejectBreakdown = ref(false);
@@ -22,15 +23,20 @@ function formatNumber(value) {
<template> <template>
<section class="card"> <section class="card">
<div class="card-header"> <div class="card-header">
<div class="card-title"> <div class="card-title">
明細列表 明細列表
<span v-if="detailReason" class="detail-reason-badge"> <span v-if="selectedParetoValues.length > 0" class="detail-reason-badge">
原因: {{ detailReason }} {{ selectedParetoDimensionLabel || 'Pareto 篩選' }}:
<button type="button" class="badge-clear" @click="$emit('clear-reason')">×</button> {{
</span> selectedParetoValues.length === 1
</div> ? selectedParetoValues[0]
</div> : `${selectedParetoValues.length} `
}}
<button type="button" class="badge-clear" @click="$emit('clear-pareto-selection')">×</button>
</span>
</div>
</div>
<div class="card-body detail-table-wrap" :class="{ 'is-loading': loading }"> <div class="card-body detail-table-wrap" :class="{ 'is-loading': loading }">
<div v-if="loading" class="table-loading-overlay"><span class="table-spinner"></span></div> <div v-if="loading" class="table-loading-overlay"><span class="table-spinner"></span></div>
<table class="detail-table"> <table class="detail-table">

View File

@@ -122,20 +122,12 @@ function emitSupplementary(patch) {
<input v-model="filters.excludeMaterialScrap" type="checkbox" /> <input v-model="filters.excludeMaterialScrap" type="checkbox" />
排除原物料報廢 排除原物料報廢
</label> </label>
<label class="checkbox-pill"> <label class="checkbox-pill">
<input v-model="filters.excludePbDiode" type="checkbox" /> <input v-model="filters.excludePbDiode" type="checkbox" />
排除 PB_* 系列 排除 PB_* 系列
</label> </label>
<label class="checkbox-pill"> </div>
<input <div class="filter-actions">
:checked="filters.paretoTop80"
type="checkbox"
@change="$emit('pareto-scope-toggle', $event.target.checked)"
/>
Pareto 僅顯示累計前 80%
</label>
</div>
<div class="filter-actions">
<button <button
class="btn btn-primary" class="btn btn-primary"
:disabled="loading.querying" :disabled="loading.querying"
@@ -187,11 +179,21 @@ function emitSupplementary(patch) {
</template> </template>
</div> </div>
<!-- Supplementary filters (only after primary query) --> <!-- Supplementary filters (only after primary query) -->
<div v-if="queryId" class="supplementary-panel"> <div v-if="queryId" class="supplementary-panel">
<div class="supplementary-header">補充篩選 (快取內篩選)</div> <div class="supplementary-header">補充篩選 (快取內篩選)</div>
<div class="supplementary-row"> <div class="supplementary-toolbar">
<div class="filter-group"> <label class="checkbox-pill">
<input
:checked="filters.paretoTop80"
type="checkbox"
@change="$emit('pareto-scope-toggle', $event.target.checked)"
/>
Pareto 僅顯示累計前 80%
</label>
</div>
<div class="supplementary-row">
<div class="filter-group">
<label class="filter-label">WORKCENTER GROUP</label> <label class="filter-label">WORKCENTER GROUP</label>
<MultiSelect <MultiSelect
:model-value="supplementaryFilters.workcenterGroups" :model-value="supplementaryFilters.workcenterGroups"

View File

@@ -1,5 +1,5 @@
<script setup> <script setup>
import { computed } from 'vue'; import { computed } from 'vue';
import { BarChart, LineChart } from 'echarts/charts'; import { BarChart, LineChart } from 'echarts/charts';
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components'; import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components';
@@ -9,33 +9,43 @@ import VChart from 'vue-echarts';
use([CanvasRenderer, BarChart, LineChart, GridComponent, TooltipComponent, LegendComponent]); use([CanvasRenderer, BarChart, LineChart, GridComponent, TooltipComponent, LegendComponent]);
const DIMENSION_OPTIONS = [ const DIMENSION_OPTIONS = [
{ value: 'reason', label: '不良原因' }, { value: 'reason', label: '不良原因' },
{ value: 'package', label: 'PACKAGE' }, { value: 'package', label: 'PACKAGE' },
{ value: 'type', label: 'TYPE' }, { value: 'type', label: 'TYPE' },
{ value: 'workflow', label: 'WORKFLOW' }, { value: 'workflow', label: 'WORKFLOW' },
{ value: 'workcenter', label: '站點' }, { value: 'workcenter', label: '站點' },
{ value: 'equipment', label: '機台' }, { value: 'equipment', label: '機台' },
]; ];
const DISPLAY_SCOPE_TOP20_DIMENSIONS = new Set(['type', 'workflow', 'equipment']);
const props = defineProps({
items: { type: Array, default: () => [] }, const props = defineProps({
detailReason: { type: String, default: '' }, items: { type: Array, default: () => [] },
selectedDates: { type: Array, default: () => [] }, selectedValues: { type: Array, default: () => [] },
metricLabel: { type: String, default: '報廢量' }, selectedDates: { type: Array, default: () => [] },
loading: { type: Boolean, default: false }, metricLabel: { type: String, default: '報廢量' },
dimension: { type: String, default: 'reason' }, loading: { type: Boolean, default: false },
showDimensionSelector: { type: Boolean, default: false }, dimension: { type: String, default: 'reason' },
}); showDimensionSelector: { type: Boolean, default: false },
displayScope: { type: String, default: 'all' },
const emit = defineEmits(['reason-click', 'dimension-change']); });
const hasData = computed(() => Array.isArray(props.items) && props.items.length > 0); const emit = defineEmits(['item-toggle', 'dimension-change', 'display-scope-change']);
const dimensionLabel = computed(() => { const hasData = computed(() => Array.isArray(props.items) && props.items.length > 0);
const opt = DIMENSION_OPTIONS.find((o) => o.value === props.dimension); const selectedValueSet = computed(() => new Set((props.selectedValues || []).map((item) => String(item || '').trim())));
return opt ? opt.label : '報廢原因'; const showDisplayScopeSelector = computed(
}); () => DISPLAY_SCOPE_TOP20_DIMENSIONS.has(props.dimension),
);
const dimensionLabel = computed(() => {
const opt = DIMENSION_OPTIONS.find((o) => o.value === props.dimension);
return opt ? opt.label : '報廢原因';
});
function isSelected(value) {
return selectedValueSet.value.has(String(value || '').trim());
}
function formatNumber(value) { function formatNumber(value) {
return Number(value || 0).toLocaleString('zh-TW'); return Number(value || 0).toLocaleString('zh-TW');
@@ -100,16 +110,16 @@ const chartOption = computed(() => {
{ {
name: props.metricLabel, name: props.metricLabel,
type: 'bar', type: 'bar',
data: items.map((item) => Number(item.metric_value || 0)), data: items.map((item) => Number(item.metric_value || 0)),
barMaxWidth: 34, barMaxWidth: 34,
itemStyle: { itemStyle: {
color(params) { color(params) {
const reason = items[params.dataIndex]?.reason || ''; const reason = items[params.dataIndex]?.reason || '';
return reason === props.detailReason ? '#b91c1c' : '#2563eb'; return isSelected(reason) ? '#b91c1c' : '#2563eb';
}, },
borderRadius: [4, 4, 0, 0], borderRadius: [4, 4, 0, 0],
}, },
}, },
{ {
name: '累積%', name: '累積%',
type: 'line', type: 'line',
@@ -123,36 +133,47 @@ const chartOption = computed(() => {
}; };
}); });
function handleChartClick(params) { function handleChartClick(params) {
if (params?.seriesType !== 'bar' || props.dimension !== 'reason') { if (params?.seriesType !== 'bar') {
return; return;
} }
const reason = props.items?.[params.dataIndex]?.reason; const itemValue = props.items?.[params.dataIndex]?.reason;
if (reason) { if (itemValue) {
emit('reason-click', reason); emit('item-toggle', itemValue);
} }
} }
</script> </script>
<template> <template>
<section class="card"> <section class="card">
<div class="card-header pareto-header"> <div class="card-header pareto-header">
<div class="card-title"> <div class="card-title">
{{ metricLabel }} vs {{ dimensionLabel }}Pareto {{ metricLabel }} vs {{ dimensionLabel }}Pareto
<span v-for="d in selectedDates" :key="d" class="pareto-date-badge">{{ d }}</span> <span v-for="d in selectedDates" :key="d" class="pareto-date-badge">{{ d }}</span>
</div> </div>
<select <div class="pareto-controls">
v-if="showDimensionSelector" <select
class="dimension-select" v-if="showDimensionSelector"
:value="dimension" class="dimension-select"
@change="emit('dimension-change', $event.target.value)" :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> <option v-for="opt in DIMENSION_OPTIONS" :key="opt.value" :value="opt.value">{{ opt.label }}</option>
</div> </select>
<div class="card-body pareto-layout"> <select
<div class="pareto-chart-wrap"> v-if="showDisplayScopeSelector"
<VChart :option="chartOption" autoresize @click="handleChartClick" /> 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">
<VChart :option="chartOption" autoresize @click="handleChartClick" />
<div v-if="!hasData && !loading" class="placeholder chart-empty">No data</div> <div v-if="!hasData && !loading" class="placeholder chart-empty">No data</div>
</div> </div>
<div class="pareto-table-wrap"> <div class="pareto-table-wrap">
@@ -166,19 +187,18 @@ function handleChartClick(params) {
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
<tr <tr
v-for="item in items" v-for="item in items"
:key="item.reason" :key="item.reason"
:class="{ active: detailReason === item.reason }" :class="{ active: isSelected(item.reason) }"
> >
<td> <td>
<button v-if="dimension === 'reason'" class="reason-link" type="button" @click="$emit('reason-click', item.reason)"> <button class="reason-link" type="button" @click="$emit('item-toggle', item.reason)">
{{ item.reason }} {{ item.reason }}
</button> </button>
<span v-else>{{ item.reason }}</span> </td>
</td> <td>{{ formatNumber(item.metric_value) }}</td>
<td>{{ formatNumber(item.metric_value) }}</td> <td>{{ formatPct(item.pct) }}</td>
<td>{{ formatPct(item.pct) }}</td>
<td>{{ formatPct(item.cumPct) }}</td> <td>{{ formatPct(item.cumPct) }}</td>
</tr> </tr>
<tr v-if="!items || items.length === 0"> <tr v-if="!items || items.length === 0">

View File

@@ -53,6 +53,10 @@
margin-bottom: 12px; margin-bottom: 12px;
} }
.supplementary-toolbar {
margin-bottom: 12px;
}
.supplementary-row { .supplementary-row {
display: grid; display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr)); grid-template-columns: repeat(3, minmax(0, 1fr));
@@ -312,6 +316,13 @@
gap: 12px; gap: 12px;
} }
.pareto-controls {
display: inline-flex;
align-items: center;
gap: 8px;
margin-left: auto;
}
.dimension-select { .dimension-select {
font-size: 12px; font-size: 12px;
padding: 3px 8px; padding: 3px 8px;
@@ -320,7 +331,10 @@
background: var(--bg-primary, #fff); background: var(--bg-primary, #fff);
color: var(--text-primary, #374151); color: var(--text-primary, #374151);
cursor: pointer; cursor: pointer;
margin-left: auto; }
.pareto-scope-select {
min-width: 110px;
} }
.pareto-date-badge { .pareto-date-badge {

View File

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

View File

@@ -0,0 +1,59 @@
## Context
報廢歷史查詢的 SQL 基底查詢(`performance_daily.sql` / `performance_daily_lot.sql`)目前有一個 `wip_lookup` CTE`DW_MES_WIP`8000 萬筆)以 `ROW_NUMBER()` + `CONTAINERID` 取最新一筆 WIP 記錄,用於 PJ_TYPE、PRODUCTLINENAME、EQUIPMENTS、WORKFLOWNAME 的 fallback。此做法存在多項問題
1. `DW_MES_WIP` 取的是「最新」WIP 步驟,不是報廢發生當下的步驟
2. PJ_TYPE 和 PRODUCTLINENAME 應直接從 `DW_MES_CONTAINER` 取得
3. EQUIPMENTNAME 若 reject history 沒有值就應留空,不需額外查找
4. WORKFLOWNAME 應從 `DW_MES_LOTWIPHISTORY` 透過 `WIPTRACKINGGROUPKEYID` 精確對應
## Goals / Non-Goals
**Goals:**
- 修正 5 個維度柏拉圖的資料來源,使 package/type/workflow/equipment 正確顯示
- workcenter 柏拉圖維度維持使用 WORKCENTER_GROUP
- WORKFLOW 精確對應到報廢發生當下的 WIP 步驟
**Non-Goals:**
- 不變更前端元件邏輯
- 不變更 API 介面或回應結構
- 不新增篩選維度
## Decisions
### D1: 移除 `wip_lookup` CTE改用直接 LEFT JOIN `DW_MES_LOTWIPHISTORY`
移除整個 `wip_lookup` CTE來自 `DW_MES_WIP`),在 `reject_raw` 的 FROM 區段新增:
```sql
LEFT JOIN DWH.DW_MES_LOTWIPHISTORY lwh
ON lwh.WIPTRACKINGGROUPKEYID = r.WIPTRACKINGGROUPKEYID
```
理由:`DW_MES_LOTREJECTHISTORY``DW_MES_LOTWIPHISTORY` 都有 `WIPTRACKINGGROUPKEYID`,兩邊都有索引,直接 JOIN 即可精確對應到報廢事件所在的 WIP 步驟。不需要 CTE、不需要 ROW_NUMBER、不需要子查詢。
### D2: 各欄位來源
| 欄位 | 來源表 | 寫法 |
|---|---|---|
| PJ_TYPE | DW_MES_CONTAINER | `NVL(TRIM(c.PJ_TYPE), '(NA)')` |
| PRODUCTLINENAME | DW_MES_CONTAINER | `NVL(TRIM(c.PRODUCTLINENAME), '(NA)')` |
| EQUIPMENTNAME | DW_MES_LOTREJECTHISTORY | `NVL(TRIM(r.EQUIPMENTNAME), '(NA)')` |
| PRIMARY_EQUIPMENTNAME | DW_MES_LOTREJECTHISTORY | `NVL(TRIM(REGEXP_SUBSTR(r.EQUIPMENTNAME, '[^,]+', 1, 1)), '(NA)')` |
| WORKFLOWNAME | DW_MES_LOTWIPHISTORY | `NVL(TRIM(lwh.WORKFLOWNAME), '(NA)')` |
| WORKCENTERNAME | spec_map (既有) | `NVL(TRIM(sm.WORK_CENTER), NVL(TRIM(r.WORKCENTERNAME), '(NA)'))` |
| WORKCENTER_GROUP | spec_map (既有) | `NVL(TRIM(sm.WORKCENTER_GROUP), NVL(TRIM(r.WORKCENTERNAME), '(NA)'))` |
### D3: Python 維度映射 workcenter 改回 WORKCENTER_GROUP
`reject_dataset_cache.py``_DIM_TO_DF_COLUMN``reject_history_service.py``_DIMENSION_COLUMN_MAP``"workcenter"` 映射改回 `WORKCENTER_GROUP` / `b.WORKCENTER_GROUP`
### D4: 兩支 SQL 同步修改
`performance_daily.sql``performance_daily_lot.sql` 需同步做相同變更,保持一致。
## Risks / Trade-offs
- `DW_MES_LOTWIPHISTORY` 有 5400 萬筆,但 `WIPTRACKINGGROUPKEYID` 有索引JOIN 效率可控
-`WIPTRACKINGGROUPKEYID` 為 NULL 的 reject 記錄WORKFLOWNAME 會顯示 (NA)——這是正確行為
- `DW_MES_CONTAINER` 的 PJ_TYPE / PRODUCTLINENAME 若為 NULL仍會顯示 (NA)——這代表該 container 確實沒有此資訊

View File

@@ -0,0 +1,28 @@
## Why
報廢歷史柏拉圖的多個維度顯示不正確package、type、equipment 全顯示 (NA)workflow 顯示成 spec nameworkcenter 維度應顯示 WORKCENTER_GROUP。原因是 SQL 資料來源選擇錯誤——目前錯誤地使用 `DW_MES_WIP` 作為 fallback應改為正確使用 `DW_MES_CONTAINER` 取 package/type`DW_MES_LOTWIPHISTORY` 取 workflow透過 `WIPTRACKINGGROUPKEYID` 精確對應報廢事件equipment 不做額外查找。
## What Changes
- 移除 `performance_daily.sql``performance_daily_lot.sql` 中的 `wip_lookup` CTE來自 `DW_MES_WIP`
- 新增 `LEFT JOIN DWH.DW_MES_LOTWIPHISTORY` 透過 `WIPTRACKINGGROUPKEYID` 取得報廢當下對應的 WORKFLOWNAME
- PJ_TYPE、PRODUCTLINENAME 還原為僅從 `DW_MES_CONTAINER` 取得
- EQUIPMENTNAME 還原為僅從 `DW_MES_LOTREJECTHISTORY` 取得(空就空,不額外查找)
- WORKFLOWNAME 改為從 `DW_MES_LOTWIPHISTORY` 取得(精確對應報廢事件的 WIP 步驟)
- 柏拉圖 workcenter 維度映射改回 `WORKCENTER_GROUP`Python service 層)
## Capabilities
### New Capabilities
(none)
### Modified Capabilities
- `reject-history-api`: Dimension Pareto 的 SQL 資料來源變更——移除 DW_MES_WIP fallback改用 DW_MES_LOTWIPHISTORY 取 workflowworkcenter 維度映射改回 WORKCENTER_GROUP
## Impact
- SQL: `performance_daily.sql`, `performance_daily_lot.sql` — CTE 結構變更JOIN 變更
- Python: `reject_dataset_cache.py`, `reject_history_service.py` — 維度映射常數調整
- 無前端變更、無 API 介面變更、無新增依賴

View File

@@ -0,0 +1,42 @@
## MODIFIED Requirements
### Requirement: Reject History SQL base query SHALL source dimension columns from correct tables
The base query (`performance_daily.sql`, `performance_daily_lot.sql`) SHALL source each dimension column from its authoritative table.
#### Scenario: PJ_TYPE sourced from DW_MES_CONTAINER
- **WHEN** the base query resolves PJ_TYPE
- **THEN** it SHALL use `DW_MES_CONTAINER.PJ_TYPE` only
- **THEN** it SHALL NOT fall back to `DW_MES_WIP`
#### Scenario: PRODUCTLINENAME sourced from DW_MES_CONTAINER
- **WHEN** the base query resolves PRODUCTLINENAME (package)
- **THEN** it SHALL use `DW_MES_CONTAINER.PRODUCTLINENAME` only
- **THEN** it SHALL NOT fall back to `DW_MES_WIP`
#### Scenario: EQUIPMENTNAME sourced from DW_MES_LOTREJECTHISTORY only
- **WHEN** the base query resolves EQUIPMENTNAME
- **THEN** it SHALL use `DW_MES_LOTREJECTHISTORY.EQUIPMENTNAME` only
- **THEN** it SHALL NOT perform any additional lookup when the value is NULL
#### Scenario: WORKFLOWNAME sourced from DW_MES_LOTWIPHISTORY via WIPTRACKINGGROUPKEYID
- **WHEN** the base query resolves WORKFLOWNAME
- **THEN** it SHALL LEFT JOIN `DW_MES_LOTWIPHISTORY` on `WIPTRACKINGGROUPKEYID`
- **THEN** it SHALL use `DW_MES_LOTWIPHISTORY.WORKFLOWNAME`
- **THEN** it SHALL NOT fall back to SPECNAME or any other field
#### Scenario: No DW_MES_WIP dependency in base query
- **WHEN** the base query CTEs are examined
- **THEN** there SHALL be no CTE or JOIN referencing `DW_MES_WIP`
### Requirement: Dimension Pareto workcenter dimension SHALL use WORKCENTER_GROUP
The workcenter dimension in Pareto analysis SHALL group by `WORKCENTER_GROUP`, not individual `WORKCENTERNAME`.
#### Scenario: Cache-based Pareto workcenter mapping
- **WHEN** `reject_dataset_cache.py` computes workcenter dimension Pareto
- **THEN** the dimension column SHALL be `WORKCENTER_GROUP`
#### Scenario: SQL-based Pareto workcenter mapping
- **WHEN** `reject_history_service.py` builds workcenter dimension Pareto SQL
- **THEN** the dimension column SHALL be `b.WORKCENTER_GROUP`

View File

@@ -0,0 +1,22 @@
## 1. SQL 基底查詢修正 — performance_daily.sql
- [x] 1.1 移除 `wip_lookup` CTE整個 DW_MES_WIP 相關區段)
- [x] 1.2 新增 `LEFT JOIN DWH.DW_MES_LOTWIPHISTORY lwh ON lwh.WIPTRACKINGGROUPKEYID = r.WIPTRACKINGGROUPKEYID`
- [x] 1.3 PJ_TYPE 還原為 `NVL(TRIM(c.PJ_TYPE), '(NA)')`
- [x] 1.4 PRODUCTLINENAME 還原為 `NVL(TRIM(c.PRODUCTLINENAME), '(NA)')`
- [x] 1.5 EQUIPMENTNAME 還原為 `NVL(TRIM(r.EQUIPMENTNAME), '(NA)')`PRIMARY_EQUIPMENTNAME 同步還原
- [x] 1.6 WORKFLOWNAME 改為 `NVL(TRIM(lwh.WORKFLOWNAME), '(NA)')`
## 2. SQL 基底查詢修正 — performance_daily_lot.sql
- [x] 2.1 移除 `wip_lookup` CTE
- [x] 2.2 新增 `LEFT JOIN DWH.DW_MES_LOTWIPHISTORY lwh ON lwh.WIPTRACKINGGROUPKEYID = r.WIPTRACKINGGROUPKEYID`
- [x] 2.3 PJ_TYPE 還原為 `NVL(TRIM(c.PJ_TYPE), '(NA)')`
- [x] 2.4 PRODUCTLINENAME 還原為 `NVL(TRIM(c.PRODUCTLINENAME), '(NA)')`
- [x] 2.5 EQUIPMENTNAME 還原為 `NVL(TRIM(r.EQUIPMENTNAME), '(NA)')`PRIMARY_EQUIPMENTNAME 同步還原
- [x] 2.6 WORKFLOWNAME 改為 `NVL(TRIM(lwh.WORKFLOWNAME), '(NA)')`
## 3. Python 維度映射修正
- [x] 3.1 `reject_dataset_cache.py` `_DIM_TO_DF_COLUMN` — workcenter 改回 `WORKCENTER_GROUP`
- [x] 3.2 `reject_history_service.py` `_DIMENSION_COLUMN_MAP` — workcenter 改回 `b.WORKCENTER_GROUP`

View File

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

View File

@@ -0,0 +1,3 @@
# reject-history-pareto-ux-enhancements
Add pareto display scope options, multi-select drill-down, and filtered detail CSV export

View File

@@ -0,0 +1,73 @@
## Context
Reject History 已改為兩階段查詢:`/api/reject-history/query` 先查 Oracle 並快取 DataFrame`/api/reject-history/view``/api/reject-history/export-cached` 在快取資料上做補充篩選。現況存在三個 UX/一致性缺口:
1. `TYPE/WORKFLOW/機台` 維度在 80% 範圍下仍可能過多,不易閱讀。
2. Pareto 點選目前僅支援單選原因reason不支援多選且不支援其他維度。
3. 匯出需要和畫面篩選完全一致,但目前缺少 Pareto 多選情境的等價參數傳遞與後端套用。
本變更跨前端 VueFilterPanel/ParetoSection/App與後端 Flask+Pandasroutes/cache service屬跨模組一致性調整。
## Goals / Non-Goals
**Goals:**
-`TYPE/WORKFLOW/機台` 維度提供 Pareto 顯示範圍切換(`全部顯示` / `只顯示 TOP 20`)。
- 將「Pareto 僅顯示累計前 80%」控制移到補充篩選區域,維持預設啟用。
- Pareto 圖表與表格都支援多選,並即時聯動刷新明系列表。
- 匯出 CSV 套用完整篩選上下文主查詢、補充篩選、互動篩選、Pareto 多選)。
- 保持 UTF-8 BOM 與標準 CSV escaping。
**Non-Goals:**
- 不改動 Oracle SQL schema 與資料來源邏輯。
- 不新增新資料表或 Redis key 結構。
- 不重做 Reject History 整體版面,只做既有模組行為擴充。
## Decisions
### 1) Pareto 多選狀態由 App.vue 集中管理
- Decision: 新增 `selectedParetoValues`array`paretoDisplayScope``all`/`top20`)於 `App.vue`
- Why: 既有趨勢日期、補充篩選、明細分頁都由 App 協調;將 Pareto 多選納入同一狀態中心可確保 URL、view、export 一致。
- Alternative considered:
-`ParetoSection.vue` 內部持有選取狀態:會造成匯出與後端參數組裝需要額外同步機制,易出現狀態漂移。
### 2) `TOP 20` 僅在前端呈現層裁切
- Decision: 後端仍回傳完整(或 top80Pareto items`TOP 20` 由前端 computed 再切片。
- Why: `TOP 20` 是視覺呈現策略,不是資料語意;放前端可避免增加 API 分支與快取鍵複雜度。
- Alternative considered:
- API 增加 `display_scope=top20`:可行但會讓同一資料語意被多組 API 參數切分,且對快取命中率不利。
### 3) Pareto 多選篩選在後端 cache service 統一套用
- Decision: `apply_view()``export_csv_from_cache()` 新增 `pareto_dimension` + `pareto_values` 參數,透過共享 helper 套用到對應欄位。
- Why: 明細畫面與匯出都要使用「同一過濾函式」才能保證 parity。
- Alternative considered:
- 前端先過濾當頁明細再匯出:無法涵蓋全資料集,且分頁資料可能不完整。
### 4) 80% toggle 位置調整但語意不變
- Decision: checkbox UI 從主工具列移至補充篩選區塊;預設值與 URL 參數(`pareto_scope_all`)維持既有相容。
- Why: 80% 為二階段視圖篩選,移入補充篩選可降低使用者誤解。
## Risks / Trade-offs
- [Risk] 多選維度欄位映射錯誤(特別是 `equipment` 對應欄位)導致篩選失準。
→ Mitigation: 單元測試覆蓋各維度映射與無效維度 400。
- [Risk] 前端多種互動趨勢日期、reason、pareto 多選)同時作用時狀態難追蹤。
→ Mitigation: `activeFilterChips` 顯示所有活躍條件,並統一經 `refreshView()` + `updateUrlState()`
- [Risk] CSV 匯出和列表排序/篩選不一致造成信任問題。
→ Mitigation: 匯出重用與 view 相同 filter helper並新增 route/service parity 測試。
## Migration Plan
1. 先落地後端 `apply_view/export_csv_from_cache` 共同 Pareto 多選過濾與參數驗證。
2. 再調整前端控制項與事件多選、TOP20、補充篩選區
3. 補上 route/service 單元測試。
4. 驗證目標:`reject-history` 相關測試通過,手動檢查 CSV 編碼與欄位。
Rollback strategy:
- 若上線後出現篩選偏差,可暫時忽略 `pareto_dimension/pareto_values` 參數(後端回退到舊邏輯),不影響既有查詢主路徑。
## Open Questions
- `TOP 20` 是否僅限定 `TYPE/WORKFLOW/機台`(本次採用是),或未來要擴展到全部維度?
- 多選 Pareto 與補充篩選中的 `reason` 同時存在時,是否需要 UI 顯示「交集」提示(本次先不新增提示文案)。

View File

@@ -0,0 +1,26 @@
## Why
目前 Reject History 柏拉圖在 `TYPE``WORKFLOW``機台` 維度上,僅靠累計 80% 顯示時仍可能出現過多項目,影響閱讀與分析效率;同時圖表點選與明細匯出尚未完整對齊,造成追查流程不連續。需要補強互動式篩選與匯出一致性,讓使用者可直接從柏拉圖一路鑽取到可交付的明細結果。
## What Changes
- 在柏拉圖 `TYPE``WORKFLOW``機台` 三個維度新增顯示範圍選項:`全部顯示``只顯示 TOP 20`
- 將「Pareto 僅顯示累計前 80%」移入「補充篩選」區域,並維持預設啟用。
- 柏拉圖支援點選項目多選bar/table並同步套用到下方明系列表。
- 明系列表新增 `匯出 CSV`匯出內容必須與當前明細可見結果完全一致套用主篩選、補充篩選、Pareto 點選篩選、排序/分頁語意)。
- 匯出 CSV 強化字元編碼與欄位轉義處理,避免中文亂碼與欄位錯位。
## Capabilities
### New Capabilities
- `reject-history-detail-export-parity`: 明細匯出與畫面篩選完全一致的 CSV 匯出能力
### Modified Capabilities
- `reject-history-page`: 擴充柏拉圖顯示範圍控制、補充篩選位置調整、Pareto 多選聯動明細
- `reject-history-api`: 匯出端點需保證套用所有有效篩選(含 Pareto 衍生篩選),並提供穩定 CSV 編碼輸出
## Impact
- Frontend: `src/pages/reject-history`FilterPanel、ParetoSection、DetailTable、頁面狀態管理與查詢參數組裝
- Backend/API: reject-history list/export 查詢參數解析與 CSV 產生流程
- Tests: 補齊 page 互動測試(多選/聯動/顯示範圍)與 API 匯出一致性測試filter parity、encoding

View File

@@ -0,0 +1,19 @@
## MODIFIED Requirements
### Requirement: Reject History API SHALL provide CSV export endpoint
The API SHALL provide CSV export using the same filter and metric semantics as list/query APIs.
#### Scenario: Export payload consistency
- **WHEN** `GET /api/reject-history/export` is called with valid filters
- **THEN** CSV headers SHALL include both `REJECT_TOTAL_QTY` and `DEFECT_QTY`
- **THEN** export rows SHALL follow the same semantic definitions as summary/list endpoints
#### Scenario: Cached export supports full detail-filter parity
- **WHEN** `GET /api/reject-history/export-cached` is called with an existing `query_id`
- **THEN** the endpoint SHALL apply primary policy toggles, supplementary filters, trend-date filters, metric filter, and Pareto-selected item filters
- **THEN** returned rows SHALL match the same filtered detail dataset semantics used by `GET /api/reject-history/view`
#### Scenario: CSV encoding and escaping are stable
- **WHEN** either export endpoint returns CSV
- **THEN** response charset SHALL be `utf-8-sig`
- **THEN** values containing commas, quotes, or newlines SHALL be CSV-escaped correctly

View File

@@ -0,0 +1,18 @@
## ADDED Requirements
### Requirement: Cached reject-history export SHALL support Pareto multi-select filter parity
The cached export endpoint SHALL support Pareto multi-select context so that exported rows match the currently drilled-down detail scope.
#### Scenario: Apply selected Pareto dimension values
- **WHEN** export request provides `pareto_dimension` and one or more `pareto_values`
- **THEN** the backend SHALL apply an OR-match filter against the mapped dimension column
- **THEN** only rows matching selected values SHALL be exported
#### Scenario: No Pareto selection keeps existing behavior
- **WHEN** `pareto_values` is absent or empty
- **THEN** export SHALL apply no extra Pareto-selected-item filter
- **THEN** existing supplementary and interactive filters SHALL still apply
#### Scenario: Invalid Pareto dimension is rejected
- **WHEN** `pareto_dimension` is not one of supported dimensions
- **THEN** API SHALL return HTTP 400 with descriptive validation error

View File

@@ -0,0 +1,43 @@
## MODIFIED Requirements
### Requirement: Reject History page SHALL provide reason Pareto analysis
The page SHALL provide a Pareto view for loss reasons and support downstream filtering.
#### 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
#### 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
#### 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: Pareto click filtering supports multi-select
- **WHEN** user clicks Pareto bars or table rows
- **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
#### 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
### 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 Pareto-selected items
- **THEN** downloaded file SHALL contain exactly the same rows currently represented by the detail list filter context
#### Scenario: Export remains UTF-8 Excel compatible
- **WHEN** CSV export is downloaded
- **THEN** the file SHALL be encoded in UTF-8 with BOM
- **THEN** Chinese headers and values SHALL render correctly in common spreadsheet tools

View File

@@ -0,0 +1,19 @@
## 1. Frontend Pareto UX Enhancements
- [x] 1.1 在 `FilterPanel.vue` 將「Pareto 僅顯示累計前 80%」移至補充篩選區域並維持預設啟用
- [x] 1.2 在 `ParetoSection.vue` 新增 `全部顯示 / 只顯示 TOP 20` 控制(僅 `TYPE/WORKFLOW/機台` 顯示)
- [x] 1.3 在 `ParetoSection.vue` 支援圖表與表格點選多選、選取高亮與取消選取
- [x] 1.4 在 `App.vue` 新增 Pareto 多選狀態管理與 URL 狀態同步dimension + selected values + display scope
## 2. Backend Filter/Export Parity
- [x] 2.1 在 `reject_dataset_cache.py` 新增 Pareto 維度多選過濾 helper供 view/export 共用
- [x] 2.2 擴充 `apply_view()` 支援 `pareto_dimension` + `pareto_values` 並套用到明細過濾
- [x] 2.3 擴充 `export_csv_from_cache()` 支援與 view 相同的 Pareto 多選過濾語意
- [x] 2.4 更新 `reject_history_routes.py``/view``/export-cached` 參數解析與維度驗證(非法維度回 400
## 3. Validation and Regression Tests
- [x] 3.1 新增/更新 route 測試:驗證 `/view``/export-cached` 會傳遞 Pareto 多選參數且非法維度回 400
- [x] 3.2 新增/更新 cache service 測試:驗證 Pareto 多選在 `apply_view``export_csv_from_cache` 行為一致
- [x] 3.3 執行 reject-history 相關測試並確認無回歸

View File

@@ -87,6 +87,16 @@ The API SHALL provide CSV export using the same filter and metric semantics as l
- **THEN** CSV headers SHALL include both `REJECT_TOTAL_QTY` and `DEFECT_QTY` - **THEN** CSV headers SHALL include both `REJECT_TOTAL_QTY` and `DEFECT_QTY`
- **THEN** export rows SHALL follow the same semantic definitions as summary/list endpoints - **THEN** export rows SHALL follow the same semantic definitions as summary/list endpoints
#### Scenario: Cached export supports full detail-filter parity
- **WHEN** `GET /api/reject-history/export-cached` is called with an existing `query_id`
- **THEN** the endpoint SHALL apply primary policy toggles, supplementary filters, trend-date filters, metric filter, and Pareto-selected item filters
- **THEN** returned rows SHALL match the same filtered detail dataset semantics used by `GET /api/reject-history/view`
#### Scenario: CSV encoding and escaping are stable
- **WHEN** either export endpoint returns CSV
- **THEN** response charset SHALL be `utf-8-sig`
- **THEN** values containing commas, quotes, or newlines SHALL be CSV-escaped correctly
### Requirement: Reject History API SHALL centralize SQL in reject_history SQL directory ### Requirement: Reject History API SHALL centralize SQL in reject_history SQL directory
The service SHALL load SQL from dedicated files under `src/mes_dashboard/sql/reject_history/`. The service SHALL load SQL from dedicated files under `src/mes_dashboard/sql/reject_history/`.

View File

@@ -0,0 +1,22 @@
# reject-history-detail-export-parity Specification
## Purpose
TBD - created by archiving change reject-history-pareto-ux-enhancements. Update Purpose after archive.
## Requirements
### Requirement: Cached reject-history export SHALL support Pareto multi-select filter parity
The cached export endpoint SHALL support Pareto multi-select context so that exported rows match the currently drilled-down detail scope.
#### Scenario: Apply selected Pareto dimension values
- **WHEN** export request provides `pareto_dimension` and one or more `pareto_values`
- **THEN** the backend SHALL apply an OR-match filter against the mapped dimension column
- **THEN** only rows matching selected values SHALL be exported
#### Scenario: No Pareto selection keeps existing behavior
- **WHEN** `pareto_values` is absent or empty
- **THEN** export SHALL apply no extra Pareto-selected-item filter
- **THEN** existing supplementary and interactive filters SHALL still apply
#### Scenario: Invalid Pareto dimension is rejected
- **WHEN** `pareto_dimension` is not one of supported dimensions
- **THEN** API SHALL return HTTP 400 with descriptive validation error

View File

@@ -77,25 +77,30 @@ The page SHALL show both quantity trend and rate trend to avoid mixing unit scal
The page SHALL provide a Pareto view for loss reasons and support downstream filtering. The page SHALL provide a Pareto view for loss reasons and support downstream filtering.
#### Scenario: Pareto rendering and ordering #### Scenario: Pareto rendering and ordering
- **WHEN** reason Pareto data is loaded - **WHEN** Pareto data is loaded
- **THEN** items SHALL be sorted by selected metric descending - **THEN** items SHALL be sorted by selected metric descending
- **THEN** a cumulative percentage line SHALL be shown - **THEN** a cumulative percentage line SHALL be shown
#### Scenario: Default 80% cumulative display mode #### Scenario: Pareto 80% filter is managed in supplementary filters
- **WHEN** the page first loads Pareto - **WHEN** the page first loads Pareto
- **THEN** it SHALL default to "only cumulative top 80%" mode - **THEN** supplementary filters SHALL include "Pareto 僅顯示累計前 80%" control
- **THEN** Pareto SHALL only render categories within the cumulative 80% threshold under current filters - **THEN** the control SHALL default to enabled
#### Scenario: Full Pareto toggle mode #### Scenario: TYPE/WORKFLOW/機台 support display scope selector
- **WHEN** user turns OFF the 80% cumulative display mode - **WHEN** Pareto dimension is `TYPE`, `WORKFLOW`, or `機台`
- **THEN** Pareto SHALL render all categories after applying current filters - **THEN** the UI SHALL provide `全部顯示` and `只顯示 TOP 20` options
- **THEN** switching mode SHALL NOT reset existing time/reason/workcenter-group filters - **THEN** `全部顯示` SHALL still respect the current 80% cumulative filter setting
#### Scenario: Pareto click filtering #### Scenario: Pareto click filtering supports multi-select
- **WHEN** user clicks a Pareto bar or row - **WHEN** user clicks Pareto bars or table rows
- **THEN** the selected reason SHALL become an active filter chip - **THEN** clicked items SHALL become active selected chips
- **THEN** detail list SHALL reload with that reason - **THEN** multiple selected items SHALL be supported at the same time
- **THEN** clicking the same reason again SHALL clear the reason filter - **THEN** detail list SHALL reload using current selected Pareto items as additional filter criteria
#### 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
### Requirement: Reject History page SHALL show paginated detail rows ### Requirement: Reject History page SHALL show paginated detail rows
The page SHALL provide a paginated detail table for investigation and traceability. The page SHALL provide a paginated detail table for investigation and traceability.
@@ -112,10 +117,15 @@ The page SHALL provide a paginated detail table for investigation and traceabili
### Requirement: Reject History page SHALL support CSV export from current filter context ### 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. The page SHALL allow users to export records using the exact active filters.
#### Scenario: Export with current filters #### Scenario: Export with all active filters
- **WHEN** user clicks "匯出 CSV" - **WHEN** user clicks "匯出 CSV"
- **THEN** export request SHALL include the current filter state and active reason filter - **THEN** export request SHALL include current primary filters, supplementary filters, trend-date filters, metric filters, and Pareto-selected items
- **THEN** downloaded file SHALL contain both `REJECT_TOTAL_QTY` and `DEFECT_QTY` - **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
### Requirement: Reject History page SHALL provide robust feedback states ### Requirement: Reject History page SHALL provide robust feedback states
The page SHALL provide loading, empty, and error states without breaking interactions. The page SHALL provide loading, empty, and error states without breaking interactions.

View File

@@ -26,7 +26,7 @@ REDIS_ENABLED="${REDIS_ENABLED:-true}"
# Worker watchdog configuration # Worker watchdog configuration
WATCHDOG_ENABLED="${WATCHDOG_ENABLED:-true}" WATCHDOG_ENABLED="${WATCHDOG_ENABLED:-true}"
# RQ trace worker configuration # RQ trace worker configuration
TRACE_WORKER_ENABLED="${TRACE_WORKER_ENABLED:-false}" TRACE_WORKER_ENABLED="${TRACE_WORKER_ENABLED:-true}"
TRACE_WORKER_QUEUE="${TRACE_WORKER_QUEUE:-trace-events}" TRACE_WORKER_QUEUE="${TRACE_WORKER_QUEUE:-trace-events}"
# Colors for output # Colors for output

View File

@@ -113,6 +113,14 @@ def _extract_meta(
_VALID_BOOL_STRINGS = {"", "0", "false", "no", "n", "off", "1", "true", "yes", "y", "on"} _VALID_BOOL_STRINGS = {"", "0", "false", "no", "n", "off", "1", "true", "yes", "y", "on"}
_VALID_PARETO_DIMENSIONS = {
"reason",
"package",
"type",
"workflow",
"workcenter",
"equipment",
}
def _parse_common_bools() -> tuple[Optional[tuple[dict, int]], bool, bool, bool]: def _parse_common_bools() -> tuple[Optional[tuple[dict, int]], bool, bool, bool]:
@@ -137,6 +145,22 @@ def _parse_common_bools() -> tuple[Optional[tuple[dict, int]], bool, bool, bool]
return None, include_excluded_scrap, exclude_material_scrap, exclude_pb_diode return None, include_excluded_scrap, exclude_material_scrap, exclude_pb_diode
def _parse_pareto_selection() -> tuple[Optional[tuple[dict, int]], Optional[str], Optional[list[str]]]:
pareto_dimension = request.args.get("pareto_dimension", "").strip().lower()
pareto_values = _parse_multi_param("pareto_values")
if pareto_values and not pareto_dimension:
pareto_dimension = "reason"
if pareto_dimension and pareto_dimension not in _VALID_PARETO_DIMENSIONS:
return (
{
"success": False,
"error": f"Invalid pareto_dimension, supported: {', '.join(sorted(_VALID_PARETO_DIMENSIONS))}",
},
400,
), None, None
return None, (pareto_dimension or None), (pareto_values or None)
@reject_history_bp.route("/api/reject-history/options", methods=["GET"]) @reject_history_bp.route("/api/reject-history/options", methods=["GET"])
def api_reject_history_options(): def api_reject_history_options():
start_date, end_date, date_error = _parse_date_range(required=False) start_date, end_date, date_error = _parse_date_range(required=False)
@@ -304,6 +328,9 @@ def api_reject_history_reason_pareto():
workcenter_groups=_parse_multi_param("workcenter_groups") or None, workcenter_groups=_parse_multi_param("workcenter_groups") or None,
reason=request.args.get("reason", "").strip() or None, reason=request.args.get("reason", "").strip() or None,
trend_dates=_parse_multi_param("trend_dates") or None, trend_dates=_parse_multi_param("trend_dates") or None,
include_excluded_scrap=include_excluded_scrap,
exclude_material_scrap=exclude_material_scrap,
exclude_pb_diode=exclude_pb_diode,
) )
if result is not None: if result is not None:
return jsonify({"success": True, "data": result, "meta": {}}) return jsonify({"success": True, "data": result, "meta": {}})
@@ -517,6 +544,9 @@ def api_reject_history_view():
metric_filter = request.args.get("metric_filter", "all").strip().lower() or "all" metric_filter = request.args.get("metric_filter", "all").strip().lower() or "all"
reason = request.args.get("reason", "").strip() or None reason = request.args.get("reason", "").strip() or None
detail_reason = request.args.get("detail_reason", "").strip() or None detail_reason = request.args.get("detail_reason", "").strip() or None
pareto_error, pareto_dimension, pareto_values = _parse_pareto_selection()
if pareto_error:
return jsonify(pareto_error[0]), pareto_error[1]
include_excluded_scrap = request.args.get("include_excluded_scrap", "false").lower() == "true" include_excluded_scrap = request.args.get("include_excluded_scrap", "false").lower() == "true"
exclude_material_scrap = request.args.get("exclude_material_scrap", "true").lower() != "false" exclude_material_scrap = request.args.get("exclude_material_scrap", "true").lower() != "false"
@@ -531,6 +561,8 @@ def api_reject_history_view():
metric_filter=metric_filter, metric_filter=metric_filter,
trend_dates=_parse_multi_param("trend_dates") or None, trend_dates=_parse_multi_param("trend_dates") or None,
detail_reason=detail_reason, detail_reason=detail_reason,
pareto_dimension=pareto_dimension,
pareto_values=pareto_values,
page=page, page=page,
per_page=per_page, per_page=per_page,
include_excluded_scrap=include_excluded_scrap, include_excluded_scrap=include_excluded_scrap,
@@ -561,6 +593,9 @@ def api_reject_history_export_cached():
metric_filter = request.args.get("metric_filter", "all").strip().lower() or "all" metric_filter = request.args.get("metric_filter", "all").strip().lower() or "all"
reason = request.args.get("reason", "").strip() or None reason = request.args.get("reason", "").strip() or None
detail_reason = request.args.get("detail_reason", "").strip() or None detail_reason = request.args.get("detail_reason", "").strip() or None
pareto_error, pareto_dimension, pareto_values = _parse_pareto_selection()
if pareto_error:
return jsonify(pareto_error[0]), pareto_error[1]
include_excluded_scrap = request.args.get("include_excluded_scrap", "false").lower() == "true" include_excluded_scrap = request.args.get("include_excluded_scrap", "false").lower() == "true"
exclude_material_scrap = request.args.get("exclude_material_scrap", "true").lower() != "false" exclude_material_scrap = request.args.get("exclude_material_scrap", "true").lower() != "false"
@@ -575,6 +610,8 @@ def api_reject_history_export_cached():
metric_filter=metric_filter, metric_filter=metric_filter,
trend_dates=_parse_multi_param("trend_dates") or None, trend_dates=_parse_multi_param("trend_dates") or None,
detail_reason=detail_reason, detail_reason=detail_reason,
pareto_dimension=pareto_dimension,
pareto_values=pareto_values,
include_excluded_scrap=include_excluded_scrap, include_excluded_scrap=include_excluded_scrap,
exclude_material_scrap=exclude_material_scrap, exclude_material_scrap=exclude_material_scrap,
exclude_pb_diode=exclude_pb_diode, exclude_pb_diode=exclude_pb_diode,

View File

@@ -50,9 +50,10 @@ from mes_dashboard.sql import QueryBuilder
logger = logging.getLogger("mes_dashboard.reject_dataset_cache") logger = logging.getLogger("mes_dashboard.reject_dataset_cache")
_CACHE_TTL = 900 # 15 minutes _CACHE_TTL = 900 # 15 minutes
_CACHE_MAX_SIZE = 8 _CACHE_MAX_SIZE = 8
_REDIS_NAMESPACE = "reject_dataset" _REDIS_NAMESPACE = "reject_dataset"
_CACHE_SCHEMA_VERSION = 4
_dataset_cache = ProcessLevelCache(ttl_seconds=_CACHE_TTL, max_size=_CACHE_MAX_SIZE) _dataset_cache = ProcessLevelCache(ttl_seconds=_CACHE_TTL, max_size=_CACHE_MAX_SIZE)
register_process_cache("reject_dataset", _dataset_cache, "Reject Dataset (L1, 15min)") register_process_cache("reject_dataset", _dataset_cache, "Reject Dataset (L1, 15min)")
@@ -252,12 +253,13 @@ def execute_primary_query(
) )
# ---- Compute query_id from base params only (policy filters applied in-memory) ---- # ---- Compute query_id from base params only (policy filters applied in-memory) ----
query_id_input = { query_id_input = {
"mode": mode, "cache_schema_version": _CACHE_SCHEMA_VERSION,
"start_date": start_date, "mode": mode,
"end_date": end_date, "start_date": start_date,
"container_input_type": container_input_type, "end_date": end_date,
"container_values": sorted(container_values or []), "container_input_type": container_input_type,
"container_values": sorted(container_values or []),
} }
query_id = _make_query_id(query_id_input) query_id = _make_query_id(query_id_input)
@@ -385,20 +387,22 @@ def _build_primary_response(
# ============================================================ # ============================================================
def apply_view( def apply_view(
*, *,
query_id: str, query_id: str,
packages: Optional[List[str]] = None, packages: Optional[List[str]] = None,
workcenter_groups: Optional[List[str]] = None, workcenter_groups: Optional[List[str]] = None,
reason: Optional[str] = None, reason: Optional[str] = None,
metric_filter: str = "all", metric_filter: str = "all",
trend_dates: Optional[List[str]] = None, trend_dates: Optional[List[str]] = None,
detail_reason: Optional[str] = None, detail_reason: Optional[str] = None,
page: int = 1, pareto_dimension: Optional[str] = None,
per_page: int = 50, pareto_values: Optional[List[str]] = None,
include_excluded_scrap: bool = False, page: int = 1,
exclude_material_scrap: bool = True, per_page: int = 50,
exclude_pb_diode: bool = True, include_excluded_scrap: bool = False,
exclude_material_scrap: bool = True,
exclude_pb_diode: bool = True,
) -> Optional[Dict[str, Any]]: ) -> Optional[Dict[str, Any]]:
"""Read cache → apply filters → return derived data. Returns None if expired.""" """Read cache → apply filters → return derived data. Returns None if expired."""
df = _get_cached_df(query_id) df = _get_cached_df(query_id)
@@ -434,12 +438,17 @@ def apply_view(
detail_df = detail_df[ detail_df = detail_df[
detail_df["TXN_DAY"].apply(lambda d: _to_date_str(d) in date_set) detail_df["TXN_DAY"].apply(lambda d: _to_date_str(d) in date_set)
] ]
if detail_reason: if detail_reason:
detail_df = detail_df[ detail_df = detail_df[
detail_df["LOSSREASONNAME"].str.strip() == detail_reason.strip() detail_df["LOSSREASONNAME"].str.strip() == detail_reason.strip()
] ]
detail_df = _apply_pareto_selection_filter(
detail_page = _paginate_detail(detail_df, page=page, per_page=per_page) detail_df,
pareto_dimension=pareto_dimension,
pareto_values=pareto_values,
)
detail_page = _paginate_detail(detail_df, page=page, per_page=per_page)
return { return {
"analytics_raw": analytics_raw, "analytics_raw": analytics_raw,
@@ -448,7 +457,7 @@ def apply_view(
} }
def _apply_supplementary_filters( def _apply_supplementary_filters(
df: pd.DataFrame, df: pd.DataFrame,
*, *,
packages: Optional[List[str]] = None, packages: Optional[List[str]] = None,
@@ -485,7 +494,47 @@ def _apply_supplementary_filters(
elif metric_filter == "defect" and "DEFECT_QTY" in df.columns: elif metric_filter == "defect" and "DEFECT_QTY" in df.columns:
mask &= df["DEFECT_QTY"] > 0 mask &= df["DEFECT_QTY"] > 0
return df[mask] return df[mask]
def _normalize_pareto_values(values: Optional[List[str]]) -> List[str]:
normalized: List[str] = []
seen = set()
for value in values or []:
item = _normalize_text(value)
if not item or item in seen:
continue
seen.add(item)
normalized.append(item)
return normalized
def _apply_pareto_selection_filter(
df: pd.DataFrame,
*,
pareto_dimension: Optional[str] = None,
pareto_values: Optional[List[str]] = None,
) -> pd.DataFrame:
"""Apply Pareto multi-select filters on detail/export datasets."""
if df is None or df.empty:
return df
normalized_values = _normalize_pareto_values(pareto_values)
if not normalized_values:
return df
dimension = _normalize_text(pareto_dimension).lower() or "reason"
dim_col = _DIM_TO_DF_COLUMN.get(dimension)
if not dim_col:
raise ValueError(f"不支援的 pareto_dimension: {pareto_dimension}")
if dim_col not in df.columns:
return df.iloc[0:0]
value_set = set(normalized_values)
normalized_dimension_values = df[dim_col].map(
lambda value: _normalize_text(value) or "(未知)"
)
return df[normalized_dimension_values.isin(value_set)]
# ============================================================ # ============================================================
@@ -712,35 +761,48 @@ def _extract_available_filters(df: pd.DataFrame) -> dict:
# ============================================================ # ============================================================
# Dimension → DF column mapping (matches _DIMENSION_COLUMN_MAP in reject_history_service) # Dimension → DF column mapping (matches _DIMENSION_COLUMN_MAP in reject_history_service)
_DIM_TO_DF_COLUMN = { _DIM_TO_DF_COLUMN = {
"reason": "LOSSREASONNAME", "reason": "LOSSREASONNAME",
"package": "PRODUCTLINENAME", "package": "PRODUCTLINENAME",
"type": "PJ_TYPE", "type": "PJ_TYPE",
"workflow": "WORKFLOWNAME", "workflow": "WORKFLOWNAME",
"workcenter": "WORKCENTER_GROUP", "workcenter": "WORKCENTER_GROUP",
"equipment": "PRIMARY_EQUIPMENTNAME", "equipment": "PRIMARY_EQUIPMENTNAME",
} }
def compute_dimension_pareto( def compute_dimension_pareto(
*, *,
query_id: str, query_id: str,
dimension: str = "reason", dimension: str = "reason",
metric_mode: str = "reject_total", metric_mode: str = "reject_total",
pareto_scope: str = "top80", pareto_scope: str = "top80",
packages: Optional[List[str]] = None, packages: Optional[List[str]] = None,
workcenter_groups: Optional[List[str]] = None, workcenter_groups: Optional[List[str]] = None,
reason: Optional[str] = None, reason: Optional[str] = None,
trend_dates: Optional[List[str]] = None, trend_dates: Optional[List[str]] = None,
) -> Optional[Dict[str, Any]]: include_excluded_scrap: bool = False,
"""Compute dimension pareto from cached DataFrame (no Oracle query).""" exclude_material_scrap: bool = True,
df = _get_cached_df(query_id) exclude_pb_diode: bool = True,
if df is None: ) -> Optional[Dict[str, Any]]:
return None """Compute dimension pareto from cached DataFrame (no Oracle query)."""
df = _get_cached_df(query_id)
dim_col = _DIM_TO_DF_COLUMN.get(dimension, "LOSSREASONNAME") if df is None:
if dim_col not in df.columns: return None
return {"items": [], "dimension": dimension, "metric_mode": metric_mode}
# Keep cache-based pareto behavior aligned with primary/view policy filters.
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 {"items": [], "dimension": dimension, "metric_mode": metric_mode}
dim_col = _DIM_TO_DF_COLUMN.get(dimension, "LOSSREASONNAME")
if dim_col not in df.columns:
return {"items": [], "dimension": dimension, "metric_mode": metric_mode}
# Apply supplementary filters # Apply supplementary filters
filtered = _apply_supplementary_filters( filtered = _apply_supplementary_filters(
@@ -834,19 +896,21 @@ def compute_dimension_pareto(
# ============================================================ # ============================================================
def export_csv_from_cache( def export_csv_from_cache(
*, *,
query_id: str, query_id: str,
packages: Optional[List[str]] = None, packages: Optional[List[str]] = None,
workcenter_groups: Optional[List[str]] = None, workcenter_groups: Optional[List[str]] = None,
reason: Optional[str] = None, reason: Optional[str] = None,
metric_filter: str = "all", metric_filter: str = "all",
trend_dates: Optional[List[str]] = None, trend_dates: Optional[List[str]] = None,
detail_reason: Optional[str] = None, detail_reason: Optional[str] = None,
include_excluded_scrap: bool = False, pareto_dimension: Optional[str] = None,
exclude_material_scrap: bool = True, pareto_values: Optional[List[str]] = None,
exclude_pb_diode: bool = True, include_excluded_scrap: bool = False,
) -> Optional[list]: exclude_material_scrap: bool = True,
exclude_pb_diode: bool = True,
) -> Optional[list]:
"""Read cache → apply filters → return list of dicts for CSV export.""" """Read cache → apply filters → return list of dicts for CSV export."""
df = _get_cached_df(query_id) df = _get_cached_df(query_id)
if df is None: if df is None:
@@ -872,12 +936,17 @@ def export_csv_from_cache(
filtered = filtered[ filtered = filtered[
filtered["TXN_DAY"].apply(lambda d: _to_date_str(d) in date_set) filtered["TXN_DAY"].apply(lambda d: _to_date_str(d) in date_set)
] ]
if detail_reason and "LOSSREASONNAME" in filtered.columns: if detail_reason and "LOSSREASONNAME" in filtered.columns:
filtered = filtered[ filtered = filtered[
filtered["LOSSREASONNAME"].str.strip() == detail_reason.strip() filtered["LOSSREASONNAME"].str.strip() == detail_reason.strip()
] ]
filtered = _apply_pareto_selection_filter(
rows = [] filtered,
pareto_dimension=pareto_dimension,
pareto_values=pareto_values,
)
rows = []
for _, row in filtered.iterrows(): for _, row in filtered.iterrows():
rows.append( rows.append(
{ {

View File

@@ -34,6 +34,7 @@ SELECT
p.EQUIPMENTNAME, p.EQUIPMENTNAME,
p.PRIMARY_EQUIPMENTNAME, p.PRIMARY_EQUIPMENTNAME,
p.PRODUCTLINENAME, p.PRODUCTLINENAME,
p.SCRAP_OBJECTTYPE,
p.PJ_TYPE, p.PJ_TYPE,
p.CONTAINERNAME, p.CONTAINERNAME,
p.PJ_FUNCTION, p.PJ_FUNCTION,

View File

@@ -30,16 +30,6 @@ WITH spec_map AS (
WHERE SPEC IS NOT NULL WHERE SPEC IS NOT NULL
GROUP BY SPEC GROUP BY SPEC
), ),
workflow_lookup AS (
SELECT /*+ MATERIALIZE */ DISTINCT w.CONTAINERID, w.WORKFLOWNAME
FROM DWH.DW_MES_WIP w
WHERE w.PRODUCTLINENAME <> '點測'
AND w.CONTAINERID IN (
SELECT DISTINCT r0.CONTAINERID
FROM DWH.DW_MES_LOTREJECTHISTORY r0
WHERE {{ WORKFLOW_FILTER }}
)
),
reject_raw AS ( reject_raw AS (
SELECT SELECT
TRUNC(r.TXNDATE) AS TXN_DAY, TRUNC(r.TXNDATE) AS TXN_DAY,
@@ -56,9 +46,37 @@ reject_raw AS (
NVL(TRIM(r.EQUIPMENTNAME), '(NA)') AS EQUIPMENTNAME, NVL(TRIM(r.EQUIPMENTNAME), '(NA)') AS EQUIPMENTNAME,
NVL( NVL(
TRIM(REGEXP_SUBSTR(r.EQUIPMENTNAME, '[^,]+', 1, 1)), TRIM(REGEXP_SUBSTR(r.EQUIPMENTNAME, '[^,]+', 1, 1)),
NVL(TRIM(r.EQUIPMENTNAME), '(NA)') '(NA)'
) AS PRIMARY_EQUIPMENTNAME, ) AS PRIMARY_EQUIPMENTNAME,
NVL(TRIM(wf.WORKFLOWNAME), NVL(TRIM(r.SPECNAME), '(NA)')) AS WORKFLOWNAME, NVL(
TRIM(lwh.WORKFLOWNAME),
NVL(
TRIM((
SELECT w.WORKFLOWNAME
FROM DWH.DW_MES_WIP w
WHERE c.CONTAINERNAME IS NOT NULL
AND w.CONTAINERNAME = c.CONTAINERNAME
AND NVL(TRIM(w.SPECNAME), '-') = NVL(TRIM(r.SPECNAME), '-')
AND w.TXNDATE <= r.TXNDATE
AND TRIM(w.WORKFLOWNAME) IS NOT NULL
ORDER BY w.TXNDATE DESC
FETCH FIRST 1 ROW ONLY
)),
NVL(
TRIM((
SELECT w.WORKFLOWNAME
FROM DWH.DW_MES_WIP w
WHERE c.CONTAINERNAME IS NOT NULL
AND w.CONTAINERNAME = c.CONTAINERNAME
AND NVL(TRIM(w.SPECNAME), '-') = NVL(TRIM(r.SPECNAME), '-')
AND TRIM(w.WORKFLOWNAME) IS NOT NULL
ORDER BY w.TXNDATE DESC
FETCH FIRST 1 ROW ONLY
)),
'(NA)'
)
)
) AS WORKFLOWNAME,
NVL(TRIM(r.LOSSREASONNAME), '(未填寫)') AS LOSSREASONNAME, NVL(TRIM(r.LOSSREASONNAME), '(未填寫)') AS LOSSREASONNAME,
NVL( NVL(
TRIM(REGEXP_SUBSTR(NVL(TRIM(r.LOSSREASONNAME), '(未填寫)'), '^[^_[:space:]-]+')), TRIM(REGEXP_SUBSTR(NVL(TRIM(r.LOSSREASONNAME), '(未填寫)'), '^[^_[:space:]-]+')),
@@ -87,10 +105,10 @@ reject_raw AS (
FROM DWH.DW_MES_LOTREJECTHISTORY r FROM DWH.DW_MES_LOTREJECTHISTORY r
LEFT JOIN DWH.DW_MES_CONTAINER c LEFT JOIN DWH.DW_MES_CONTAINER c
ON c.CONTAINERID = r.CONTAINERID ON c.CONTAINERID = r.CONTAINERID
LEFT JOIN DWH.DW_MES_LOTWIPHISTORY lwh
ON lwh.WIPTRACKINGGROUPKEYID = r.WIPTRACKINGGROUPKEYID
LEFT JOIN spec_map sm LEFT JOIN spec_map sm
ON sm.SPEC = TRIM(r.SPECNAME) ON sm.SPEC = TRIM(r.SPECNAME)
LEFT JOIN workflow_lookup wf
ON wf.CONTAINERID = r.CONTAINERID
WHERE {{ BASE_WHERE }} WHERE {{ BASE_WHERE }}
), ),
daily_agg AS ( daily_agg AS (

View File

@@ -5,7 +5,7 @@
-- :start_date - Start date (YYYY-MM-DD) -- :start_date - Start date (YYYY-MM-DD)
-- :end_date - End date (YYYY-MM-DD) -- :end_date - End date (YYYY-MM-DD)
WITH spec_map AS ( WITH spec_map AS (
SELECT SELECT
SPEC, SPEC,
MIN(WORK_CENTER) KEEP ( MIN(WORK_CENTER) KEEP (
@@ -18,43 +18,61 @@ WITH spec_map AS (
FROM DWH.DW_MES_SPEC_WORKCENTER_V FROM DWH.DW_MES_SPEC_WORKCENTER_V
WHERE SPEC IS NOT NULL WHERE SPEC IS NOT NULL
GROUP BY SPEC GROUP BY SPEC
), ),
workflow_lookup AS ( reject_raw AS (
SELECT /*+ MATERIALIZE */ DISTINCT w.CONTAINERID, w.WORKFLOWNAME
FROM DWH.DW_MES_WIP w
WHERE w.PRODUCTLINENAME <> '點測'
AND w.CONTAINERID IN (
SELECT DISTINCT r0.CONTAINERID
FROM DWH.DW_MES_LOTREJECTHISTORY r0
WHERE {{ WORKFLOW_FILTER }}
)
),
reject_raw AS (
SELECT SELECT
r.TXNDATE, r.TXNDATE,
TRUNC(r.TXNDATE) AS TXN_DAY, TRUNC(r.TXNDATE) AS TXN_DAY,
TO_CHAR(TRUNC(r.TXNDATE), 'YYYY-MM') AS TXN_MONTH, TO_CHAR(TRUNC(r.TXNDATE), 'YYYY-MM') AS TXN_MONTH,
r.CONTAINERID, r.CONTAINERID,
NVL(TRIM(c.CONTAINERNAME), TRIM(r.CONTAINERID)) AS CONTAINERNAME, NVL(TRIM(c.CONTAINERNAME), TRIM(r.CONTAINERID)) AS CONTAINERNAME,
NVL(TRIM(r.PJ_WORKORDER), TRIM(c.MFGORDERNAME)) AS PJ_WORKORDER, NVL(TRIM(r.PJ_WORKORDER), TRIM(c.MFGORDERNAME)) AS PJ_WORKORDER,
NVL(TRIM(c.PJ_TYPE), '(NA)') AS PJ_TYPE, NVL(TRIM(c.PJ_TYPE), '(NA)') AS PJ_TYPE,
NVL(TRIM(c.PJ_FUNCTION), '(NA)') AS PJ_FUNCTION, NVL(TRIM(c.PJ_FUNCTION), '(NA)') AS PJ_FUNCTION,
NVL(TRIM(c.PRODUCTNAME), '(NA)') AS PRODUCTNAME, NVL(TRIM(c.PRODUCTNAME), '(NA)') AS PRODUCTNAME,
NVL(TRIM(c.PRODUCTLINENAME), '(NA)') AS PRODUCTLINENAME, NVL(TRIM(c.PRODUCTLINENAME), '(NA)') AS PRODUCTLINENAME,
NVL(TRIM(c.OBJECTTYPE), '(NA)') AS SCRAP_OBJECTTYPE, NVL(TRIM(c.OBJECTTYPE), '(NA)') AS SCRAP_OBJECTTYPE,
NVL(TRIM(sm.WORK_CENTER), NVL(TRIM(r.WORKCENTERNAME), '(NA)')) AS WORKCENTERNAME, NVL(TRIM(sm.WORK_CENTER), NVL(TRIM(r.WORKCENTERNAME), '(NA)')) AS WORKCENTERNAME,
NVL(TRIM(sm.WORKCENTER_GROUP), NVL(TRIM(r.WORKCENTERNAME), '(NA)')) AS WORKCENTER_GROUP, NVL(TRIM(sm.WORKCENTER_GROUP), NVL(TRIM(r.WORKCENTERNAME), '(NA)')) AS WORKCENTER_GROUP,
NVL(sm.WORKCENTERSEQUENCE_GROUP, 999) AS WORKCENTERSEQUENCE_GROUP, NVL(sm.WORKCENTERSEQUENCE_GROUP, 999) AS WORKCENTERSEQUENCE_GROUP,
NVL(TRIM(r.SPECNAME), '(NA)') AS SPECNAME, NVL(TRIM(r.SPECNAME), '(NA)') AS SPECNAME,
NVL(TRIM(r.EQUIPMENTNAME), '(NA)') AS EQUIPMENTNAME, NVL(TRIM(r.EQUIPMENTNAME), '(NA)') AS EQUIPMENTNAME,
NVL( NVL(
TRIM(REGEXP_SUBSTR(r.EQUIPMENTNAME, '[^,]+', 1, 1)), TRIM(REGEXP_SUBSTR(r.EQUIPMENTNAME, '[^,]+', 1, 1)),
NVL(TRIM(r.EQUIPMENTNAME), '(NA)') '(NA)'
) AS PRIMARY_EQUIPMENTNAME, ) AS PRIMARY_EQUIPMENTNAME,
NVL(TRIM(wf.WORKFLOWNAME), NVL(TRIM(r.SPECNAME), '(NA)')) AS WORKFLOWNAME, NVL(
NVL(TRIM(r.LOSSREASONNAME), '(未填寫)') AS LOSSREASONNAME, TRIM(lwh.WORKFLOWNAME),
NVL( NVL(
TRIM(REGEXP_SUBSTR(NVL(TRIM(r.LOSSREASONNAME), '(未填寫)'), '^[^_[:space:]-]+')), TRIM((
SELECT w.WORKFLOWNAME
FROM DWH.DW_MES_WIP w
WHERE c.CONTAINERNAME IS NOT NULL
AND w.CONTAINERNAME = c.CONTAINERNAME
AND NVL(TRIM(w.SPECNAME), '-') = NVL(TRIM(r.SPECNAME), '-')
AND w.TXNDATE <= r.TXNDATE
AND TRIM(w.WORKFLOWNAME) IS NOT NULL
ORDER BY w.TXNDATE DESC
FETCH FIRST 1 ROW ONLY
)),
NVL(
TRIM((
SELECT w.WORKFLOWNAME
FROM DWH.DW_MES_WIP w
WHERE c.CONTAINERNAME IS NOT NULL
AND w.CONTAINERNAME = c.CONTAINERNAME
AND NVL(TRIM(w.SPECNAME), '-') = NVL(TRIM(r.SPECNAME), '-')
AND TRIM(w.WORKFLOWNAME) IS NOT NULL
ORDER BY w.TXNDATE DESC
FETCH FIRST 1 ROW ONLY
)),
'(NA)'
)
)
) AS WORKFLOWNAME,
NVL(TRIM(r.LOSSREASONNAME), '(未填寫)') AS LOSSREASONNAME,
NVL(
TRIM(REGEXP_SUBSTR(NVL(TRIM(r.LOSSREASONNAME), '(未填寫)'), '^[^_[:space:]-]+')),
NVL(TRIM(r.LOSSREASONNAME), '(未填寫)') NVL(TRIM(r.LOSSREASONNAME), '(未填寫)')
) AS LOSSREASON_CODE, ) AS LOSSREASON_CODE,
NVL(TRIM(r.REJECTCATEGORYNAME), '(未填寫)') AS REJECTCATEGORYNAME, NVL(TRIM(r.REJECTCATEGORYNAME), '(未填寫)') AS REJECTCATEGORYNAME,
@@ -78,15 +96,15 @@ reject_raw AS (
) )
ORDER BY NVL(TRIM(r.LOSSREASONNAME), ' ') ORDER BY NVL(TRIM(r.LOSSREASONNAME), ' ')
) AS EVENT_RN ) AS EVENT_RN
FROM DWH.DW_MES_LOTREJECTHISTORY r FROM DWH.DW_MES_LOTREJECTHISTORY r
LEFT JOIN DWH.DW_MES_CONTAINER c LEFT JOIN DWH.DW_MES_CONTAINER c
ON c.CONTAINERID = r.CONTAINERID ON c.CONTAINERID = r.CONTAINERID
LEFT JOIN spec_map sm LEFT JOIN DWH.DW_MES_LOTWIPHISTORY lwh
ON sm.SPEC = TRIM(r.SPECNAME) ON lwh.WIPTRACKINGGROUPKEYID = r.WIPTRACKINGGROUPKEYID
LEFT JOIN workflow_lookup wf LEFT JOIN spec_map sm
ON wf.CONTAINERID = r.CONTAINERID ON sm.SPEC = TRIM(r.SPECNAME)
WHERE {{ BASE_WHERE }} WHERE {{ BASE_WHERE }}
), ),
daily_agg AS ( daily_agg AS (
SELECT SELECT
TXN_DAY, TXN_DAY,

View File

@@ -0,0 +1,188 @@
# -*- coding: utf-8 -*-
"""Unit tests for reject_dataset_cache helpers."""
from __future__ import annotations
import pandas as pd
import pytest
from mes_dashboard.services import reject_dataset_cache as cache_svc
def test_compute_dimension_pareto_applies_policy_filters_before_grouping(monkeypatch):
"""Cached pareto should honor the same policy toggles as view/query paths."""
df = pd.DataFrame(
[
{
"CONTAINERID": "C1",
"LOSSREASONNAME": "001_A",
"LOSSREASON_CODE": "001_A",
"SCRAP_OBJECTTYPE": "MATERIAL",
"PRODUCTLINENAME": "(NA)",
"WORKCENTER_GROUP": "WB",
"REJECT_TOTAL_QTY": 100,
"DEFECT_QTY": 0,
"MOVEIN_QTY": 1000,
},
{
"CONTAINERID": "C2",
"LOSSREASONNAME": "001_A",
"LOSSREASON_CODE": "001_A",
"SCRAP_OBJECTTYPE": "LOT",
"PRODUCTLINENAME": "PKG-A",
"WORKCENTER_GROUP": "WB",
"REJECT_TOTAL_QTY": 50,
"DEFECT_QTY": 0,
"MOVEIN_QTY": 900,
},
]
)
monkeypatch.setattr(cache_svc, "_get_cached_df", lambda _query_id: df)
monkeypatch.setattr(
"mes_dashboard.services.scrap_reason_exclusion_cache.get_excluded_reasons",
lambda: [],
)
excluded_material = cache_svc.compute_dimension_pareto(
query_id="qid-1",
dimension="package",
pareto_scope="all",
include_excluded_scrap=False,
exclude_material_scrap=True,
exclude_pb_diode=True,
)
kept_all = cache_svc.compute_dimension_pareto(
query_id="qid-1",
dimension="package",
pareto_scope="all",
include_excluded_scrap=False,
exclude_material_scrap=False,
exclude_pb_diode=True,
)
excluded_labels = {item.get("reason") for item in excluded_material.get("items", [])}
all_labels = {item.get("reason") for item in kept_all.get("items", [])}
assert "PKG-A" in excluded_labels
assert "(NA)" not in excluded_labels
assert "(NA)" in all_labels
def _build_detail_filter_df():
return pd.DataFrame(
[
{
"CONTAINERID": "C1",
"CONTAINERNAME": "LOT-001",
"TXN_DAY": pd.Timestamp("2026-02-01"),
"TXN_TIME": pd.Timestamp("2026-02-01 08:00:00"),
"WORKCENTERSEQUENCE_GROUP": 1,
"WORKCENTER_GROUP": "WB",
"WORKCENTERNAME": "WB-A",
"SPECNAME": "SPEC-A",
"WORKFLOWNAME": "WF-A",
"PRIMARY_EQUIPMENTNAME": "EQ-1",
"EQUIPMENTNAME": "EQ-1",
"PRODUCTLINENAME": "PKG-A",
"PJ_TYPE": "TYPE-A",
"LOSSREASONNAME": "001_A",
"LOSSREASON_CODE": "001_A",
"SCRAP_OBJECTTYPE": "LOT",
"MOVEIN_QTY": 100,
"REJECT_TOTAL_QTY": 30,
"DEFECT_QTY": 0,
},
{
"CONTAINERID": "C2",
"CONTAINERNAME": "LOT-002",
"TXN_DAY": pd.Timestamp("2026-02-01"),
"TXN_TIME": pd.Timestamp("2026-02-01 09:00:00"),
"WORKCENTERSEQUENCE_GROUP": 1,
"WORKCENTER_GROUP": "WB",
"WORKCENTERNAME": "WB-B",
"SPECNAME": "SPEC-B",
"WORKFLOWNAME": "WF-B",
"PRIMARY_EQUIPMENTNAME": "EQ-2",
"EQUIPMENTNAME": "EQ-2",
"PRODUCTLINENAME": "PKG-B",
"PJ_TYPE": "TYPE-B",
"LOSSREASONNAME": "001_A",
"LOSSREASON_CODE": "001_A",
"SCRAP_OBJECTTYPE": "LOT",
"MOVEIN_QTY": 100,
"REJECT_TOTAL_QTY": 20,
"DEFECT_QTY": 0,
},
{
"CONTAINERID": "C3",
"CONTAINERNAME": "LOT-003",
"TXN_DAY": pd.Timestamp("2026-02-01"),
"TXN_TIME": pd.Timestamp("2026-02-01 10:00:00"),
"WORKCENTERSEQUENCE_GROUP": 1,
"WORKCENTER_GROUP": "WB",
"WORKCENTERNAME": "WB-C",
"SPECNAME": "SPEC-C",
"WORKFLOWNAME": "WF-C",
"PRIMARY_EQUIPMENTNAME": "EQ-3",
"EQUIPMENTNAME": "EQ-3",
"PRODUCTLINENAME": "PKG-C",
"PJ_TYPE": "TYPE-C",
"LOSSREASONNAME": "002_B",
"LOSSREASON_CODE": "002_B",
"SCRAP_OBJECTTYPE": "LOT",
"MOVEIN_QTY": 100,
"REJECT_TOTAL_QTY": 10,
"DEFECT_QTY": 0,
},
]
)
def test_apply_view_and_export_share_same_pareto_multi_select_filter(monkeypatch):
df = _build_detail_filter_df()
monkeypatch.setattr(cache_svc, "_get_cached_df", lambda _query_id: df)
monkeypatch.setattr(
"mes_dashboard.services.scrap_reason_exclusion_cache.get_excluded_reasons",
lambda: [],
)
view_result = cache_svc.apply_view(
query_id="qid-2",
pareto_dimension="type",
pareto_values=["TYPE-A", "TYPE-C"],
)
export_rows = cache_svc.export_csv_from_cache(
query_id="qid-2",
pareto_dimension="type",
pareto_values=["TYPE-A", "TYPE-C"],
)
detail_items = view_result["detail"]["items"]
detail_types = {item["PJ_TYPE"] for item in detail_items}
exported_types = {row["TYPE"] for row in export_rows}
assert view_result["detail"]["pagination"]["total"] == 2
assert detail_types == {"TYPE-A", "TYPE-C"}
assert exported_types == {"TYPE-A", "TYPE-C"}
assert len(export_rows) == 2
def test_apply_view_rejects_invalid_pareto_dimension(monkeypatch):
df = _build_detail_filter_df()
monkeypatch.setattr(cache_svc, "_get_cached_df", lambda _query_id: df)
with pytest.raises(ValueError, match="不支援的 pareto_dimension"):
cache_svc.apply_view(
query_id="qid-3",
pareto_dimension="invalid-dimension",
pareto_values=["X"],
)
with pytest.raises(ValueError, match="不支援的 pareto_dimension"):
cache_svc.export_csv_from_cache(
query_id="qid-3",
pareto_dimension="invalid-dimension",
pareto_values=["X"],
)

View File

@@ -218,6 +218,121 @@ class TestRejectHistoryApiRoutes(TestRejectHistoryRoutesBase):
_, kwargs = mock_pareto.call_args _, kwargs = mock_pareto.call_args
self.assertEqual(kwargs['dimension'], 'equipment') self.assertEqual(kwargs['dimension'], 'equipment')
@patch('mes_dashboard.routes.reject_history_routes.query_dimension_pareto')
@patch('mes_dashboard.routes.reject_history_routes.compute_dimension_pareto')
def test_dimension_pareto_with_query_id_passes_policy_flags_to_cached_path(
self,
mock_cached_pareto,
mock_sql_pareto,
):
mock_cached_pareto.return_value = {
'items': [{'reason': 'PKG-A', 'metric_value': 100, 'pct': 100, 'cumPct': 100}],
'dimension': 'package',
'metric_mode': 'reject_total',
'pareto_scope': 'all',
}
response = self.client.get(
'/api/reject-history/reason-pareto'
'?start_date=2026-02-01'
'&end_date=2026-02-07'
'&query_id=qid-001'
'&dimension=package'
'&pareto_scope=all'
'&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_cached_pareto.call_args
self.assertEqual(kwargs['query_id'], 'qid-001')
self.assertEqual(kwargs['dimension'], 'package')
self.assertEqual(kwargs['pareto_scope'], 'all')
self.assertIs(kwargs['include_excluded_scrap'], True)
self.assertIs(kwargs['exclude_material_scrap'], False)
self.assertIs(kwargs['exclude_pb_diode'], False)
mock_sql_pareto.assert_not_called()
@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 = {
'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'
'&pareto_dimension=workflow'
'&pareto_values=WF-A'
'&pareto_values=WF-B'
)
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_dimension'], 'workflow')
self.assertEqual(kwargs['pareto_values'], ['WF-A', 'WF-B'])
@patch('mes_dashboard.routes.reject_history_routes.apply_view')
def test_view_invalid_pareto_dimension_returns_400(self, mock_apply_view):
response = self.client.get(
'/api/reject-history/view'
'?query_id=qid-001'
'&pareto_dimension=invalid'
'&pareto_values=X'
)
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
mock_apply_view.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_pareto_multi_select_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'
'&pareto_dimension=type'
'&pareto_values=TYPE-A'
'&pareto_values=TYPE-C'
)
self.assertEqual(response.status_code, 200)
_, kwargs = mock_export_cached.call_args
self.assertEqual(kwargs['pareto_dimension'], 'type')
self.assertEqual(kwargs['pareto_values'], ['TYPE-A', 'TYPE-C'])
@patch('mes_dashboard.routes.reject_history_routes.export_csv_from_cache')
def test_export_cached_invalid_pareto_dimension_returns_400(self, mock_export_cached):
response = self.client.get(
'/api/reject-history/export-cached'
'?query_id=qid-001'
'&pareto_dimension=invalid'
'&pareto_values=TYPE-A'
)
payload = json.loads(response.data)
self.assertEqual(response.status_code, 400)
self.assertFalse(payload['success'])
mock_export_cached.assert_not_called()
@patch('mes_dashboard.routes.reject_history_routes.query_list') @patch('mes_dashboard.routes.reject_history_routes.query_list')
@patch('mes_dashboard.core.rate_limit.check_and_record', return_value=(True, 6)) @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): def test_list_rate_limited_returns_429(self, _mock_limit, mock_list):