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

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

View File

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

View File

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

View File

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

View File

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

View File

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