feat: polish reject history UI and enhance WIP filter interactions

This commit is contained in:
egg
2026-02-22 11:54:51 +08:00
parent 9687deb9ad
commit 7bf9e33cd5
35 changed files with 3054 additions and 1085 deletions

View File

@@ -5,6 +5,16 @@ function toTrimmedString(value) {
return String(value).trim();
}
function normalizeFilterValue(value) {
if (Array.isArray(value)) {
return value
.map((item) => toTrimmedString(item))
.filter((item) => item.length > 0)
.join(',');
}
return toTrimmedString(value);
}
export function normalizeStatusFilter(statusFilter) {
if (!statusFilter) {
return {};
@@ -20,15 +30,19 @@ export function normalizeStatusFilter(statusFilter) {
export function buildWipOverviewQueryParams(filters = {}, statusFilter = null) {
const params = {};
const workorder = toTrimmedString(filters.workorder);
const lotid = toTrimmedString(filters.lotid);
const pkg = toTrimmedString(filters.package);
const type = toTrimmedString(filters.type);
const workorder = normalizeFilterValue(filters.workorder);
const lotid = normalizeFilterValue(filters.lotid);
const pkg = normalizeFilterValue(filters.package);
const type = normalizeFilterValue(filters.type);
const firstname = normalizeFilterValue(filters.firstname);
const waferdesc = normalizeFilterValue(filters.waferdesc);
if (workorder) params.workorder = workorder;
if (lotid) params.lotid = lotid;
if (pkg) params.package = pkg;
if (type) params.type = type;
if (firstname) params.firstname = firstname;
if (waferdesc) params.waferdesc = waferdesc;
return { ...params, ...normalizeStatusFilter(statusFilter) };
}

View File

@@ -1,17 +1,14 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { BarChart, LineChart } from 'echarts/charts';
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import VChart from 'vue-echarts';
import { apiGet } from '../core/api.js';
import { replaceRuntimeHistory } from '../core/shell-navigation.js';
import MultiSelect from '../resource-shared/components/MultiSelect.vue';
use([CanvasRenderer, BarChart, LineChart, GridComponent, TooltipComponent, LegendComponent]);
import DetailTable from './components/DetailTable.vue';
import FilterPanel from './components/FilterPanel.vue';
import ParetoSection from './components/ParetoSection.vue';
import SummaryCards from './components/SummaryCards.vue';
import TrendChart from './components/TrendChart.vue';
const API_TIMEOUT = 60000;
const DEFAULT_PER_PAGE = 50;
@@ -24,11 +21,14 @@ const filters = reactive({
reason: '',
includeExcludedScrap: false,
excludeMaterialScrap: true,
excludePbDiode: true,
paretoTop80: true,
});
const page = ref(1);
const detailReason = ref('');
const selectedTrendDates = ref([]);
const trendLegendSelected = ref({ '扣帳報廢量': true, '不扣帳報廢量': true });
const options = reactive({
workcenterGroups: [],
@@ -48,7 +48,7 @@ const summary = ref({
});
const trend = ref({ items: [], granularity: 'day' });
const pareto = ref({ items: [], metric_mode: 'reject_total', pareto_scope: 'top80' });
const analyticsRawItems = ref([]);
const detail = ref({
items: [],
pagination: {
@@ -64,7 +64,6 @@ const loading = reactive({
querying: false,
options: false,
list: false,
pareto: false,
});
const errorMessage = ref('');
@@ -150,9 +149,14 @@ function restoreFromUrl() {
if (detailReasonFromUrl) {
detailReason.value = detailReasonFromUrl;
}
const trendDates = readArrayParam(params, 'trend_dates');
if (trendDates.length > 0) {
selectedTrendDates.value = trendDates;
}
filters.includeExcludedScrap = readBooleanParam(params, 'include_excluded_scrap', false);
filters.excludeMaterialScrap = readBooleanParam(params, 'exclude_material_scrap', true);
filters.excludePbDiode = readBooleanParam(params, 'exclude_pb_diode', true);
filters.paretoTop80 = !readBooleanParam(params, 'pareto_scope_all', false);
const parsedPage = Number(params.get('page') || '1');
@@ -173,11 +177,13 @@ function updateUrlState() {
if (detailReason.value) {
params.set('detail_reason', detailReason.value);
}
selectedTrendDates.value.forEach((d) => params.append('trend_dates', d));
if (filters.includeExcludedScrap) {
params.set('include_excluded_scrap', 'true');
}
params.set('exclude_material_scrap', String(filters.excludeMaterialScrap));
params.set('exclude_pb_diode', String(filters.excludePbDiode));
if (!filters.paretoTop80) {
params.set('pareto_scope_all', 'true');
@@ -190,14 +196,6 @@ function updateUrlState() {
replaceRuntimeHistory(`/reject-history?${params.toString()}`);
}
function formatNumber(value) {
return Number(value || 0).toLocaleString('zh-TW');
}
function formatPct(value) {
return `${Number(value || 0).toFixed(2)}%`;
}
function unwrapApiResult(result, fallbackMessage) {
if (result?.success === true) {
return result;
@@ -216,6 +214,7 @@ function buildCommonParams({ reason = filters.reason } = {}) {
packages: filters.packages,
include_excluded_scrap: filters.includeExcludedScrap,
exclude_material_scrap: filters.excludeMaterialScrap,
exclude_pb_diode: filters.excludePbDiode,
};
if (reason) {
@@ -225,21 +224,19 @@ function buildCommonParams({ reason = filters.reason } = {}) {
return params;
}
function buildParetoParams() {
return {
...buildCommonParams({ reason: filters.reason }),
metric_mode: 'reject_total',
pareto_scope: filters.paretoTop80 ? 'top80' : 'all',
};
}
function buildListParams() {
const effectiveReason = detailReason.value || filters.reason;
return {
const params = {
...buildCommonParams({ reason: effectiveReason }),
page: page.value,
per_page: DEFAULT_PER_PAGE,
};
if (selectedTrendDates.value.length > 0) {
const sorted = [...selectedTrendDates.value].sort();
params.start_date = sorted[0];
params.end_date = sorted[sorted.length - 1];
}
return params;
}
async function fetchOptions() {
@@ -249,6 +246,7 @@ async function fetchOptions() {
end_date: filters.endDate,
include_excluded_scrap: filters.includeExcludedScrap,
exclude_material_scrap: filters.excludeMaterialScrap,
exclude_pb_diode: filters.excludePbDiode,
},
timeout: API_TIMEOUT,
});
@@ -256,33 +254,15 @@ async function fetchOptions() {
return payload.data || {};
}
async function fetchSummary() {
const response = await apiGet('/api/reject-history/summary', {
params: buildCommonParams(),
timeout: API_TIMEOUT,
});
const payload = unwrapApiResult(response, '載入摘要資料失敗');
return payload;
}
async function fetchTrend() {
const response = await apiGet('/api/reject-history/trend', {
async function fetchAnalytics() {
const response = await apiGet('/api/reject-history/analytics', {
params: {
...buildCommonParams(),
granularity: 'day',
metric_mode: 'reject_total',
},
timeout: API_TIMEOUT,
});
const payload = unwrapApiResult(response, '載入趨勢資料失敗');
return payload;
}
async function fetchPareto() {
const response = await apiGet('/api/reject-history/reason-pareto', {
params: buildParetoParams(),
timeout: API_TIMEOUT,
});
const payload = unwrapApiResult(response, '載入柏拉圖資料失敗');
const payload = unwrapApiResult(response, '載入分析資料失敗');
return payload;
}
@@ -319,11 +299,10 @@ async function loadAllData({ loadOptions = true } = {}) {
loading.querying = true;
loading.list = true;
loading.pareto = true;
errorMessage.value = '';
try {
const tasks = [fetchSummary(), fetchTrend(), fetchPareto(), fetchList()];
const tasks = [fetchAnalytics(), fetchList()];
if (loadOptions) {
loading.options = true;
tasks.push(fetchOptions());
@@ -334,17 +313,16 @@ async function loadAllData({ loadOptions = true } = {}) {
return;
}
const [summaryResp, trendResp, paretoResp, listResp, optionsResp] = responses;
const [analyticsResp, listResp, optionsResp] = responses;
summary.value = summaryResp.data || summary.value;
trend.value = trendResp.data || trend.value;
pareto.value = paretoResp.data || pareto.value;
const analyticsData = analyticsResp.data || {};
summary.value = analyticsData.summary || summary.value;
trend.value = analyticsData.trend || trend.value;
analyticsRawItems.value = Array.isArray(analyticsData.raw_items) ? analyticsData.raw_items : [];
detail.value = listResp.data || detail.value;
const meta = {
...(summaryResp.meta || {}),
...(trendResp.meta || {}),
...(paretoResp.meta || {}),
...(analyticsResp.meta || {}),
...(listResp.meta || {}),
};
mergePolicyMeta(meta);
@@ -377,7 +355,6 @@ async function loadAllData({ loadOptions = true } = {}) {
loading.querying = false;
loading.options = false;
loading.list = false;
loading.pareto = false;
}
}
@@ -407,35 +384,10 @@ async function loadListOnly() {
}
}
async function loadParetoOnly() {
const requestId = nextRequestId();
loading.pareto = true;
errorMessage.value = '';
try {
const paretoResp = await fetchPareto();
if (isStaleRequest(requestId)) {
return;
}
pareto.value = paretoResp.data || pareto.value;
mergePolicyMeta(paretoResp.meta || {});
updateUrlState();
} catch (error) {
if (isStaleRequest(requestId)) {
return;
}
errorMessage.value = error?.message || '載入柏拉圖資料失敗';
} finally {
if (isStaleRequest(requestId)) {
return;
}
loading.pareto = false;
}
}
function applyFilters() {
page.value = 1;
detailReason.value = '';
selectedTrendDates.value = [];
void loadAllData({ loadOptions: true });
}
@@ -445,8 +397,10 @@ function clearFilters() {
filters.packages = [];
filters.reason = '';
detailReason.value = '';
selectedTrendDates.value = [];
filters.includeExcludedScrap = false;
filters.excludeMaterialScrap = true;
filters.excludePbDiode = true;
filters.paretoTop80 = true;
page.value = 1;
void loadAllData({ loadOptions: true });
@@ -460,6 +414,25 @@ function goToPage(nextPage) {
void loadListOnly();
}
function onTrendDateClick(dateStr) {
if (!dateStr) {
return;
}
const idx = selectedTrendDates.value.indexOf(dateStr);
if (idx >= 0) {
selectedTrendDates.value = selectedTrendDates.value.filter((d) => d !== dateStr);
} else {
selectedTrendDates.value = [...selectedTrendDates.value, dateStr];
}
page.value = 1;
void loadListOnly();
}
function onTrendLegendChange(selected) {
trendLegendSelected.value = selected;
updateUrlState();
}
function onParetoClick(reason) {
if (!reason) {
return;
@@ -471,7 +444,7 @@ function onParetoClick(reason) {
function handleParetoScopeToggle(checked) {
filters.paretoTop80 = Boolean(checked);
void loadParetoOnly();
updateUrlState();
}
function removeFilterChip(chip) {
@@ -491,6 +464,11 @@ function removeFilterChip(chip) {
page.value = 1;
void loadListOnly();
return;
} else if (chip.type === 'trend-dates') {
selectedTrendDates.value = [];
page.value = 1;
void loadListOnly();
return;
} else {
return;
}
@@ -505,6 +483,7 @@ function exportCsv() {
params.set('end_date', filters.endDate);
params.set('include_excluded_scrap', String(filters.includeExcludedScrap));
params.set('exclude_material_scrap', String(filters.excludeMaterialScrap));
params.set('exclude_pb_diode', String(filters.excludePbDiode));
filters.workcenterGroups.forEach((item) => params.append('workcenter_groups', item));
filters.packages.forEach((item) => params.append('packages', item));
@@ -520,6 +499,85 @@ const totalScrapQty = computed(() => {
return Number(summary.value.REJECT_TOTAL_QTY || 0) + Number(summary.value.DEFECT_QTY || 0);
});
const paretoMetricMode = computed(() => {
const s = trendLegendSelected.value;
const rejectOn = s['扣帳報廢量'] !== false;
const defectOn = s['不扣帳報廢量'] !== false;
if (rejectOn && defectOn) return 'all';
if (rejectOn) return 'reject';
if (defectOn) return 'defect';
return 'none';
});
const paretoMetricLabel = computed(() => {
switch (paretoMetricMode.value) {
case 'reject': return '扣帳報廢量';
case 'defect': return '不扣帳報廢量';
case 'none': return '報廢量';
default: return '全部報廢量';
}
});
const allParetoItems = computed(() => {
const raw = analyticsRawItems.value;
if (!raw || raw.length === 0) return [];
const mode = paretoMetricMode.value;
if (mode === 'none') return [];
const dateSet = selectedTrendDates.value.length > 0 ? new Set(selectedTrendDates.value) : null;
const filtered = dateSet ? raw.filter((r) => dateSet.has(r.bucket_date)) : raw;
if (filtered.length === 0) return [];
const map = new Map();
for (const item of filtered) {
const key = item.reason;
if (!map.has(key)) {
map.set(key, { reason: key, MOVEIN_QTY: 0, REJECT_TOTAL_QTY: 0, DEFECT_QTY: 0, AFFECTED_LOT_COUNT: 0 });
}
const acc = map.get(key);
acc.MOVEIN_QTY += Number(item.MOVEIN_QTY || 0);
acc.REJECT_TOTAL_QTY += Number(item.REJECT_TOTAL_QTY || 0);
acc.DEFECT_QTY += Number(item.DEFECT_QTY || 0);
acc.AFFECTED_LOT_COUNT += Number(item.AFFECTED_LOT_COUNT || 0);
}
const withMetric = Array.from(map.values()).map((row) => {
let mv;
if (mode === 'all') mv = row.REJECT_TOTAL_QTY + row.DEFECT_QTY;
else if (mode === 'reject') mv = row.REJECT_TOTAL_QTY;
else mv = row.DEFECT_QTY;
return { ...row, metric_value: mv };
});
const sorted = withMetric.filter((r) => r.metric_value > 0).sort((a, b) => b.metric_value - a.metric_value);
const total = sorted.reduce((sum, r) => sum + r.metric_value, 0);
let cum = 0;
return sorted.map((row) => {
const pct = total ? Number(((row.metric_value / total) * 100).toFixed(4)) : 0;
cum += pct;
return {
reason: row.reason,
metric_value: row.metric_value,
MOVEIN_QTY: row.MOVEIN_QTY,
REJECT_TOTAL_QTY: row.REJECT_TOTAL_QTY,
DEFECT_QTY: row.DEFECT_QTY,
count: row.AFFECTED_LOT_COUNT,
pct,
cumPct: Number(cum.toFixed(4)),
};
});
});
const filteredParetoItems = computed(() => {
const items = allParetoItems.value || [];
if (!filters.paretoTop80 || items.length === 0) {
return items;
}
const top = items.filter((item) => Number(item.cumPct || 0) <= 80);
return top.length > 0 ? top : [items[0]];
});
const activeFilterChips = computed(() => {
const chips = [
{
@@ -543,6 +601,13 @@ const activeFilterChips = computed(() => {
type: 'policy',
value: '',
},
{
key: 'pb-diode-policy',
label: filters.excludePbDiode ? 'PB_Diode: 已排除' : 'PB_Diode: 已納入',
removable: false,
type: 'policy',
value: '',
},
];
if (filters.reason) {
@@ -554,6 +619,19 @@ const activeFilterChips = computed(() => {
value: filters.reason,
});
}
if (selectedTrendDates.value.length > 0) {
const dates = selectedTrendDates.value;
const label = dates.length === 1
? `趨勢日期: ${dates[0]}`
: `趨勢日期: ${dates.length}`;
chips.push({
key: 'trend-dates',
label,
removable: true,
type: 'trend-dates',
value: '',
});
}
if (detailReason.value) {
chips.push({
key: `detail-reason:${detailReason.value}`,
@@ -598,135 +676,6 @@ const kpiCards = computed(() => {
];
});
const quantityChartOption = computed(() => {
const items = Array.isArray(trend.value?.items) ? trend.value.items : [];
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
},
legend: {
data: ['扣帳報廢量', '不扣帳報廢量'],
bottom: 0,
},
grid: { left: 48, right: 24, top: 22, bottom: 70 },
xAxis: {
type: 'category',
data: items.map((item) => item.bucket_date || ''),
},
yAxis: {
type: 'value',
axisLabel: {
formatter(value) {
return Number(value || 0).toLocaleString('zh-TW');
},
},
},
series: [
{
name: '扣帳報廢量',
type: 'bar',
data: items.map((item) => Number(item.REJECT_TOTAL_QTY || 0)),
itemStyle: { color: '#dc2626' },
barMaxWidth: 28,
},
{
name: '不扣帳報廢量',
type: 'bar',
data: items.map((item) => Number(item.DEFECT_QTY || 0)),
itemStyle: { color: '#0284c7' },
barMaxWidth: 28,
},
],
};
});
const paretoChartOption = computed(() => {
const items = Array.isArray(pareto.value?.items) ? pareto.value.items : [];
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
formatter(params) {
const idx = Number(params?.[0]?.dataIndex || 0);
const item = items[idx] || {};
return [
`<b>${item.reason || '(未填寫)'}</b>`,
`報廢量: ${formatNumber(item.metric_value || 0)}`,
`占比: ${Number(item.pct || 0).toFixed(2)}%`,
`累計: ${Number(item.cumPct || 0).toFixed(2)}%`,
].join('<br/>');
},
},
legend: {
data: ['報廢量', '累積%'],
bottom: 0,
},
grid: {
left: 52,
right: 52,
top: 20,
bottom: 96,
},
xAxis: {
type: 'category',
data: items.map((item) => item.reason || '(未填寫)'),
axisLabel: {
interval: 0,
rotate: items.length > 6 ? 35 : 0,
fontSize: 11,
overflow: 'truncate',
width: 100,
},
},
yAxis: [
{
type: 'value',
name: '量',
},
{
type: 'value',
name: '%',
min: 0,
max: 100,
axisLabel: { formatter: '{value}%' },
},
],
series: [
{
name: '報廢量',
type: 'bar',
data: items.map((item) => Number(item.metric_value || 0)),
barMaxWidth: 34,
itemStyle: {
color(params) {
const reason = items[params.dataIndex]?.reason || '';
return reason === detailReason.value ? '#b91c1c' : '#2563eb';
},
borderRadius: [4, 4, 0, 0],
},
},
{
name: '累積%',
type: 'line',
yAxisIndex: 1,
data: items.map((item) => Number(item.cumPct || 0)),
lineStyle: { color: '#f59e0b', width: 2 },
itemStyle: { color: '#f59e0b' },
symbolSize: 6,
},
],
};
});
function onParetoChartClick(params) {
if (params?.seriesType !== 'bar') {
return;
}
const selected = pareto.value?.items?.[params.dataIndex]?.reason;
onParetoClick(selected);
}
const pagination = computed(() => detail.value?.pagination || {
page: 1,
perPage: DEFAULT_PER_PAGE,
@@ -734,9 +683,6 @@ const pagination = computed(() => detail.value?.pagination || {
totalPages: 1,
});
const hasTrendData = computed(() => Array.isArray(trend.value?.items) && trend.value.items.length > 0);
const hasParetoData = computed(() => Array.isArray(pareto.value?.items) && pareto.value.items.length > 0);
onMounted(() => {
setDefaultDateRange();
restoreFromUrl();
@@ -752,217 +698,54 @@ onMounted(() => {
</div>
<div class="header-right">
<div class="last-update" v-if="lastQueryAt">更新時間{{ lastQueryAt }}</div>
<button type="button" class="btn btn-light" :disabled="loading.querying" @click="applyFilters">重新整理</button>
</div>
</header>
<div v-if="errorMessage" class="error-banner">{{ errorMessage }}</div>
<section class="card">
<div class="card-header">
<div class="card-title">查詢條件</div>
</div>
<div class="card-body filter-panel">
<div class="filter-group">
<label class="filter-label" for="start-date">開始日期</label>
<input id="start-date" v-model="filters.startDate" type="date" class="filter-input" />
</div>
<div class="filter-group">
<label class="filter-label" for="end-date">結束日期</label>
<input id="end-date" v-model="filters.endDate" type="date" class="filter-input" />
</div>
<FilterPanel
:filters="filters"
:options="options"
:loading="loading"
:active-filter-chips="activeFilterChips"
@apply="applyFilters"
@clear="clearFilters"
@export-csv="exportCsv"
@remove-chip="removeFilterChip"
@pareto-scope-toggle="handleParetoScopeToggle"
/>
<div class="filter-group">
<label class="filter-label">Package</label>
<MultiSelect
:model-value="filters.packages"
:options="options.packages"
placeholder="全部 Package"
searchable
@update:model-value="filters.packages = $event"
/>
</div>
<SummaryCards :cards="kpiCards" />
<div class="filter-group filter-group-wide">
<label class="filter-label">WORKCENTER GROUP</label>
<MultiSelect
:model-value="filters.workcenterGroups"
:options="options.workcenterGroups"
placeholder="全部工作中心群組"
searchable
@update:model-value="filters.workcenterGroups = $event"
/>
</div>
<TrendChart
:items="trend.items"
:selected-dates="selectedTrendDates"
:loading="loading.querying"
@date-click="onTrendDateClick"
@legend-change="onTrendLegendChange"
/>
<div class="filter-group filter-group-wide">
<label class="filter-label" for="reason">報廢原因</label>
<select id="reason" v-model="filters.reason" class="filter-input">
<option value="">全部原因</option>
<option v-for="reason in options.reasons" :key="reason" :value="reason">
{{ reason }}
</option>
</select>
</div>
<ParetoSection
:items="filteredParetoItems"
:detail-reason="detailReason"
:selected-dates="selectedTrendDates"
:metric-label="paretoMetricLabel"
:loading="loading.querying"
@reason-click="onParetoClick"
/>
<div class="filter-group filter-group-wide inline-toggle-group">
<div class="checkbox-row">
<label class="checkbox-pill">
<input v-model="filters.includeExcludedScrap" type="checkbox" />
納入不計良率報廢
</label>
<label class="checkbox-pill">
<input v-model="filters.excludeMaterialScrap" type="checkbox" />
排除原物料報廢
</label>
<label class="checkbox-pill">
<input
:checked="filters.paretoTop80"
type="checkbox"
@change="handleParetoScopeToggle($event.target.checked)"
/>
Pareto 僅顯示累計前 80%
</label>
</div>
</div>
<DetailTable
:items="detail.items"
:pagination="pagination"
:loading="loading.list"
:detail-reason="detailReason"
@go-to-page="goToPage"
@clear-reason="onParetoClick(detailReason)"
/>
</div>
<div class="filter-actions">
<button class="btn btn-primary" :disabled="loading.querying" @click="applyFilters">查詢</button>
<button class="btn btn-secondary" :disabled="loading.querying" @click="clearFilters">清除條件</button>
<button class="btn btn-light btn-export" :disabled="loading.querying" @click="exportCsv">匯出 CSV</button>
</div>
</div>
<div class="card-body active-filter-chip-row" v-if="activeFilterChips.length > 0">
<div class="filter-label">套用中篩選</div>
<div class="chip-list">
<div v-for="chip in activeFilterChips" :key="chip.key" class="filter-chip">
<span>{{ chip.label }}</span>
<button
v-if="chip.removable"
type="button"
class="chip-remove"
@click="removeFilterChip(chip)"
>
×
</button>
</div>
</div>
</div>
</section>
<section class="summary-row reject-summary-row">
<article
v-for="card in kpiCards"
:key="card.key"
class="summary-card"
:class="`lane-${card.lane}`"
>
<div class="summary-label">{{ card.label }}</div>
<div class="summary-value small">{{ card.isPct ? formatPct(card.value) : formatNumber(card.value) }}</div>
</article>
</section>
<section class="chart-grid">
<article class="card">
<div class="card-header"><div class="card-title">報廢量趨勢</div></div>
<div class="card-body chart-wrap">
<VChart :option="quantityChartOption" autoresize />
<div v-if="!hasTrendData && !loading.querying" class="placeholder chart-empty">No data</div>
</div>
</article>
</section>
<section class="card">
<div class="card-header pareto-header">
<div class="card-title">報廢量 vs 報廢原因Pareto</div>
</div>
<div class="card-body pareto-layout">
<div class="pareto-chart-wrap">
<VChart :option="paretoChartOption" autoresize @click="onParetoChartClick" />
<div v-if="!hasParetoData && !loading.pareto" class="placeholder chart-empty">No data</div>
</div>
<div class="pareto-table-wrap">
<table class="detail-table pareto-table">
<thead>
<tr>
<th>原因</th>
<th>報廢量</th>
<th>占比</th>
<th>累積</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in pareto.items"
:key="item.reason"
:class="{ active: detailReason === item.reason }"
>
<td>
<button class="reason-link" type="button" @click="onParetoClick(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="!pareto.items || pareto.items.length === 0">
<td colspan="4" class="placeholder">No data</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<section class="card">
<div class="card-header">
<div class="card-title">明細列表</div>
</div>
<div class="card-body detail-table-wrap">
<table class="detail-table">
<thead>
<tr>
<th>日期</th>
<th>WORKCENTER_GROUP</th>
<th>WORKCENTER</th>
<th>Package</th>
<th>原因</th>
<th>REJECT_TOTAL_QTY</th>
<th>DEFECT_QTY</th>
<th>REJECT_QTY</th>
<th>STANDBY_QTY</th>
<th>QTYTOPROCESS_QTY</th>
<th>INPROCESS_QTY</th>
<th>PROCESSED_QTY</th>
</tr>
</thead>
<tbody>
<tr v-for="row in detail.items" :key="`${row.TXN_DAY}-${row.WORKCENTERNAME}-${row.LOSSREASONNAME}`">
<td>{{ row.TXN_DAY }}</td>
<td>{{ row.WORKCENTER_GROUP }}</td>
<td>{{ row.WORKCENTERNAME }}</td>
<td>{{ row.PRODUCTLINENAME }}</td>
<td>{{ row.LOSSREASONNAME }}</td>
<td>{{ formatNumber(row.REJECT_TOTAL_QTY) }}</td>
<td>{{ formatNumber(row.DEFECT_QTY) }}</td>
<td>{{ formatNumber(row.REJECT_QTY) }}</td>
<td>{{ formatNumber(row.STANDBY_QTY) }}</td>
<td>{{ formatNumber(row.QTYTOPROCESS_QTY) }}</td>
<td>{{ formatNumber(row.INPROCESS_QTY) }}</td>
<td>{{ formatNumber(row.PROCESSED_QTY) }}</td>
</tr>
<tr v-if="!detail.items || detail.items.length === 0">
<td colspan="12" class="placeholder">No data</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination">
<button :disabled="pagination.page <= 1 || loading.list" @click="goToPage(pagination.page - 1)">Prev</button>
<span class="page-info">
Page {{ pagination.page }} / {{ pagination.totalPages }} · Total {{ formatNumber(pagination.total) }}
</span>
<button :disabled="pagination.page >= pagination.totalPages || loading.list" @click="goToPage(pagination.page + 1)">Next</button>
</div>
</section>
<div v-if="loading.initial" class="loading-overlay">
<span class="loading-spinner"></span>
</div>
</template>

View File

@@ -0,0 +1,85 @@
<script setup>
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']);
function formatNumber(value) {
return Number(value || 0).toLocaleString('zh-TW');
}
</script>
<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-body detail-table-wrap">
<table class="detail-table">
<thead>
<tr>
<th>日期</th>
<th>LOT</th>
<th>WORKCENTER_GROUP</th>
<th>WORKCENTER</th>
<th>Package</th>
<th>PJ_TYPE</th>
<th>PJ_FUNCTION</th>
<th>PRODUCT</th>
<th>原因</th>
<th>扣帳報廢量</th>
<th>不扣帳報廢量</th>
<th>REJECT_QTY</th>
<th>STANDBY_QTY</th>
<th>QTYTOPROCESS</th>
<th>INPROCESS</th>
<th>PROCESSED</th>
</tr>
</thead>
<tbody>
<tr v-for="(row, idx) in items" :key="`${row.TXN_DAY}-${row.WORKCENTERNAME}-${row.CONTAINERNAME}-${row.LOSSREASONNAME}-${idx}`">
<td>{{ row.TXN_DAY }}</td>
<td>{{ row.CONTAINERNAME || '' }}</td>
<td>{{ row.WORKCENTER_GROUP }}</td>
<td>{{ row.WORKCENTERNAME }}</td>
<td>{{ row.PRODUCTLINENAME }}</td>
<td>{{ row.PJ_TYPE }}</td>
<td>{{ row.PJ_FUNCTION || '' }}</td>
<td>{{ row.PRODUCTNAME || '' }}</td>
<td>{{ row.LOSSREASONNAME }}</td>
<td>{{ formatNumber(row.REJECT_TOTAL_QTY) }}</td>
<td>{{ formatNumber(row.DEFECT_QTY) }}</td>
<td>{{ formatNumber(row.REJECT_QTY) }}</td>
<td>{{ formatNumber(row.STANDBY_QTY) }}</td>
<td>{{ formatNumber(row.QTYTOPROCESS_QTY) }}</td>
<td>{{ formatNumber(row.INPROCESS_QTY) }}</td>
<td>{{ formatNumber(row.PROCESSED_QTY) }}</td>
</tr>
<tr v-if="!items || items.length === 0">
<td colspan="16" class="placeholder">No data</td>
</tr>
</tbody>
</table>
</div>
<div class="pagination">
<button :disabled="pagination.page <= 1 || loading" @click="$emit('go-to-page', pagination.page - 1)">上一頁</button>
<span class="page-info">
{{ pagination.page }} / {{ pagination.totalPages }} · {{ formatNumber(pagination.total) }}
</span>
<button :disabled="pagination.page >= pagination.totalPages || loading" @click="$emit('go-to-page', pagination.page + 1)">下一頁</button>
</div>
</section>
</template>

View File

@@ -0,0 +1,108 @@
<script setup>
import MultiSelect from '../../resource-shared/components/MultiSelect.vue';
defineProps({
filters: { type: Object, required: true },
options: { type: Object, required: true },
loading: { type: Object, required: true },
activeFilterChips: { type: Array, default: () => [] },
});
defineEmits(['apply', 'clear', 'export-csv', 'remove-chip', 'pareto-scope-toggle']);
</script>
<template>
<section class="card">
<div class="card-header">
<div class="card-title">查詢條件</div>
</div>
<div class="card-body filter-panel">
<div class="filter-group">
<label class="filter-label" for="start-date">開始日期</label>
<input id="start-date" v-model="filters.startDate" type="date" class="filter-input" />
</div>
<div class="filter-group">
<label class="filter-label" for="end-date">結束日期</label>
<input id="end-date" v-model="filters.endDate" type="date" class="filter-input" />
</div>
<div class="filter-group">
<label class="filter-label">Package</label>
<MultiSelect
:model-value="filters.packages"
:options="options.packages"
placeholder="全部 Package"
searchable
@update:model-value="filters.packages = $event"
/>
</div>
<div class="filter-group">
<label class="filter-label" for="reason">報廢原因</label>
<select id="reason" v-model="filters.reason" class="filter-input">
<option value="">全部原因</option>
<option v-for="reason in options.reasons" :key="reason" :value="reason">
{{ reason }}
</option>
</select>
</div>
<div class="filter-group filter-group-full">
<label class="filter-label">WORKCENTER GROUP</label>
<MultiSelect
:model-value="filters.workcenterGroups"
:options="options.workcenterGroups"
placeholder="全部工作中心群組"
searchable
@update:model-value="filters.workcenterGroups = $event"
/>
</div>
<div class="filter-toolbar">
<div class="checkbox-row">
<label class="checkbox-pill">
<input v-model="filters.includeExcludedScrap" type="checkbox" />
納入不計良率報廢
</label>
<label class="checkbox-pill">
<input v-model="filters.excludeMaterialScrap" type="checkbox" />
排除原物料報廢
</label>
<label class="checkbox-pill">
<input v-model="filters.excludePbDiode" type="checkbox" />
排除 PB_Diode
</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">
<button class="btn btn-primary" :disabled="loading.querying" @click="$emit('apply')">查詢</button>
<button class="btn btn-secondary" :disabled="loading.querying" @click="$emit('clear')">清除條件</button>
<button class="btn btn-light btn-export" :disabled="loading.querying" @click="$emit('export-csv')">匯出 CSV</button>
</div>
</div>
</div>
<div class="card-body active-filter-chip-row" v-if="activeFilterChips.length > 0">
<div class="filter-label">套用中篩選</div>
<div class="chip-list">
<div v-for="chip in activeFilterChips" :key="chip.key" class="filter-chip">
<span>{{ chip.label }}</span>
<button
v-if="chip.removable"
type="button"
class="chip-remove"
@click="$emit('remove-chip', chip)"
>
×
</button>
</div>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,167 @@
<script setup>
import { computed } from 'vue';
import { BarChart, LineChart } from 'echarts/charts';
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import VChart from 'vue-echarts';
use([CanvasRenderer, BarChart, LineChart, GridComponent, TooltipComponent, LegendComponent]);
const props = defineProps({
items: { type: Array, default: () => [] },
detailReason: { type: String, default: '' },
selectedDates: { type: Array, default: () => [] },
metricLabel: { type: String, default: '報廢量' },
loading: { type: Boolean, default: false },
});
const emit = defineEmits(['reason-click']);
const hasData = computed(() => Array.isArray(props.items) && props.items.length > 0);
function formatNumber(value) {
return Number(value || 0).toLocaleString('zh-TW');
}
function formatPct(value) {
return `${Number(value || 0).toFixed(2)}%`;
}
const chartOption = computed(() => {
const items = props.items || [];
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
formatter(params) {
const idx = Number(params?.[0]?.dataIndex || 0);
const item = items[idx] || {};
return [
`<b>${item.reason || '(未填寫)'}</b>`,
`${props.metricLabel}: ${formatNumber(item.metric_value || 0)}`,
`占比: ${Number(item.pct || 0).toFixed(2)}%`,
`累計: ${Number(item.cumPct || 0).toFixed(2)}%`,
].join('<br/>');
},
},
legend: {
data: [props.metricLabel, '累積%'],
bottom: 0,
},
grid: {
left: 52,
right: 52,
top: 20,
bottom: 96,
},
xAxis: {
type: 'category',
data: items.map((item) => item.reason || '(未填寫)'),
axisLabel: {
interval: 0,
rotate: items.length > 6 ? 35 : 0,
fontSize: 11,
overflow: 'truncate',
width: 100,
},
},
yAxis: [
{
type: 'value',
name: '量',
},
{
type: 'value',
name: '%',
min: 0,
max: 100,
axisLabel: { formatter: '{value}%' },
},
],
series: [
{
name: props.metricLabel,
type: 'bar',
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],
},
},
{
name: '累積%',
type: 'line',
yAxisIndex: 1,
data: items.map((item) => Number(item.cumPct || 0)),
lineStyle: { color: '#f59e0b', width: 2 },
itemStyle: { color: '#f59e0b' },
symbolSize: 6,
},
],
};
});
function handleChartClick(params) {
if (params?.seriesType !== 'bar') {
return;
}
const reason = props.items?.[params.dataIndex]?.reason;
if (reason) {
emit('reason-click', reason);
}
}
</script>
<template>
<section class="card">
<div class="card-header pareto-header">
<div class="card-title">
{{ metricLabel }} vs 報廢原因Pareto
<span v-for="d in selectedDates" :key="d" class="pareto-date-badge">{{ d }}</span>
</div>
</div>
<div class="card-body pareto-layout">
<div class="pareto-chart-wrap">
<VChart :option="chartOption" autoresize @click="handleChartClick" />
<div v-if="!hasData && !loading" class="placeholder chart-empty">No data</div>
</div>
<div class="pareto-table-wrap">
<table class="detail-table pareto-table">
<thead>
<tr>
<th>原因</th>
<th>{{ metricLabel }}</th>
<th>占比</th>
<th>累積</th>
</tr>
</thead>
<tbody>
<tr
v-for="item in items"
:key="item.reason"
:class="{ active: detailReason === item.reason }"
>
<td>
<button class="reason-link" type="button" @click="$emit('reason-click', 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">
<td colspan="4" class="placeholder">No data</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
</template>

View File

@@ -0,0 +1,27 @@
<script setup>
defineProps({
cards: { type: Array, default: () => [] },
});
function formatNumber(value) {
return Number(value || 0).toLocaleString('zh-TW');
}
function formatPct(value) {
return `${Number(value || 0).toFixed(2)}%`;
}
</script>
<template>
<section class="summary-row reject-summary-row">
<article
v-for="card in cards"
:key="card.key"
class="summary-card"
:class="`lane-${card.lane}`"
>
<div class="summary-label">{{ card.label }}</div>
<div class="summary-value small">{{ card.isPct ? formatPct(card.value) : formatNumber(card.value) }}</div>
</article>
</section>
</template>

View File

@@ -0,0 +1,105 @@
<script setup>
import { computed } from 'vue';
import { BarChart } from 'echarts/charts';
import { GridComponent, LegendComponent, TooltipComponent } from 'echarts/components';
import { use } from 'echarts/core';
import { CanvasRenderer } from 'echarts/renderers';
import VChart from 'vue-echarts';
use([CanvasRenderer, BarChart, GridComponent, LegendComponent, TooltipComponent]);
const props = defineProps({
items: { type: Array, default: () => [] },
selectedDates: { type: Array, default: () => [] },
loading: { type: Boolean, default: false },
});
const emit = defineEmits(['date-click', 'legend-change']);
const hasData = computed(() => Array.isArray(props.items) && props.items.length > 0);
const chartOption = computed(() => {
const items = props.items || [];
const dateSet = props.selectedDates.length > 0 ? new Set(props.selectedDates) : null;
return {
tooltip: {
trigger: 'axis',
axisPointer: { type: 'cross' },
},
legend: {
data: ['扣帳報廢量', '不扣帳報廢量'],
bottom: 0,
},
grid: { left: 48, right: 24, top: 22, bottom: 70 },
xAxis: {
type: 'category',
data: items.map((item) => item.bucket_date || ''),
},
yAxis: {
type: 'value',
axisLabel: {
formatter(value) {
return Number(value || 0).toLocaleString('zh-TW');
},
},
},
series: [
{
name: '扣帳報廢量',
type: 'bar',
color: '#dc2626',
data: items.map((item) => Number(item.REJECT_TOTAL_QTY || 0)),
itemStyle: {
color(params) {
const date = items[params.dataIndex]?.bucket_date || '';
return dateSet && !dateSet.has(date) ? '#f9a8a8' : '#dc2626';
},
},
barMaxWidth: 28,
},
{
name: '不扣帳報廢量',
type: 'bar',
color: '#0284c7',
data: items.map((item) => Number(item.DEFECT_QTY || 0)),
itemStyle: {
color(params) {
const date = items[params.dataIndex]?.bucket_date || '';
return dateSet && !dateSet.has(date) ? '#a5d8f0' : '#0284c7';
},
},
barMaxWidth: 28,
},
],
};
});
function handleChartClick(params) {
if (params?.componentType !== 'series') {
return;
}
const date = props.items?.[params.dataIndex]?.bucket_date;
if (date) {
emit('date-click', date);
}
}
function handleLegendChange(params) {
if (params?.selected) {
emit('legend-change', { ...params.selected });
}
}
</script>
<template>
<section class="chart-grid">
<article class="card">
<div class="card-header"><div class="card-title">報廢量趨勢</div></div>
<div class="card-body chart-wrap">
<VChart :option="chartOption" autoresize @click="handleChartClick" @legendselectchanged="handleLegendChange" />
<div v-if="!hasData && !loading" class="placeholder chart-empty">No data</div>
</div>
</article>
</section>
</template>

View File

@@ -52,6 +52,10 @@
grid-column: span 2;
}
.filter-group-full {
grid-column: 1 / -1;
}
.filter-label {
font-size: 12px;
font-weight: 700;
@@ -78,12 +82,17 @@
min-width: 0;
}
.inline-toggle-group {
align-self: center;
.filter-toolbar {
grid-column: 1 / -1;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
flex-wrap: wrap;
}
.checkbox-row {
display: inline-flex;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 10px;
@@ -111,8 +120,7 @@
.filter-actions {
display: flex;
gap: 10px;
justify-content: flex-end;
grid-column: span 2;
flex-shrink: 0;
}
.active-filter-chip-row {
@@ -194,6 +202,17 @@
gap: 12px;
}
.pareto-date-badge {
display: inline-block;
margin-left: 8px;
padding: 2px 8px;
border-radius: 999px;
background: #dbeafe;
color: #1e40af;
font-size: 12px;
font-weight: 600;
}
.pareto-layout {
display: grid;
grid-template-columns: minmax(0, 1.1fr) minmax(0, 0.9fr);
@@ -226,6 +245,10 @@
z-index: 1;
}
.detail-table tbody tr:hover {
background: #f8fbff;
}
.reason-link {
border: none;
background: transparent;
@@ -243,7 +266,39 @@
overflow: auto;
}
/* ---- MultiSelect component styles (shared-ui compatible) ---- */
.detail-table .cell-wrap {
white-space: normal;
max-width: 220px;
word-break: break-all;
}
.detail-reason-badge {
display: inline-flex;
align-items: center;
gap: 6px;
margin-left: 10px;
padding: 2px 10px;
border-radius: 999px;
background: #fef2f2;
color: #991b1b;
font-size: 12px;
font-weight: 600;
}
.badge-clear {
border: 0;
background: transparent;
color: #991b1b;
cursor: pointer;
font-size: 14px;
line-height: 1;
}
/* ---- MultiSelect component styles ----
Duplicated from resource-shared/styles.css because this page imports
wip-shared/styles.css instead. Cannot import resource-shared directly
due to conflicting global class names (.dashboard, .btn, etc.).
TODO: Add <style> block to MultiSelect.vue to eliminate this duplication. ---- */
.multi-select {
position: relative;
@@ -387,11 +442,6 @@
grid-column: span 2;
}
.filter-actions {
grid-column: span 2;
justify-content: flex-start;
}
.pareto-layout {
grid-template-columns: 1fr;
}
@@ -406,11 +456,15 @@
grid-template-columns: 1fr;
}
.filter-group-wide,
.filter-actions {
.filter-group-wide {
grid-column: span 1;
}
.filter-toolbar {
flex-direction: column;
align-items: flex-start;
}
.checkbox-row {
flex-direction: column;
align-items: flex-start;

View File

@@ -1,9 +1,9 @@
<script setup>
import { computed, reactive, ref } from 'vue';
import { computed, onBeforeUnmount, reactive, ref } from 'vue';
import { apiGet } from '../core/api.js';
import { replaceRuntimeHistory, toRuntimeRoute } from '../core/shell-navigation.js';
import { buildWipDetailQueryParams } from '../core/wip-derive.js';
import { buildWipDetailQueryParams, buildWipOverviewQueryParams } from '../core/wip-derive.js';
import { useAutoRefresh } from '../shared-composables/useAutoRefresh.js';
import FilterPanel from './components/FilterPanel.vue';
@@ -13,15 +13,27 @@ import SummaryCards from './components/SummaryCards.vue';
const API_TIMEOUT = 60000;
const PAGE_SIZE = 100;
const FILTER_OPTION_DEBOUNCE_MS = 120;
const workcenter = ref('');
const page = ref(1);
const filters = reactive({
workorder: '',
lotid: '',
package: '',
type: '',
workorder: [],
lotid: [],
package: [],
type: [],
firstname: [],
waferdesc: [],
});
const filterOptions = ref({
workorders: [],
lotids: [],
packages: [],
types: [],
firstnames: [],
waferdescs: [],
});
const activeStatusFilter = ref(null);
const detailData = ref(null);
@@ -33,6 +45,9 @@ const refreshError = ref(false);
const errorMessage = ref('');
const selectedLotId = ref('');
let filterOptionsDebounceTimer = null;
let filterOptionsRequestToken = 0;
function unwrapApiResult(result, fallbackMessage) {
if (result?.success) {
return result.data;
@@ -50,6 +65,35 @@ function getUrlParam(name) {
return new URLSearchParams(window.location.search).get(name)?.trim() || '';
}
function parseCsvParam(name) {
const raw = getUrlParam(name);
if (!raw) {
return [];
}
return raw
.split(',')
.map((value) => value.trim())
.filter(Boolean);
}
function normalizeArrayValues(values) {
if (!values) {
return [];
}
if (Array.isArray(values)) {
return values.map((value) => String(value).trim()).filter(Boolean);
}
return String(values)
.split(',')
.map((value) => value.trim())
.filter(Boolean);
}
function serializeFilterValue(values) {
const normalized = normalizeArrayValues(values);
return normalized.join(',');
}
function updateUrlState() {
if (!workcenter.value) {
return;
@@ -58,17 +102,30 @@ function updateUrlState() {
const params = new URLSearchParams();
params.set('workcenter', workcenter.value);
if (filters.workorder) {
params.set('workorder', filters.workorder);
const workorder = serializeFilterValue(filters.workorder);
const lotid = serializeFilterValue(filters.lotid);
const pkg = serializeFilterValue(filters.package);
const type = serializeFilterValue(filters.type);
const firstname = serializeFilterValue(filters.firstname);
const waferdesc = serializeFilterValue(filters.waferdesc);
if (workorder) {
params.set('workorder', workorder);
}
if (filters.lotid) {
params.set('lotid', filters.lotid);
if (lotid) {
params.set('lotid', lotid);
}
if (filters.package) {
params.set('package', filters.package);
if (pkg) {
params.set('package', pkg);
}
if (filters.type) {
params.set('type', filters.type);
if (type) {
params.set('type', type);
}
if (firstname) {
params.set('firstname', firstname);
}
if (waferdesc) {
params.set('waferdesc', waferdesc);
}
if (activeStatusFilter.value) {
params.set('status', activeStatusFilter.value);
@@ -105,6 +162,51 @@ async function fetchDetail(signal) {
return unwrapApiResult(result, 'Failed to fetch detail');
}
async function loadFilterOptions(sourceFilters = filters) {
const requestToken = ++filterOptionsRequestToken;
try {
const params = buildWipOverviewQueryParams(sourceFilters);
const result = await apiGet('/api/wip/meta/filter-options', {
params,
timeout: API_TIMEOUT,
silent: true,
});
const data = unwrapApiResult(result, '載入篩選選項失敗');
if (requestToken !== filterOptionsRequestToken) {
return;
}
filterOptions.value = {
workorders: Array.isArray(data?.workorders) ? data.workorders : [],
lotids: Array.isArray(data?.lotids) ? data.lotids : [],
packages: Array.isArray(data?.packages) ? data.packages : [],
types: Array.isArray(data?.types) ? data.types : [],
firstnames: Array.isArray(data?.firstnames) ? data.firstnames : [],
waferdescs: Array.isArray(data?.waferdescs) ? data.waferdescs : [],
};
} catch (error) {
if (error?.name !== 'AbortError') {
console.warn('載入 WIP Detail 篩選選項失敗:', error);
}
}
}
function scheduleFilterOptionsReload(nextDraftFilters) {
if (filterOptionsDebounceTimer) {
clearTimeout(filterOptionsDebounceTimer);
}
filterOptionsDebounceTimer = setTimeout(() => {
void loadFilterOptions(nextDraftFilters);
}, FILTER_OPTION_DEBOUNCE_MS);
}
function onFilterDraftChange(nextDraftFilters) {
scheduleFilterOptionsReload(nextDraftFilters);
}
function showRefreshSuccess() {
refreshSuccess.value = true;
setTimeout(() => {
@@ -112,7 +214,7 @@ function showRefreshSuccess() {
}, 1500);
}
const { createAbortSignal, triggerRefresh, startAutoRefresh, resetAutoRefresh } = useAutoRefresh({
const { createAbortSignal, triggerRefresh, startAutoRefresh } = useAutoRefresh({
onRefresh: () => loadAllData(false),
autoStart: false,
});
@@ -190,17 +292,30 @@ const tableData = computed(() => ({
const backUrl = computed(() => {
const params = new URLSearchParams();
if (filters.workorder) {
params.set('workorder', filters.workorder);
const workorder = serializeFilterValue(filters.workorder);
const lotid = serializeFilterValue(filters.lotid);
const pkg = serializeFilterValue(filters.package);
const type = serializeFilterValue(filters.type);
const firstname = serializeFilterValue(filters.firstname);
const waferdesc = serializeFilterValue(filters.waferdesc);
if (workorder) {
params.set('workorder', workorder);
}
if (filters.lotid) {
params.set('lotid', filters.lotid);
if (lotid) {
params.set('lotid', lotid);
}
if (filters.package) {
params.set('package', filters.package);
if (pkg) {
params.set('package', pkg);
}
if (filters.type) {
params.set('type', filters.type);
if (type) {
params.set('type', type);
}
if (firstname) {
params.set('firstname', firstname);
}
if (waferdesc) {
params.set('waferdesc', waferdesc);
}
if (activeStatusFilter.value) {
params.set('status', activeStatusFilter.value);
@@ -211,24 +326,37 @@ const backUrl = computed(() => {
});
function updateFilters(nextFilters) {
filters.workorder = nextFilters.workorder || '';
filters.lotid = nextFilters.lotid || '';
filters.package = nextFilters.package || '';
filters.type = nextFilters.type || '';
filters.workorder = normalizeArrayValues(nextFilters.workorder);
filters.lotid = normalizeArrayValues(nextFilters.lotid);
filters.package = normalizeArrayValues(nextFilters.package);
filters.type = normalizeArrayValues(nextFilters.type);
filters.firstname = normalizeArrayValues(nextFilters.firstname);
filters.waferdesc = normalizeArrayValues(nextFilters.waferdesc);
}
function applyFilters(nextFilters) {
updateFilters(nextFilters);
page.value = 1;
selectedLotId.value = '';
updateUrlState();
void loadFilterOptions(filters);
void loadAllData(false);
}
function clearFilters() {
updateFilters({ workorder: '', lotid: '', package: '', type: '' });
updateFilters({
workorder: [],
lotid: [],
package: [],
type: [],
firstname: [],
waferdesc: [],
});
activeStatusFilter.value = null;
page.value = 1;
selectedLotId.value = '';
updateUrlState();
void loadFilterOptions(filters);
void loadAllData(false);
}
@@ -274,10 +402,14 @@ async function manualRefresh() {
async function initializePage() {
workcenter.value = getUrlParam('workcenter');
filters.workorder = getUrlParam('workorder');
filters.lotid = getUrlParam('lotid');
filters.package = getUrlParam('package');
filters.type = getUrlParam('type');
updateFilters({
workorder: parseCsvParam('workorder'),
lotid: parseCsvParam('lotid'),
package: parseCsvParam('package'),
type: parseCsvParam('type'),
firstname: parseCsvParam('firstname'),
waferdesc: parseCsvParam('waferdesc'),
});
activeStatusFilter.value = getUrlParam('status') || null;
if (!workcenter.value) {
@@ -301,11 +433,21 @@ async function initializePage() {
return;
}
await loadAllData(true);
await Promise.all([
loadFilterOptions(filters),
loadAllData(true),
]);
startAutoRefresh();
}
void initializePage();
onBeforeUnmount(() => {
if (filterOptionsDebounceTimer) {
clearTimeout(filterOptionsDebounceTimer);
filterOptionsDebounceTimer = null;
}
});
</script>
<template>
@@ -328,7 +470,14 @@ void initializePage();
<p v-if="errorMessage" class="error-banner">{{ errorMessage }}</p>
<FilterPanel :filters="filters" @apply="applyFilters" @clear="clearFilters" />
<FilterPanel
:filters="filters"
:options="filterOptions"
:loading="refreshing"
@apply="applyFilters"
@clear="clearFilters"
@draft-change="onFilterDraftChange"
/>
<SummaryCards
:summary="summary"

View File

@@ -1,71 +1,100 @@
<script setup>
import { reactive, watch } from 'vue';
import { apiGet } from '../../core/api.js';
import { useAutocomplete } from '../../shared-composables/useAutocomplete.js';
import MultiSelect from '../../resource-shared/components/MultiSelect.vue';
const props = defineProps({
filters: {
type: Object,
required: true,
},
options: {
type: Object,
default: () => ({}),
},
loading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['apply', 'clear']);
const emit = defineEmits(['apply', 'clear', 'draft-change']);
const fields = [
{ key: 'workorder', label: 'WORKORDER', optionKey: 'workorders', placeholder: 'All WORKORDER' },
{ key: 'lotid', label: 'LOT ID', optionKey: 'lotids', placeholder: 'All LOT ID' },
{ key: 'package', label: 'PACKAGE', optionKey: 'packages', placeholder: 'All PACKAGE' },
{ key: 'type', label: 'TYPE', optionKey: 'types', placeholder: 'All TYPE' },
{ key: 'firstname', label: 'Wafer LOT', optionKey: 'firstnames', placeholder: 'All Wafer LOT' },
{ key: 'waferdesc', label: 'Wafer Type', optionKey: 'waferdescs', placeholder: 'All Wafer Type' },
];
const draft = reactive({
workorder: '',
lotid: '',
package: '',
type: '',
workorder: [],
lotid: [],
package: [],
type: [],
firstname: [],
waferdesc: [],
});
function toArray(value) {
if (!value) {
return [];
}
if (Array.isArray(value)) {
return value.map((item) => String(item).trim()).filter(Boolean);
}
return String(value)
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
function cloneDraft() {
return {
workorder: [...draft.workorder],
lotid: [...draft.lotid],
package: [...draft.package],
type: [...draft.type],
firstname: [...draft.firstname],
waferdesc: [...draft.waferdesc],
};
}
watch(
() => props.filters,
(nextFilters) => {
draft.workorder = nextFilters.workorder || '';
draft.lotid = nextFilters.lotid || '';
draft.package = nextFilters.package || '';
draft.type = nextFilters.type || '';
draft.workorder = toArray(nextFilters.workorder);
draft.lotid = toArray(nextFilters.lotid);
draft.package = toArray(nextFilters.package);
draft.type = toArray(nextFilters.type);
draft.firstname = toArray(nextFilters.firstname);
draft.waferdesc = toArray(nextFilters.waferdesc);
},
{ immediate: true, deep: true }
);
const { ensureField, handleInput, handleFocus, handleBlur, selectItem } = useAutocomplete({
getFilters: () => ({ ...draft }),
request: (url, options) => apiGet(url, options),
debounceMs: 300,
});
const fields = [
{ key: 'workorder', label: 'WORKORDER', placeholder: 'Search...' },
{ key: 'lotid', label: 'LOT ID', placeholder: 'Search...' },
{ key: 'package', label: 'PACKAGE', placeholder: 'Search...' },
{ key: 'type', label: 'TYPE', placeholder: 'Search...' },
];
function getFieldState(field) {
return ensureField(field);
function getOptions(field) {
return Array.isArray(props.options?.[field.optionKey]) ? props.options[field.optionKey] : [];
}
function onInput(field, event) {
draft[field] = event.target.value;
handleInput(field, draft[field]);
}
function onSelect(field, value) {
draft[field] = selectItem(field, value);
function notifyDraftChange() {
emit('draft-change', cloneDraft());
}
function applyFilters() {
emit('apply', { ...draft });
emit('apply', cloneDraft());
}
function clearFilters() {
draft.workorder = '';
draft.lotid = '';
draft.package = '';
draft.type = '';
draft.workorder = [];
draft.lotid = [];
draft.package = [];
draft.type = [];
draft.firstname = [];
draft.waferdesc = [];
notifyDraftChange();
emit('clear');
}
</script>
@@ -74,37 +103,22 @@ function clearFilters() {
<section class="filters">
<div v-for="field in fields" :key="field.key" class="filter-group">
<label>{{ field.label }}</label>
<div class="autocomplete-container">
<input
type="text"
:value="draft[field.key]"
:placeholder="field.placeholder"
autocomplete="off"
@input="onInput(field.key, $event)"
@focus="handleFocus(field.key)"
@blur="handleBlur(field.key)"
@keydown.enter.prevent="applyFilters"
/>
<div class="autocomplete-dropdown" :class="{ show: getFieldState(field.key).open }">
<div
v-for="item in getFieldState(field.key).items"
:key="item"
class="autocomplete-item"
@mousedown.prevent="onSelect(field.key, item)"
>
{{ item }}
</div>
<div
v-if="!getFieldState(field.key).loading && getFieldState(field.key).open && getFieldState(field.key).items.length === 0"
class="autocomplete-empty"
>
No results
</div>
</div>
</div>
<MultiSelect
:model-value="draft[field.key]"
:options="getOptions(field)"
:disabled="loading"
:placeholder="field.placeholder"
searchable
@update:model-value="
draft[field.key] = $event;
notifyDraftChange();
"
/>
</div>
<button type="button" class="btn-primary" @click="applyFilters">Apply</button>
<button type="button" class="btn-secondary" @click="clearFilters">Clear</button>
<div class="filters-actions">
<button type="button" class="btn-primary" :disabled="loading" @click="applyFilters">套用篩選</button>
<button type="button" class="btn-secondary" :disabled="loading" @click="clearFilters">清除篩選</button>
</div>
</section>
</template>

View File

@@ -1,4 +1,5 @@
@import '../wip-shared/styles.css';
@import '../resource-shared/styles.css';
.wip-detail-page .header h1 {
font-size: 24px;
@@ -19,16 +20,17 @@
border-radius: 10px;
margin-bottom: 16px;
box-shadow: var(--shadow);
display: flex;
gap: 16px;
align-items: flex-end;
flex-wrap: wrap;
display: grid;
grid-template-columns: repeat(3, minmax(220px, 1fr));
gap: 14px 16px;
align-items: start;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 6px;
min-width: 0;
}
.filter-group label {
@@ -37,61 +39,17 @@
color: var(--muted);
}
.autocomplete-container {
position: relative;
display: inline-block;
.filters-actions {
grid-column: 1 / -1;
display: inline-flex;
justify-content: flex-end;
align-items: center;
gap: 10px;
}
.autocomplete-container input {
width: 180px;
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
}
.autocomplete-container input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
.autocomplete-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
max-height: 200px;
overflow-y: auto;
background: #fff;
border: 1px solid var(--border);
border-top: none;
border-radius: 0 0 6px 6px;
box-shadow: var(--shadow);
z-index: 20;
display: none;
}
.autocomplete-dropdown.show {
display: block;
}
.autocomplete-item,
.autocomplete-empty {
padding: 8px 12px;
font-size: 13px;
}
.autocomplete-item {
cursor: pointer;
}
.autocomplete-item:hover {
background: #f0f2ff;
}
.autocomplete-empty {
color: var(--muted);
.filters-actions .btn-primary,
.filters-actions .btn-secondary {
min-width: 108px;
}
.detail-summary-row {
@@ -457,6 +415,10 @@
}
@media (max-width: 1000px) {
.filters {
grid-template-columns: repeat(2, minmax(220px, 1fr));
}
.detail-summary-row {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
@@ -464,13 +426,11 @@
@media (max-width: 768px) {
.filters {
flex-direction: column;
align-items: stretch;
grid-template-columns: 1fr;
}
.autocomplete-container,
.autocomplete-container input {
width: 100%;
.filters-actions {
justify-content: flex-start;
}
.detail-summary-row,

View File

@@ -1,5 +1,5 @@
<script setup>
import { computed, onMounted, reactive, ref } from 'vue';
import { computed, onBeforeUnmount, onMounted, reactive, ref } from 'vue';
import { apiGet } from '../core/api.js';
import { navigateToRuntimeRoute, replaceRuntimeHistory } from '../core/shell-navigation.js';
@@ -16,15 +16,26 @@ import StatusCards from './components/StatusCards.vue';
import SummaryCards from './components/SummaryCards.vue';
const API_TIMEOUT = 60000;
const FILTER_OPTION_DEBOUNCE_MS = 120;
const summary = ref(null);
const matrix = ref(null);
const hold = ref(null);
const filterOptions = ref({
workorders: [],
lotids: [],
packages: [],
types: [],
firstnames: [],
waferdescs: [],
});
const filters = reactive({
workorder: '',
lotid: '',
package: '',
type: '',
workorder: [],
lotid: [],
package: [],
type: [],
firstname: [],
waferdesc: [],
});
const activeStatusFilter = ref(null);
@@ -33,6 +44,8 @@ const refreshing = ref(false);
const refreshSuccess = ref(false);
const refreshError = ref(false);
const errorMessage = ref('');
let filterOptionsDebounceTimer = null;
let filterOptionsRequestToken = 0;
function unwrapApiResult(result, fallbackMessage) {
if (result?.success) {
@@ -51,6 +64,35 @@ function getUrlParam(name) {
return new URLSearchParams(window.location.search).get(name)?.trim() || '';
}
function parseCsvParam(name) {
const raw = getUrlParam(name);
if (!raw) {
return [];
}
return raw
.split(',')
.map((value) => value.trim())
.filter(Boolean);
}
function normalizeArrayValues(values) {
if (!values) {
return [];
}
if (Array.isArray(values)) {
return values.map((value) => String(value).trim()).filter(Boolean);
}
return String(values)
.split(',')
.map((value) => value.trim())
.filter(Boolean);
}
function serializeFilterValue(values) {
const normalized = normalizeArrayValues(values);
return normalized.join(',');
}
function buildFilters(status = null) {
return buildWipOverviewQueryParams(filters, status);
}
@@ -82,6 +124,51 @@ async function fetchHold(signal) {
return unwrapApiResult(result, 'Failed to fetch hold data');
}
async function loadFilterOptions(sourceFilters = filters) {
const requestToken = ++filterOptionsRequestToken;
try {
const params = buildWipOverviewQueryParams(sourceFilters);
const result = await apiGet('/api/wip/meta/filter-options', {
params,
timeout: API_TIMEOUT,
silent: true,
});
const data = unwrapApiResult(result, '載入篩選選項失敗');
if (requestToken !== filterOptionsRequestToken) {
return;
}
filterOptions.value = {
workorders: Array.isArray(data?.workorders) ? data.workorders : [],
lotids: Array.isArray(data?.lotids) ? data.lotids : [],
packages: Array.isArray(data?.packages) ? data.packages : [],
types: Array.isArray(data?.types) ? data.types : [],
firstnames: Array.isArray(data?.firstnames) ? data.firstnames : [],
waferdescs: Array.isArray(data?.waferdescs) ? data.waferdescs : [],
};
} catch (error) {
if (error?.name !== 'AbortError') {
console.warn('載入 WIP 篩選選項失敗:', error);
}
}
}
function scheduleFilterOptionsReload(nextDraftFilters) {
if (filterOptionsDebounceTimer) {
clearTimeout(filterOptionsDebounceTimer);
}
filterOptionsDebounceTimer = setTimeout(() => {
void loadFilterOptions(nextDraftFilters);
}, FILTER_OPTION_DEBOUNCE_MS);
}
function onFilterDraftChange(nextDraftFilters) {
scheduleFilterOptionsReload(nextDraftFilters);
}
const lastUpdate = computed(() => {
return summary.value?.dataUpdateDate ? `Last Update: ${summary.value.dataUpdateDate}` : '';
});
@@ -103,7 +190,7 @@ const matrixTitle = computed(() => {
const splitHold = computed(() => splitHoldByType(hold.value));
const { createAbortSignal, resetAutoRefresh, triggerRefresh } = useAutoRefresh({
const { createAbortSignal, triggerRefresh } = useAutoRefresh({
onRefresh: () => loadAllData(false),
autoStart: true,
});
@@ -175,26 +262,41 @@ function toggleStatusFilter(status) {
}
function updateFilters(nextFilters) {
filters.workorder = nextFilters.workorder || '';
filters.lotid = nextFilters.lotid || '';
filters.package = nextFilters.package || '';
filters.type = nextFilters.type || '';
filters.workorder = normalizeArrayValues(nextFilters.workorder);
filters.lotid = normalizeArrayValues(nextFilters.lotid);
filters.package = normalizeArrayValues(nextFilters.package);
filters.type = normalizeArrayValues(nextFilters.type);
filters.firstname = normalizeArrayValues(nextFilters.firstname);
filters.waferdesc = normalizeArrayValues(nextFilters.waferdesc);
}
function updateUrlState() {
const params = new URLSearchParams();
if (filters.workorder) {
params.set('workorder', filters.workorder);
const workorder = serializeFilterValue(filters.workorder);
const lotid = serializeFilterValue(filters.lotid);
const pkg = serializeFilterValue(filters.package);
const type = serializeFilterValue(filters.type);
const firstname = serializeFilterValue(filters.firstname);
const waferdesc = serializeFilterValue(filters.waferdesc);
if (workorder) {
params.set('workorder', workorder);
}
if (filters.lotid) {
params.set('lotid', filters.lotid);
if (lotid) {
params.set('lotid', lotid);
}
if (filters.package) {
params.set('package', filters.package);
if (pkg) {
params.set('package', pkg);
}
if (filters.type) {
params.set('type', filters.type);
if (type) {
params.set('type', type);
}
if (firstname) {
params.set('firstname', firstname);
}
if (waferdesc) {
params.set('waferdesc', waferdesc);
}
if (activeStatusFilter.value) {
params.set('status', activeStatusFilter.value);
@@ -208,19 +310,22 @@ function updateUrlState() {
function applyFilters(nextFilters) {
updateFilters(nextFilters);
updateUrlState();
void loadFilterOptions(filters);
void loadAllData(false);
}
function clearFilters() {
updateFilters({ workorder: '', lotid: '', package: '', type: '' });
updateFilters({
workorder: [],
lotid: [],
package: [],
type: [],
firstname: [],
waferdesc: [],
});
activeStatusFilter.value = null;
updateUrlState();
void loadAllData(false);
}
function removeFilter(field) {
filters[field] = '';
updateUrlState();
void loadFilterOptions(filters);
void loadAllData(false);
}
@@ -228,17 +333,30 @@ function navigateToDetail(workcenter) {
const params = new URLSearchParams();
params.append('workcenter', workcenter);
if (filters.workorder) {
params.append('workorder', filters.workorder);
const workorder = serializeFilterValue(filters.workorder);
const lotid = serializeFilterValue(filters.lotid);
const pkg = serializeFilterValue(filters.package);
const type = serializeFilterValue(filters.type);
const firstname = serializeFilterValue(filters.firstname);
const waferdesc = serializeFilterValue(filters.waferdesc);
if (workorder) {
params.append('workorder', workorder);
}
if (filters.lotid) {
params.append('lotid', filters.lotid);
if (lotid) {
params.append('lotid', lotid);
}
if (filters.package) {
params.append('package', filters.package);
if (pkg) {
params.append('package', pkg);
}
if (filters.type) {
params.append('type', filters.type);
if (type) {
params.append('type', type);
}
if (firstname) {
params.append('firstname', firstname);
}
if (waferdesc) {
params.append('waferdesc', waferdesc);
}
if (activeStatusFilter.value) {
params.append('status', activeStatusFilter.value);
@@ -259,18 +377,32 @@ async function manualRefresh() {
}
async function initializePage() {
filters.workorder = getUrlParam('workorder');
filters.lotid = getUrlParam('lotid');
filters.package = getUrlParam('package');
filters.type = getUrlParam('type');
updateFilters({
workorder: parseCsvParam('workorder'),
lotid: parseCsvParam('lotid'),
package: parseCsvParam('package'),
type: parseCsvParam('type'),
firstname: parseCsvParam('firstname'),
waferdesc: parseCsvParam('waferdesc'),
});
activeStatusFilter.value = getUrlParam('status') || null;
await loadAllData(true);
await Promise.all([
loadFilterOptions(filters),
loadAllData(true),
]);
}
onMounted(() => {
void initializePage();
});
onBeforeUnmount(() => {
if (filterOptionsDebounceTimer) {
clearTimeout(filterOptionsDebounceTimer);
filterOptionsDebounceTimer = null;
}
});
</script>
<template>
@@ -292,9 +424,11 @@ onMounted(() => {
<FilterPanel
:filters="filters"
:options="filterOptions"
:loading="refreshing"
@apply="applyFilters"
@clear="clearFilters"
@remove="removeFilter"
@draft-change="onFilterDraftChange"
/>
<SummaryCards :summary="summary" />

View File

@@ -1,133 +1,125 @@
<script setup>
import { reactive, watch } from 'vue';
import { apiGet } from '../../core/api.js';
import { useAutocomplete } from '../../shared-composables/useAutocomplete.js';
import MultiSelect from '../../resource-shared/components/MultiSelect.vue';
const props = defineProps({
filters: {
type: Object,
required: true,
},
options: {
type: Object,
default: () => ({}),
},
loading: {
type: Boolean,
default: false,
},
});
const emit = defineEmits(['apply', 'clear', 'remove']);
const emit = defineEmits(['apply', 'clear', 'draft-change']);
const fields = [
{ key: 'workorder', label: 'WORKORDER', optionKey: 'workorders', placeholder: '全部 WORKORDER' },
{ key: 'lotid', label: 'LOT ID', optionKey: 'lotids', placeholder: '全部 LOT ID' },
{ key: 'package', label: 'PACKAGE', optionKey: 'packages', placeholder: '全部 PACKAGE' },
{ key: 'type', label: 'TYPE', optionKey: 'types', placeholder: '全部 TYPE' },
{ key: 'firstname', label: 'Wafer LOT', optionKey: 'firstnames', placeholder: '全部 Wafer LOT' },
{ key: 'waferdesc', label: 'Wafer Type', optionKey: 'waferdescs', placeholder: '全部 Wafer Type' },
];
const draft = reactive({
workorder: '',
lotid: '',
package: '',
type: '',
workorder: [],
lotid: [],
package: [],
type: [],
firstname: [],
waferdesc: [],
});
function toArray(value) {
if (!value) {
return [];
}
if (Array.isArray(value)) {
return value.map((item) => String(item).trim()).filter(Boolean);
}
return String(value)
.split(',')
.map((item) => item.trim())
.filter(Boolean);
}
function cloneDraft() {
return {
workorder: [...draft.workorder],
lotid: [...draft.lotid],
package: [...draft.package],
type: [...draft.type],
firstname: [...draft.firstname],
waferdesc: [...draft.waferdesc],
};
}
watch(
() => props.filters,
(nextFilters) => {
draft.workorder = nextFilters.workorder || '';
draft.lotid = nextFilters.lotid || '';
draft.package = nextFilters.package || '';
draft.type = nextFilters.type || '';
draft.workorder = toArray(nextFilters.workorder);
draft.lotid = toArray(nextFilters.lotid);
draft.package = toArray(nextFilters.package);
draft.type = toArray(nextFilters.type);
draft.firstname = toArray(nextFilters.firstname);
draft.waferdesc = toArray(nextFilters.waferdesc);
},
{ immediate: true, deep: true }
);
const { ensureField, handleInput, handleFocus, handleBlur, selectItem } = useAutocomplete({
getFilters: () => ({ ...draft }),
request: (url, options) => apiGet(url, options),
debounceMs: 300,
});
const fields = [
{ key: 'workorder', label: 'WORKORDER', placeholder: '輸入 WORKORDER...' },
{ key: 'lotid', label: 'LOT ID', placeholder: '輸入 LOT ID...' },
{ key: 'package', label: 'PACKAGE', placeholder: '輸入 PACKAGE...' },
{ key: 'type', label: 'TYPE', placeholder: '輸入 TYPE...' },
];
function getFieldState(field) {
return ensureField(field);
function getOptions(field) {
return Array.isArray(props.options?.[field.optionKey]) ? props.options[field.optionKey] : [];
}
function applyFilters() {
emit('apply', { ...draft });
emit('apply', cloneDraft());
}
function notifyDraftChange() {
emit('draft-change', cloneDraft());
}
function clearFilters() {
draft.workorder = '';
draft.lotid = '';
draft.package = '';
draft.type = '';
draft.workorder = [];
draft.lotid = [];
draft.package = [];
draft.type = [];
draft.firstname = [];
draft.waferdesc = [];
notifyDraftChange();
emit('clear');
}
function removeFilter(field) {
draft[field] = '';
emit('remove', field);
}
function onInput(field, event) {
draft[field] = event.target.value;
handleInput(field, draft[field]);
}
function onSelect(field, value) {
draft[field] = selectItem(field, value);
}
</script>
<template>
<section class="filters">
<div v-for="field in fields" :key="field.key" class="filter-group">
<label>{{ field.label }}</label>
<input
type="text"
:value="draft[field.key]"
<MultiSelect
:model-value="draft[field.key]"
:options="getOptions(field)"
:disabled="loading"
:placeholder="field.placeholder"
autocomplete="off"
@input="onInput(field.key, $event)"
@focus="handleFocus(field.key)"
@blur="handleBlur(field.key)"
@keydown.enter.prevent="applyFilters"
searchable
@update:model-value="
draft[field.key] = $event;
notifyDraftChange();
"
/>
<span class="search-loading" :class="{ active: getFieldState(field.key).loading }"></span>
<div class="autocomplete-dropdown" :class="{ active: getFieldState(field.key).open }">
<div
v-for="item in getFieldState(field.key).items"
:key="item"
class="autocomplete-item"
@mousedown.prevent="onSelect(field.key, item)"
>
{{ item }}
</div>
<div
v-if="!getFieldState(field.key).loading && getFieldState(field.key).open && getFieldState(field.key).items.length === 0"
class="autocomplete-item no-results"
>
無符合結果
</div>
</div>
</div>
<button type="button" class="btn-primary" @click="applyFilters">套用篩選</button>
<button type="button" class="btn-secondary" @click="clearFilters">清除篩選</button>
<TransitionGroup name="filter-chip" tag="div" class="active-filters">
<span v-if="filters.workorder" key="workorder" class="filter-tag">
WO: {{ filters.workorder }}
<span class="remove" @click="removeFilter('workorder')">×</span>
</span>
<span v-if="filters.lotid" key="lotid" class="filter-tag">
Lot: {{ filters.lotid }}
<span class="remove" @click="removeFilter('lotid')">×</span>
</span>
<span v-if="filters.package" key="package" class="filter-tag">
Pkg: {{ filters.package }}
<span class="remove" @click="removeFilter('package')">×</span>
</span>
<span v-if="filters.type" key="type" class="filter-tag">
Type: {{ filters.type }}
<span class="remove" @click="removeFilter('type')">×</span>
</span>
</TransitionGroup>
<div class="filters-actions">
<button type="button" class="btn-primary" :disabled="loading" @click="applyFilters">套用篩選</button>
<button type="button" class="btn-secondary" :disabled="loading" @click="clearFilters">清除篩選</button>
</div>
</section>
</template>

View File

@@ -1,4 +1,5 @@
@import '../wip-shared/styles.css';
@import '../resource-shared/styles.css';
.wip-overview-page .header h1 {
font-size: 24px;
@@ -19,17 +20,17 @@
border-radius: 10px;
margin-bottom: 16px;
box-shadow: var(--shadow);
display: flex;
gap: 16px;
align-items: flex-end;
flex-wrap: wrap;
display: grid;
grid-template-columns: repeat(3, minmax(220px, 1fr));
gap: 14px 16px;
align-items: start;
}
.filter-group {
display: flex;
flex-direction: column;
gap: 6px;
position: relative;
min-width: 0;
}
.filter-group label {
@@ -38,109 +39,17 @@
color: var(--muted);
}
.filter-group input {
padding: 8px 12px;
border: 1px solid var(--border);
border-radius: 6px;
font-size: 13px;
min-width: 200px;
}
.filter-group input:focus {
outline: none;
border-color: var(--primary);
box-shadow: 0 0 0 2px rgba(102, 126, 234, 0.2);
}
.search-loading {
position: absolute;
right: 10px;
top: 32px;
width: 14px;
height: 14px;
border: 2px solid var(--border);
border-top-color: var(--primary);
border-radius: 50%;
animation: spin 0.8s linear infinite;
display: none;
}
.search-loading.active {
display: block;
}
.autocomplete-dropdown {
position: absolute;
top: 100%;
left: 0;
right: 0;
background: #fff;
border: 1px solid var(--border);
border-radius: 6px;
box-shadow: var(--shadow);
max-height: 200px;
overflow-y: auto;
z-index: 20;
display: none;
}
.autocomplete-dropdown.active {
display: block;
}
.autocomplete-item {
padding: 8px 12px;
cursor: pointer;
font-size: 13px;
}
.autocomplete-item:hover {
background: #f3f4f6;
}
.autocomplete-item.no-results {
color: var(--muted);
cursor: default;
}
.active-filters {
display: inline-flex;
flex-wrap: wrap;
gap: 8px;
}
.filter-tag {
.filters-actions {
grid-column: 1 / -1;
display: inline-flex;
gap: 10px;
align-items: center;
gap: 6px;
padding: 4px 10px;
background: #e8ecff;
color: var(--primary);
border-radius: 4px;
font-size: 12px;
justify-content: flex-end;
}
.filter-tag .remove {
cursor: pointer;
font-weight: 700;
}
.filter-chip-enter-active,
.filter-chip-leave-active {
transition: all 0.2s ease;
}
.filter-chip-enter-from,
.filter-chip-leave-to {
opacity: 0;
transform: translateY(-4px) scale(0.96);
}
@media (prefers-reduced-motion: reduce) {
.filter-chip-enter-active,
.filter-chip-leave-active {
transition: none !important;
}
.filters-actions .btn-primary,
.filters-actions .btn-secondary {
min-width: 108px;
}
.overview-summary-row {
@@ -487,11 +396,7 @@
@media (max-width: 1000px) {
.filters {
gap: 12px;
}
.filter-group input {
min-width: 180px;
grid-template-columns: repeat(2, minmax(220px, 1fr));
}
.wip-status-row {
@@ -501,13 +406,11 @@
@media (max-width: 768px) {
.filters {
flex-direction: column;
align-items: stretch;
grid-template-columns: 1fr;
}
.filter-group input {
width: 100%;
min-width: 0;
.filters-actions {
justify-content: flex-start;
}
.overview-summary-row,

View File

@@ -10,16 +10,20 @@ import {
test('buildWipOverviewQueryParams keeps only non-empty filters', () => {
const params = buildWipOverviewQueryParams({
workorder: ' WO-1 ',
lotid: '',
package: 'PKG-A',
workorder: [' WO-1 ', 'WO-2'],
lotid: [],
package: ['PKG-A'],
type: 'QFN',
firstname: ['WF-01'],
waferdesc: 'SiC',
});
assert.deepEqual(params, {
workorder: 'WO-1',
workorder: 'WO-1,WO-2',
package: 'PKG-A',
type: 'QFN',
firstname: 'WF-01',
waferdesc: 'SiC',
});
});

View File

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

View File

@@ -0,0 +1,79 @@
## Context
`WIP 即時概況` 現在使用 4 個文字輸入框WORKORDER/LOT ID/PACKAGE/TYPE搭配 `/api/wip/meta/search` 即時建議。此模式在多條件操作時需要頻繁輸入,且首次進頁不會先拿到完整候選值。需求要改成可模糊搜尋的下拉清單,並新增 `FIRSTNAME``WAFERDESC` 兩個篩選維度,且篩選選項來源以快取為主。
## Goals / Non-Goals
**Goals**
- 將 WIP 概況篩選改成下拉可搜尋(參考設備即時概況「機台」篩選互動)
- 新增 `Wafer LOT(FIRSTNAME)``Wafer Type(WAFERDESC)` 篩選
- 所有篩選候選值可由快取一次取得,並支援首次載入預先填充
- 既有 summary/matrix/hold 查詢都能吃到新舊篩選條件
**Non-Goals**
- 不改 WIP Detail 頁面的篩選 UI仍維持現有 autocomplete
- 不移除既有 `/api/wip/meta/search`(保留向下相容)
- 不變更 WIP 指標計算邏輯(僅改篩選方式與欄位)
## Decisions
### D1: 新增 `GET /api/wip/meta/filter-options` 做「一次取齊」篩選選項
API 回傳:
- `workorders`
- `lotids`
- `packages`
- `types`
- `firstnames`
- `waferdescs`
資料來源優先順序:
1. WIP 快取衍生搜尋索引(`_get_wip_search_index`
2. WIP 快取快照(必要時)
3. Oracle fallback僅快取不可用時
此設計讓前端在第一次查詢前就能載入完整下拉選項。
### D2: WIP 概況前端篩選改採 `MultiSelect`(可搜尋)
`frontend/src/wip-overview/components/FilterPanel.vue` 改為使用 `resource-shared/components/MultiSelect.vue`
- 6 個篩選欄位皆為可搜尋下拉
- 支援多選(內部值為陣列)
- 顯示 active chips移除 chip 會觸發重查
### D3: 篩選參數以 CSV 傳遞,服務層統一解析
API query 維持既有參數名稱(`workorder`, `lotid`, `package`, `type`)並新增:
- `firstname`
- `waferdesc`
多選由前端以逗號串接傳遞。服務層新增 CSV 解析 helper將單值/多值統一轉成條件。
### D4: 搜尋索引與快照索引擴充 Wafer 欄位
`wip_service` 的衍生索引加入:
- `FIRSTNAME`
- `WAFERDESC`
確保:
- `meta/filter-options` 可直接由索引取值
- summary/matrix/hold 可在快取路徑下高效套用 Wafer 篩選
## Risks / Trade-offs
- **參數長度風險**:多選過多時 URL 長度增加;目前以一般 dashboard 操作量可接受。
- **跨頁一致性**WIP Detail 未同步改成新 UI但後端先支持新欄位避免 overview drilldown 失真。
- **快取不可用場景**filter-options 需 fallback 查詢,首次延遲可能上升。
## Validation Plan
- 單元測試:
- `tests/test_wip_routes.py`:新增 `meta/filter-options` 與新參數傳遞驗證
- `tests/test_wip_service.py`:新增 filter-options 來源與新欄位索引輸出驗證
- `frontend/tests/wip-derive.test.js`CSV/新欄位 query 參數組裝驗證
- 手動驗證:
- 進入 `/wip-overview`,首次不查主資料也能看到下拉選項
- 套用任一新舊篩選後 summary/matrix/hold 都一致變化
- 下拉框可模糊搜尋、可多選、可清除

View File

@@ -0,0 +1,36 @@
## Why
WIP 即時概況目前使用文字輸入搭配動態 autocomplete使用者在多條件查詢時容易反覆輸入且選項不一致。改為可搜尋的下拉清單並新增 Wafer 維度篩選,可降低操作成本、提升查詢一致性,且能直接利用既有快取資料來源。
## What Changes
- 將 WIP 即時概況篩選 UI 從文字 autocomplete 改為可模糊搜尋的下拉清單(對齊設備即時概況機台篩選互動)
- 新增兩個篩選欄位:`Wafer LOT`(資料欄位 `FIRSTNAME`)、`Wafer Type`(資料欄位 `WAFERDESC`
- 新增 WIP 篩選選項 API一次回傳舊有與新增篩選欄位的候選值優先由 WIP 快取衍生索引提供
- WIP 概況查詢 APIsummary/matrix/hold納入 `firstname``waferdesc` 參數
- 前端初始化階段預先載入篩選選項(不需先觸發主查詢才有下拉選項)
## Capabilities
### New Capabilities
_(none)_
### Modified Capabilities
- `wip-overview-page`: 篩選互動改為可搜尋下拉並新增 Wafer 維度篩選,且篩選選項由快取驅動
## Impact
- **Frontend**
- `frontend/src/wip-overview/App.vue`
- `frontend/src/wip-overview/components/FilterPanel.vue`
- `frontend/src/wip-overview/style.css`
- `frontend/src/core/wip-derive.js`
- `frontend/tests/wip-derive.test.js`
- **Backend**
- `src/mes_dashboard/routes/wip_routes.py`
- `src/mes_dashboard/services/wip_service.py`
- `tests/test_wip_routes.py`
- `tests/test_wip_service.py`
- **No breaking route removal**: 既有 API 與參數仍保持相容,新增參數採選填。

View File

@@ -0,0 +1,39 @@
## MODIFIED Requirements
### Requirement: Overview page SHALL support dropdown filtering
The page SHALL provide searchable dropdown filters for WORKORDER, LOT ID, PACKAGE, TYPE, Wafer LOT, and Wafer Type.
#### Scenario: Filter options preload from cache-backed endpoint
- **WHEN** the page initializes
- **THEN** the page SHALL call `GET /api/wip/meta/filter-options`
- **THEN** dropdown options SHALL be loaded before user performs first query
- **THEN** options SHALL include `workorders`, `lotids`, `packages`, `types`, `firstnames`, and `waferdescs`
#### Scenario: Searchable dropdown interaction
- **WHEN** user opens any filter dropdown
- **THEN** the dropdown SHALL support fuzzy keyword search over loaded options
- **THEN** user SHALL be able to select one or multiple options
#### Scenario: Apply and clear filters
- **WHEN** user clicks `套用篩選`
- **THEN** all three API calls (`/api/wip/overview/summary`, `/api/wip/overview/matrix`, `/api/wip/overview/hold`) SHALL reload with selected filter values
- **WHEN** user clicks `清除篩選`
- **THEN** all filter values SHALL reset and data SHALL reload without filters
#### Scenario: Active filter chips
- **WHEN** any filter has selected values
- **THEN** selected values SHALL be displayed as removable chips
- **THEN** removing a chip SHALL trigger data reload with updated filters
### Requirement: Overview page SHALL persist filter state in URL
The page SHALL synchronize all filter state to URL query parameters as the single source of truth.
#### Scenario: URL state includes new wafer filters
- **WHEN** filters are applied
- **THEN** URL query parameters SHALL include non-empty values for `workorder`, `lotid`, `package`, `type`, `firstname`, `waferdesc`, and `status`
- **THEN** multi-select values SHALL be serialized as comma-separated strings
#### Scenario: URL state restoration on load
- **WHEN** the page is loaded with filter query parameters
- **THEN** all filter controls SHALL restore values from URL
- **THEN** data SHALL load with restored filters applied

View File

@@ -0,0 +1,25 @@
## 1. OpenSpec alignment
- [x] 1.1 Confirm modified capability scope and spec deltas for `wip-overview-page`
## 2. Backend: cache-backed filter options + new filter fields
- [x] 2.1 Add WIP service support for `FIRSTNAME` / `WAFERDESC` in cache-derived indexes and snapshot filter path
- [x] 2.2 Add cache-backed `get_wip_filter_options` service API returning workorders/lotids/packages/types/firstnames/waferdescs
- [x] 2.3 Add `GET /api/wip/meta/filter-options` route
- [x] 2.4 Extend overview query routes (`summary`, `matrix`, `hold`) to parse and pass `firstname` and `waferdesc`
- [x] 2.5 Keep backward compatibility for existing params and behavior
## 3. Frontend: WIP overview filter UX replacement
- [x] 3.1 Replace `wip-overview` filter inputs with searchable dropdowns (reuse `resource-shared/components/MultiSelect.vue`)
- [x] 3.2 Add two new filters in UI: `Wafer LOT` (`firstname`) and `Wafer Type` (`waferdesc`)
- [x] 3.3 Load filter options from `/api/wip/meta/filter-options` on initialization and bind to dropdown options
- [x] 3.4 Ensure apply/clear/chip-remove and URL sync all work with old + new filters
## 4. Tests and verification
- [x] 4.1 Update route tests for new endpoint and new query parameters
- [x] 4.2 Update service tests for filter options and new index fields
- [x] 4.3 Update frontend derive tests for URL/query param mapping
- [x] 4.4 Run targeted test commands and fix regressions

View File

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

View File

@@ -0,0 +1,57 @@
## Context
The reject-history page is a monolithic `App.vue` (~968 lines template+script) with a co-located `style.css`. It was built quickly and works, but differs from the hold-history page (the maturity benchmark) in structure and several UI details. The hold-history page delegates to 7 sub-components and follows project-wide conventions (loading overlay, hover effects, Chinese pagination text, header refresh button).
The page imports `wip-shared/styles.css` for design tokens and global classes, and `resource-shared/components/MultiSelect.vue` for multi-select dropdowns — but also duplicates ~120 lines of MultiSelect CSS in its own `style.css`.
## Goals / Non-Goals
**Goals:**
- Match hold-history's visual baseline: loading overlay, table hover, Chinese pagination, header refresh button
- Extract App.vue into sub-components following hold-history's proven pattern
- Remove duplicated MultiSelect CSS
- Keep all existing functionality and API interactions unchanged
**Non-Goals:**
- Changing column names or data display (user explicitly excluded #1)
- Adding new features, APIs, or functional capabilities
- Migrating to Tailwind or shared-ui components (page stays on wip-shared CSS)
- Touching backend code
## Decisions
### D1: Component extraction mirrors hold-history's architecture
Extract into 5 sub-components under `frontend/src/reject-history/components/`:
| Component | Responsibility | hold-history equivalent |
|-----------|---------------|------------------------|
| `FilterPanel.vue` | Filter grid, checkboxes, action buttons, active chips | `FilterBar.vue` |
| `SummaryCards.vue` | 6 KPI cards with lane colors | `SummaryCards.vue` |
| `TrendChart.vue` | Quantity trend bar chart (vue-echarts) | `DailyTrend.vue` |
| `ParetoSection.vue` | Pareto chart + table side-by-side | `ReasonPareto.vue` |
| `DetailTable.vue` | Detail table + pagination | `DetailTable.vue` |
**Rationale**: The hold-history pattern is proven and familiar to the team. Same granularity, same naming convention.
### D2: State stays in App.vue, components receive props + emit events
App.vue keeps all reactive state (`filters`, `summary`, `trend`, `pareto`, `detail`, `loading`, etc.) and API functions. Sub-components are presentational. This matches hold-history exactly and avoids over-engineering with composables for a single-page report.
### D3: Remove duplicated MultiSelect CSS, rely on resource-shared import chain
The MultiSelect component from `resource-shared/components/MultiSelect.vue` already bundles its own styles. The ~120 lines duplicated in `reject-history/style.css` (`.multi-select`, `.multi-select-trigger`, `.multi-select-dropdown`, etc.) can be deleted.
**Risk**: If some pages import MultiSelect without importing `resource-shared/styles.css`, they break. But reject-history doesn't import resource-shared/styles.css either — the MultiSelect component uses scoped styles or injects its own. Verify before deleting.
### D4: Loading overlay uses existing wip-shared pattern
Add `<div v-if="loading.initial" class="loading-overlay"><span class="loading-spinner"></span></div>` after the `.dashboard` div, identical to hold-history. The `.loading-overlay` and `.loading-spinner` classes are already defined in `wip-shared/styles.css`.
## Risks / Trade-offs
- **[Risk] MultiSelect CSS deletion breaks styling** → Verify the MultiSelect component renders correctly after removing the duplicated CSS. If it doesn't, the component may need its own `<style scoped>` block or the import chain needs adjustment.
- **[Risk] Extraction introduces subtle regressions** → Each component boundary is a potential data-flow bug. Mitigate by keeping the extraction mechanical: cut template section → paste into component → add props/emits.
- **[Trade-off] No composable extraction** → The script logic stays in App.vue (400+ lines). This is acceptable for now — hold-history works the same way. Future refactoring can extract a `useRejectHistory` composable if needed.

View File

@@ -0,0 +1,28 @@
## Why
The reject-history page was shipped as a monolithic single-file implementation. While functional, it has visual inconsistencies with other mature report pages (hold-history, wip-overview) and is missing standard UX affordances. Aligning it now reduces user confusion when switching between report pages and improves maintainability.
## What Changes
- Add `tbody tr:hover` highlight and missing loading overlay/spinner to match hold-history baseline
- Localize pagination controls from English (Prev/Next/Page/Total) to Chinese (上一頁/下一頁/頁/共)
- Remove ~120 lines of duplicated MultiSelect CSS from `style.css` (already provided by `resource-shared/styles.css`)
- Add a "重新整理" (refresh) button in the header, consistent with hold-history
- Extract monolithic `App.vue` (~968 lines) into focused sub-components mirroring hold-history's architecture: `FilterPanel`, `SummaryCards`, `TrendChart`, `ParetoSection`, `DetailTable`
## Capabilities
### New Capabilities
_(none — no new functional capabilities are introduced)_
### Modified Capabilities
- `reject-history-page`: UI/UX polish — add loading overlay, hover effects, localized pagination, header refresh button, and modular component extraction
## Impact
- **Files modified**: `frontend/src/reject-history/App.vue`, `frontend/src/reject-history/style.css`
- **Files created**: `frontend/src/reject-history/components/FilterPanel.vue`, `SummaryCards.vue`, `TrendChart.vue`, `ParetoSection.vue`, `DetailTable.vue`
- **No API changes** — all backend endpoints remain untouched
- **No dependency changes** — continues using `vue-echarts`, `resource-shared/MultiSelect`

View File

@@ -0,0 +1,78 @@
## MODIFIED Requirements
### Requirement: Reject History page SHALL provide filterable historical query controls
The page SHALL provide a filter area for date range and major production dimensions to drive all report sections.
#### Scenario: Default filter values
- **WHEN** the page is first loaded
- **THEN** `start_date` and `end_date` SHALL default to a valid recent range
- **THEN** all other dimension filters SHALL default to empty (no restriction)
#### Scenario: Apply and clear filters
- **WHEN** user clicks "查詢"
- **THEN** summary, trend, pareto, and list sections SHALL reload with the same filter set
- **WHEN** user clicks "清除條件"
- **THEN** all filters SHALL reset to defaults and all sections SHALL reload
#### Scenario: Required core filters are present
- **WHEN** the filter panel is rendered
- **THEN** it SHALL include `start_date/end_date` time filter controls
- **THEN** it SHALL include reason filter control
- **THEN** it SHALL include `WORKCENTER_GROUP` filter control
#### Scenario: Header refresh button
- **WHEN** the page header is rendered
- **THEN** it SHALL include a "重新整理" button in the header-right area
- **WHEN** user clicks the refresh button
- **THEN** all sections SHALL reload with current filters (equivalent to "查詢")
## ADDED Requirements
### Requirement: Reject History page SHALL display a loading overlay during initial data load
The page SHALL show a full-screen loading overlay with spinner during the first data load to provide clear feedback.
#### Scenario: Loading overlay on initial mount
- **WHEN** the page first mounts and `loadAllData` begins
- **THEN** a loading overlay with spinner SHALL be displayed over the page content
- **WHEN** all initial API responses complete
- **THEN** the overlay SHALL be hidden
#### Scenario: Subsequent queries do not show overlay
- **WHEN** the user triggers a re-query after initial load
- **THEN** no full-screen overlay SHALL appear (inline loading states are sufficient)
### Requirement: Detail table rows SHALL highlight on hover
The detail table and pareto table rows SHALL visually respond to mouse hover for improved readability.
#### Scenario: Row hover in detail table
- **WHEN** user hovers over a row in the detail table
- **THEN** the row background SHALL change to a subtle highlight color
#### Scenario: Row hover in pareto table
- **WHEN** user hovers over a row in the pareto summary table
- **THEN** the row background SHALL change to a subtle highlight color
### Requirement: Pagination controls SHALL use Chinese labels
The detail list pagination SHALL display controls in Chinese to match the rest of the page language.
#### Scenario: Pagination button labels
- **WHEN** the pagination controls are rendered
- **THEN** the previous-page button SHALL display "上一頁"
- **THEN** the next-page button SHALL display "下一頁"
- **THEN** the page info text SHALL use Chinese formatting (e.g., "第 1 / 5 頁 · 共 250 筆")
### Requirement: Reject History page SHALL be structured as modular sub-components
The page template SHALL delegate sections to focused sub-components, following the hold-history architecture pattern.
#### Scenario: Component decomposition
- **WHEN** the page source is examined
- **THEN** the filter panel SHALL be a separate `FilterPanel.vue` component
- **THEN** the KPI summary cards SHALL be a separate `SummaryCards.vue` component
- **THEN** the trend chart SHALL be a separate `TrendChart.vue` component
- **THEN** the pareto section (chart + table) SHALL be a separate `ParetoSection.vue` component
- **THEN** the detail table with pagination SHALL be a separate `DetailTable.vue` component
#### Scenario: App.vue acts as orchestrator
- **WHEN** the page runs
- **THEN** `App.vue` SHALL hold all reactive state and API logic
- **THEN** sub-components SHALL receive data via props and communicate via events

View File

@@ -0,0 +1,26 @@
## 1. Quick visual fixes (no component extraction needed)
- [x] 1.1 Add `tbody tr:hover` background rule to `style.css` for `.detail-table` and `.pareto-table`
- [x] 1.2 Localize pagination: change "Prev" → "上一頁", "Next" → "下一頁", "Page X / Y · Total Z" → "第 X / Y 頁 · 共 Z 筆"
- [x] 1.3 Add loading overlay + spinner after `.dashboard` div (`<div v-if="loading.initial" class="loading-overlay">`)
- [x] 1.4 Add "重新整理" button in header-right area, wired to `applyFilters`
- [x] 1.5 Remove duplicated MultiSelect CSS (~120 lines of `.multi-select-*` rules) from `style.css`; verify MultiSelect still renders correctly
## 2. Extract sub-components from App.vue
- [x] 2.1 Create `components/FilterPanel.vue` — extract filter grid, checkbox row, action buttons, and active-filter chips section; props: `filters`, `options`, `loading`, `activeFilterChips`; emits: `apply`, `clear`, `remove-chip`, `export-csv`, `pareto-scope-toggle`
- [x] 2.2 Create `components/SummaryCards.vue` — extract `.summary-row` section; props: `cards`
- [x] 2.3 Create `components/TrendChart.vue` — extract trend chart `.card` section with ECharts registration and chart option computed internally; props: `items`, `loading`
- [x] 2.4 Create `components/ParetoSection.vue` — extract pareto chart + table `.card` section with ECharts registration and chart option computed internally; props: `items`, `detailReason`, `loading`; emits: `reason-click`
- [x] 2.5 Create `components/DetailTable.vue` — extract detail table + pagination; props: `items`, `pagination`, `loading`; emits: `go-to-page`
## 3. Rewire App.vue as orchestrator
- [x] 3.1 Replace inline template sections with sub-component tags, passing props and wiring emits
- [x] 3.2 Move ECharts `use()` registration and chart computed properties into their respective chart components
- [x] 3.3 Verify all interactions work: filter apply/clear, pareto click → detail filter, pagination, CSV export, refresh button
## 4. Verify and build
- [x] 4.1 Run `vite build` and confirm no compilation errors
- [x] 4.2 Visually verify: loading overlay, table hover, Chinese pagination, refresh button, pareto interaction, filter chips

View File

@@ -12,6 +12,7 @@ from mes_dashboard.core.rate_limit import configured_rate_limit
from mes_dashboard.services.reject_history_service import (
export_csv,
get_filter_options,
query_analytics,
query_list,
query_reason_pareto,
query_summary,
@@ -86,44 +87,67 @@ def _extract_meta(
payload: dict,
include_excluded_scrap: bool,
exclude_material_scrap: bool,
exclude_pb_diode: bool = True,
) -> tuple[dict, dict]:
data = dict(payload or {})
meta = data.pop("meta", {}) if isinstance(data.get("meta"), dict) else {}
meta["include_excluded_scrap"] = bool(include_excluded_scrap)
meta["exclude_material_scrap"] = bool(exclude_material_scrap)
meta["exclude_pb_diode"] = bool(exclude_pb_diode)
return data, meta
def _parse_common_bools() -> tuple[Optional[tuple[dict, int]], bool, bool, bool]:
"""Parse include_excluded_scrap, exclude_material_scrap, exclude_pb_diode."""
include_excluded_scrap, err1 = _parse_bool(
request.args.get("include_excluded_scrap", ""),
name="include_excluded_scrap",
)
if err1:
return err1, False, True, True
exclude_material_scrap, err2 = _parse_bool(
request.args.get("exclude_material_scrap", "true"),
name="exclude_material_scrap",
)
if err2:
return err2, False, True, True
exclude_pb_diode, err3 = _parse_bool(
request.args.get("exclude_pb_diode", "true"),
name="exclude_pb_diode",
)
if err3:
return err3, False, True, True
return (
None,
bool(include_excluded_scrap),
bool(exclude_material_scrap),
bool(exclude_pb_diode),
)
@reject_history_bp.route("/api/reject-history/options", methods=["GET"])
def api_reject_history_options():
start_date, end_date, date_error = _parse_date_range(required=False)
if date_error:
return jsonify(date_error[0]), date_error[1]
include_excluded_scrap, bool_error = _parse_bool(
request.args.get("include_excluded_scrap", ""),
name="include_excluded_scrap",
)
bool_error, include_excluded_scrap, exclude_material_scrap, exclude_pb_diode = _parse_common_bools()
if bool_error:
return jsonify(bool_error[0]), bool_error[1]
exclude_material_scrap, material_bool_error = _parse_bool(
request.args.get("exclude_material_scrap", "true"),
name="exclude_material_scrap",
)
if material_bool_error:
return jsonify(material_bool_error[0]), material_bool_error[1]
try:
result = get_filter_options(
start_date=start_date,
end_date=end_date,
include_excluded_scrap=bool(include_excluded_scrap),
exclude_material_scrap=bool(exclude_material_scrap),
include_excluded_scrap=include_excluded_scrap,
exclude_material_scrap=exclude_material_scrap,
exclude_pb_diode=exclude_pb_diode,
)
data, meta = _extract_meta(
result,
bool(include_excluded_scrap),
bool(exclude_material_scrap),
include_excluded_scrap,
exclude_material_scrap,
exclude_pb_diode,
)
return jsonify({"success": True, "data": data, "meta": meta})
except ValueError as exc:
@@ -138,18 +162,9 @@ def api_reject_history_summary():
if date_error:
return jsonify(date_error[0]), date_error[1]
include_excluded_scrap, bool_error = _parse_bool(
request.args.get("include_excluded_scrap", ""),
name="include_excluded_scrap",
)
bool_error, include_excluded_scrap, exclude_material_scrap, exclude_pb_diode = _parse_common_bools()
if bool_error:
return jsonify(bool_error[0]), bool_error[1]
exclude_material_scrap, material_bool_error = _parse_bool(
request.args.get("exclude_material_scrap", "true"),
name="exclude_material_scrap",
)
if material_bool_error:
return jsonify(material_bool_error[0]), material_bool_error[1]
try:
result = query_summary(
@@ -159,13 +174,15 @@ def api_reject_history_summary():
packages=_parse_multi_param("packages") or None,
reasons=_parse_multi_param("reasons") or None,
categories=_parse_multi_param("categories") or None,
include_excluded_scrap=bool(include_excluded_scrap),
exclude_material_scrap=bool(exclude_material_scrap),
include_excluded_scrap=include_excluded_scrap,
exclude_material_scrap=exclude_material_scrap,
exclude_pb_diode=exclude_pb_diode,
)
data, meta = _extract_meta(
result,
bool(include_excluded_scrap),
bool(exclude_material_scrap),
include_excluded_scrap,
exclude_material_scrap,
exclude_pb_diode,
)
return jsonify({"success": True, "data": data, "meta": meta})
except ValueError as exc:
@@ -180,18 +197,9 @@ def api_reject_history_trend():
if date_error:
return jsonify(date_error[0]), date_error[1]
include_excluded_scrap, bool_error = _parse_bool(
request.args.get("include_excluded_scrap", ""),
name="include_excluded_scrap",
)
bool_error, include_excluded_scrap, exclude_material_scrap, exclude_pb_diode = _parse_common_bools()
if bool_error:
return jsonify(bool_error[0]), bool_error[1]
exclude_material_scrap, material_bool_error = _parse_bool(
request.args.get("exclude_material_scrap", "true"),
name="exclude_material_scrap",
)
if material_bool_error:
return jsonify(material_bool_error[0]), material_bool_error[1]
granularity = request.args.get("granularity", "day").strip().lower() or "day"
try:
@@ -203,13 +211,15 @@ def api_reject_history_trend():
packages=_parse_multi_param("packages") or None,
reasons=_parse_multi_param("reasons") or None,
categories=_parse_multi_param("categories") or None,
include_excluded_scrap=bool(include_excluded_scrap),
exclude_material_scrap=bool(exclude_material_scrap),
include_excluded_scrap=include_excluded_scrap,
exclude_material_scrap=exclude_material_scrap,
exclude_pb_diode=exclude_pb_diode,
)
data, meta = _extract_meta(
result,
bool(include_excluded_scrap),
bool(exclude_material_scrap),
include_excluded_scrap,
exclude_material_scrap,
exclude_pb_diode,
)
return jsonify({"success": True, "data": data, "meta": meta})
except ValueError as exc:
@@ -224,18 +234,9 @@ def api_reject_history_reason_pareto():
if date_error:
return jsonify(date_error[0]), date_error[1]
include_excluded_scrap, bool_error = _parse_bool(
request.args.get("include_excluded_scrap", ""),
name="include_excluded_scrap",
)
bool_error, include_excluded_scrap, exclude_material_scrap, exclude_pb_diode = _parse_common_bools()
if bool_error:
return jsonify(bool_error[0]), bool_error[1]
exclude_material_scrap, material_bool_error = _parse_bool(
request.args.get("exclude_material_scrap", "true"),
name="exclude_material_scrap",
)
if material_bool_error:
return jsonify(material_bool_error[0]), material_bool_error[1]
metric_mode = request.args.get("metric_mode", "reject_total").strip().lower() or "reject_total"
pareto_scope = request.args.get("pareto_scope", "top80").strip().lower() or "top80"
@@ -250,13 +251,15 @@ def api_reject_history_reason_pareto():
packages=_parse_multi_param("packages") or None,
reasons=_parse_multi_param("reasons") or None,
categories=_parse_multi_param("categories") or None,
include_excluded_scrap=bool(include_excluded_scrap),
exclude_material_scrap=bool(exclude_material_scrap),
include_excluded_scrap=include_excluded_scrap,
exclude_material_scrap=exclude_material_scrap,
exclude_pb_diode=exclude_pb_diode,
)
data, meta = _extract_meta(
result,
bool(include_excluded_scrap),
bool(exclude_material_scrap),
include_excluded_scrap,
exclude_material_scrap,
exclude_pb_diode,
)
return jsonify({"success": True, "data": data, "meta": meta})
except ValueError as exc:
@@ -272,18 +275,9 @@ def api_reject_history_list():
if date_error:
return jsonify(date_error[0]), date_error[1]
include_excluded_scrap, bool_error = _parse_bool(
request.args.get("include_excluded_scrap", ""),
name="include_excluded_scrap",
)
bool_error, include_excluded_scrap, exclude_material_scrap, exclude_pb_diode = _parse_common_bools()
if bool_error:
return jsonify(bool_error[0]), bool_error[1]
exclude_material_scrap, material_bool_error = _parse_bool(
request.args.get("exclude_material_scrap", "true"),
name="exclude_material_scrap",
)
if material_bool_error:
return jsonify(material_bool_error[0]), material_bool_error[1]
page = request.args.get("page", 1, type=int) or 1
per_page = request.args.get("per_page", 50, type=int) or 50
@@ -298,13 +292,15 @@ def api_reject_history_list():
packages=_parse_multi_param("packages") or None,
reasons=_parse_multi_param("reasons") or None,
categories=_parse_multi_param("categories") or None,
include_excluded_scrap=bool(include_excluded_scrap),
exclude_material_scrap=bool(exclude_material_scrap),
include_excluded_scrap=include_excluded_scrap,
exclude_material_scrap=exclude_material_scrap,
exclude_pb_diode=exclude_pb_diode,
)
data, meta = _extract_meta(
result,
bool(include_excluded_scrap),
bool(exclude_material_scrap),
include_excluded_scrap,
exclude_material_scrap,
exclude_pb_diode,
)
return jsonify({"success": True, "data": data, "meta": meta})
except ValueError as exc:
@@ -320,18 +316,9 @@ def api_reject_history_export():
if date_error:
return jsonify(date_error[0]), date_error[1]
include_excluded_scrap, bool_error = _parse_bool(
request.args.get("include_excluded_scrap", ""),
name="include_excluded_scrap",
)
bool_error, include_excluded_scrap, exclude_material_scrap, exclude_pb_diode = _parse_common_bools()
if bool_error:
return jsonify(bool_error[0]), bool_error[1]
exclude_material_scrap, material_bool_error = _parse_bool(
request.args.get("exclude_material_scrap", "true"),
name="exclude_material_scrap",
)
if material_bool_error:
return jsonify(material_bool_error[0]), material_bool_error[1]
filename = f"reject_history_{start_date}_to_{end_date}.csv"
try:
@@ -343,8 +330,9 @@ def api_reject_history_export():
packages=_parse_multi_param("packages") or None,
reasons=_parse_multi_param("reasons") or None,
categories=_parse_multi_param("categories") or None,
include_excluded_scrap=bool(include_excluded_scrap),
exclude_material_scrap=bool(exclude_material_scrap),
include_excluded_scrap=include_excluded_scrap,
exclude_material_scrap=exclude_material_scrap,
exclude_pb_diode=exclude_pb_diode,
),
mimetype="text/csv",
headers={
@@ -356,3 +344,41 @@ def api_reject_history_export():
return jsonify({"success": False, "error": str(exc)}), 400
except Exception:
return jsonify({"success": False, "error": "匯出 CSV 失敗"}), 500
@reject_history_bp.route("/api/reject-history/analytics", methods=["GET"])
def api_reject_history_analytics():
start_date, end_date, date_error = _parse_date_range(required=True)
if date_error:
return jsonify(date_error[0]), date_error[1]
bool_error, include_excluded_scrap, exclude_material_scrap, exclude_pb_diode = _parse_common_bools()
if bool_error:
return jsonify(bool_error[0]), bool_error[1]
metric_mode = request.args.get("metric_mode", "reject_total").strip().lower() or "reject_total"
try:
result = query_analytics(
start_date=start_date,
end_date=end_date,
metric_mode=metric_mode,
workcenter_groups=_parse_multi_param("workcenter_groups") or None,
packages=_parse_multi_param("packages") or None,
reasons=_parse_multi_param("reasons") or None,
categories=_parse_multi_param("categories") or None,
include_excluded_scrap=include_excluded_scrap,
exclude_material_scrap=exclude_material_scrap,
exclude_pb_diode=exclude_pb_diode,
)
data, meta = _extract_meta(
result,
include_excluded_scrap,
exclude_material_scrap,
exclude_pb_diode,
)
return jsonify({"success": True, "data": data, "meta": meta})
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
except Exception:
return jsonify({"success": False, "error": "查詢分析資料失敗"}), 500

View File

@@ -16,6 +16,7 @@ from mes_dashboard.services.wip_service import (
get_wip_detail,
get_workcenters,
get_packages,
get_wip_filter_options,
search_workorders,
search_lot_ids,
search_packages,
@@ -56,6 +57,8 @@ def api_overview_summary():
lotid: Optional LOTID filter (fuzzy match)
package: Optional PACKAGE_LEF filter (exact match)
pj_type: Optional PJ_TYPE filter (exact match)
firstname: Optional FIRSTNAME filter (exact match)
waferdesc: Optional WAFERDESC filter (exact match)
include_dummy: Include DUMMY lots (default: false)
Returns:
@@ -65,6 +68,8 @@ def api_overview_summary():
lotid = request.args.get('lotid', '').strip() or None
package = request.args.get('package', '').strip() or None
pj_type = request.args.get('type', '').strip() or None
firstname = request.args.get('firstname', '').strip() or None
waferdesc = request.args.get('waferdesc', '').strip() or None
include_dummy = parse_bool_query(request.args.get('include_dummy'))
result = get_wip_summary(
@@ -72,7 +77,9 @@ def api_overview_summary():
workorder=workorder,
lotid=lotid,
package=package,
pj_type=pj_type
pj_type=pj_type,
firstname=firstname,
waferdesc=waferdesc,
)
if result is not None:
return jsonify({'success': True, 'data': result})
@@ -89,6 +96,8 @@ def api_overview_matrix():
lotid: Optional LOTID filter (fuzzy match)
package: Optional PACKAGE_LEF filter (exact match)
pj_type: Optional PJ_TYPE filter (exact match)
firstname: Optional FIRSTNAME filter (exact match)
waferdesc: Optional WAFERDESC filter (exact match)
include_dummy: Include DUMMY lots (default: false)
status: Optional WIP status filter ('RUN', 'QUEUE', 'HOLD')
hold_type: Optional hold type filter ('quality', 'non-quality')
@@ -102,6 +111,8 @@ def api_overview_matrix():
lotid = request.args.get('lotid', '').strip() or None
package = request.args.get('package', '').strip() or None
pj_type = request.args.get('type', '').strip() or None
firstname = request.args.get('firstname', '').strip() or None
waferdesc = request.args.get('waferdesc', '').strip() or None
include_dummy = parse_bool_query(request.args.get('include_dummy'))
status = request.args.get('status', '').strip().upper() or None
hold_type = request.args.get('hold_type', '').strip().lower() or None
@@ -127,7 +138,9 @@ def api_overview_matrix():
status=status,
hold_type=hold_type,
package=package,
pj_type=pj_type
pj_type=pj_type,
firstname=firstname,
waferdesc=waferdesc,
)
if result is not None:
return jsonify({'success': True, 'data': result})
@@ -141,6 +154,10 @@ def api_overview_hold():
Query Parameters:
workorder: Optional WORKORDER filter (fuzzy match)
lotid: Optional LOTID filter (fuzzy match)
package: Optional PACKAGE_LEF filter (exact match)
type: Optional PJ_TYPE filter (exact match)
firstname: Optional FIRSTNAME filter (exact match)
waferdesc: Optional WAFERDESC filter (exact match)
include_dummy: Include DUMMY lots (default: false)
Returns:
@@ -148,12 +165,20 @@ def api_overview_hold():
"""
workorder = request.args.get('workorder', '').strip() or None
lotid = request.args.get('lotid', '').strip() or None
package = request.args.get('package', '').strip() or None
pj_type = request.args.get('type', '').strip() or None
firstname = request.args.get('firstname', '').strip() or None
waferdesc = request.args.get('waferdesc', '').strip() or None
include_dummy = parse_bool_query(request.args.get('include_dummy'))
result = get_wip_hold_summary(
include_dummy=include_dummy,
workorder=workorder,
lotid=lotid
lotid=lotid,
package=package,
pj_type=pj_type,
firstname=firstname,
waferdesc=waferdesc,
)
if result is not None:
return jsonify({'success': True, 'data': result})
@@ -175,6 +200,8 @@ def api_detail(workcenter: str):
Query Parameters:
package: Optional PRODUCTLINENAME filter
type: Optional PJ_TYPE filter (exact match)
firstname: Optional FIRSTNAME filter (exact match)
waferdesc: Optional WAFERDESC filter (exact match)
status: Optional WIP status filter ('RUN', 'QUEUE', 'HOLD')
hold_type: Optional hold type filter ('quality', 'non-quality')
Only effective when status='HOLD'
@@ -189,6 +216,8 @@ def api_detail(workcenter: str):
"""
package = request.args.get('package', '').strip() or None
pj_type = request.args.get('type', '').strip() or None
firstname = request.args.get('firstname', '').strip() or None
waferdesc = request.args.get('waferdesc', '').strip() or None
status = request.args.get('status', '').strip().upper() or None
hold_type = request.args.get('hold_type', '').strip().lower() or None
workorder = request.args.get('workorder', '').strip() or None
@@ -223,6 +252,8 @@ def api_detail(workcenter: str):
workcenter=workcenter,
package=package,
pj_type=pj_type,
firstname=firstname,
waferdesc=waferdesc,
status=status,
hold_type=hold_type,
workorder=workorder,
@@ -294,6 +325,31 @@ def api_meta_packages():
return jsonify({'success': False, 'error': '查詢失敗'}), 500
@wip_bp.route('/meta/filter-options')
def api_meta_filter_options():
"""API: Get interdependent WIP overview filter options from cache-backed source."""
include_dummy = parse_bool_query(request.args.get('include_dummy'))
workorder = request.args.get('workorder', '').strip() or None
lotid = request.args.get('lotid', '').strip() or None
package = request.args.get('package', '').strip() or None
pj_type = request.args.get('type', '').strip() or None
firstname = request.args.get('firstname', '').strip() or None
waferdesc = request.args.get('waferdesc', '').strip() or None
result = get_wip_filter_options(
include_dummy=include_dummy,
workorder=workorder,
lotid=lotid,
package=package,
pj_type=pj_type,
firstname=firstname,
waferdesc=waferdesc,
)
if result is not None:
return jsonify({'success': True, 'data': result})
return jsonify({'success': False, 'error': '查詢失敗'}), 500
@wip_bp.route('/meta/search')
def api_meta_search():
"""API: Search for WORKORDER, LOTID, PACKAGE, or PJ_TYPE values.

View File

@@ -103,8 +103,9 @@ def _load_sql(name: str) -> str:
return SQLLoader.load(f"reject_history/{name}")
def _base_query_sql() -> str:
sql = _load_sql("performance_daily").strip().rstrip(";")
def _base_query_sql(variant: str = "") -> str:
sql_name = "performance_daily_lot" if variant == "lot" else "performance_daily"
sql = _load_sql(sql_name).strip().rstrip(";")
# Strip leading comment/blank lines so WITH parsing can detect the first SQL token.
lines = sql.splitlines()
first_sql_line = 0
@@ -161,8 +162,8 @@ def _split_with_query(sql: str) -> tuple[str, str] | None:
return None
def _base_with_cte_sql(alias: str = "base") -> str:
base_sql = _base_query_sql()
def _base_with_cte_sql(alias: str = "base", variant: str = "") -> str:
base_sql = _base_query_sql(variant)
split = _split_with_query(base_sql)
if split is None:
return f"WITH {alias} AS (\n{base_sql}\n)"
@@ -178,6 +179,7 @@ def _build_where_clause(
categories: Optional[list[str]] = None,
include_excluded_scrap: bool = False,
exclude_material_scrap: bool = True,
exclude_pb_diode: bool = True,
) -> tuple[str, dict[str, Any], dict[str, Any]]:
builder = QueryBuilder()
@@ -200,6 +202,10 @@ def _build_where_clause(
if exclude_material_scrap and not material_reason_selected:
builder.add_condition("UPPER(NVL(TRIM(b.SCRAP_OBJECTTYPE), '-')) <> 'MATERIAL'")
material_exclusion_applied = True
pb_diode_exclusion_applied = False
if exclude_pb_diode and "PB_Diode" not in normalized_packages:
builder.add_condition("b.PRODUCTLINENAME <> 'PB_Diode'")
pb_diode_exclusion_applied = True
if normalized_categories:
builder.add_in_condition("b.REJECTCATEGORYNAME", normalized_categories)
@@ -241,6 +247,8 @@ def _build_where_clause(
"package_filter_count": len(normalized_packages),
"reason_filter_count": len(reason_name_filters),
"material_reason_selected": material_reason_selected,
"exclude_pb_diode": bool(exclude_pb_diode),
"pb_diode_exclusion_applied": pb_diode_exclusion_applied,
}
return where_clause, params, meta
@@ -251,10 +259,11 @@ def _prepare_sql(
where_clause: str = "",
bucket_expr: str = "",
metric_column: str = "",
base_variant: str = "",
) -> str:
sql = _load_sql(name)
sql = sql.replace("{{ BASE_QUERY }}", _base_query_sql())
sql = sql.replace("{{ BASE_WITH_CTE }}", _base_with_cte_sql("base"))
sql = sql.replace("{{ BASE_QUERY }}", _base_query_sql(base_variant))
sql = sql.replace("{{ BASE_WITH_CTE }}", _base_with_cte_sql("base", base_variant))
sql = sql.replace("{{ WHERE_CLAUSE }}", where_clause or "")
sql = sql.replace("{{ BUCKET_EXPR }}", bucket_expr or "TRUNC(b.TXN_DAY)")
sql = sql.replace("{{ METRIC_COLUMN }}", metric_column or "b.REJECT_TOTAL_QTY")
@@ -292,39 +301,41 @@ def get_filter_options(
end_date: str,
include_excluded_scrap: bool = False,
exclude_material_scrap: bool = True,
exclude_pb_diode: bool = True,
) -> dict[str, Any]:
"""Return workcenter-group / package / reason options."""
"""Return workcenter-group / package / reason options (single DB query)."""
_validate_range(start_date, end_date)
where_clause, params, meta = _build_where_clause(
include_excluded_scrap=include_excluded_scrap,
exclude_material_scrap=exclude_material_scrap,
exclude_pb_diode=exclude_pb_diode,
)
reason_sql = _prepare_sql("reason_options", where_clause=where_clause)
reason_df = read_sql_df(reason_sql, _common_params(start_date, end_date, params))
reasons = []
if reason_df is not None and not reason_df.empty:
reasons = [
_normalize_text(v)
for v in reason_df.get("REASON", [])
if _normalize_text(v)
]
sql = _prepare_sql("filter_options", where_clause=where_clause)
df = read_sql_df(sql, _common_params(start_date, end_date, params))
material_sql = _prepare_sql("material_reason_option", where_clause=where_clause)
material_df = read_sql_df(material_sql, _common_params(start_date, end_date, params))
reasons: list[str] = []
packages: list[str] = []
has_material_option = False
if material_df is not None and not material_df.empty:
has_material_option = _as_int(material_df.iloc[0].get("HAS_MATERIAL")) > 0
package_sql = _prepare_sql("package_options", where_clause=where_clause)
package_df = read_sql_df(package_sql, _common_params(start_date, end_date, params))
packages = []
if package_df is not None and not package_df.empty:
packages = [
if df is not None and not df.empty:
reasons = sorted({
_normalize_text(v)
for v in package_df.get("PACKAGE", [])
for v in df["REASON"].dropna()
if _normalize_text(v)
]
})
packages = sorted({
_normalize_text(v)
for v in df["PACKAGE"].dropna()
if _normalize_text(v)
})
if "SCRAP_OBJECTTYPE" in df.columns:
has_material_option = (
df["SCRAP_OBJECTTYPE"]
.apply(lambda v: str(v or "").strip().upper())
.eq("MATERIAL")
.any()
)
groups_raw = get_workcenter_groups() or []
workcenter_groups = []
@@ -360,6 +371,7 @@ def query_summary(
categories: Optional[list[str]] = None,
include_excluded_scrap: bool = False,
exclude_material_scrap: bool = True,
exclude_pb_diode: bool = True,
) -> dict[str, Any]:
_validate_range(start_date, end_date)
where_clause, params, meta = _build_where_clause(
@@ -369,6 +381,7 @@ def query_summary(
categories=categories,
include_excluded_scrap=include_excluded_scrap,
exclude_material_scrap=exclude_material_scrap,
exclude_pb_diode=exclude_pb_diode,
)
sql = _prepare_sql("summary", where_clause=where_clause)
df = read_sql_df(sql, _common_params(start_date, end_date, params))
@@ -398,6 +411,7 @@ def query_trend(
categories: Optional[list[str]] = None,
include_excluded_scrap: bool = False,
exclude_material_scrap: bool = True,
exclude_pb_diode: bool = True,
) -> dict[str, Any]:
_validate_range(start_date, end_date)
normalized_granularity = _normalize_text(granularity).lower() or "day"
@@ -411,6 +425,7 @@ def query_trend(
categories=categories,
include_excluded_scrap=include_excluded_scrap,
exclude_material_scrap=exclude_material_scrap,
exclude_pb_diode=exclude_pb_diode,
)
sql = _prepare_sql(
"trend",
@@ -451,6 +466,7 @@ def query_reason_pareto(
categories: Optional[list[str]] = None,
include_excluded_scrap: bool = False,
exclude_material_scrap: bool = True,
exclude_pb_diode: bool = True,
) -> dict[str, Any]:
_validate_range(start_date, end_date)
normalized_metric = _normalize_text(metric_mode).lower() or "reject_total"
@@ -468,6 +484,7 @@ def query_reason_pareto(
categories=categories,
include_excluded_scrap=include_excluded_scrap,
exclude_material_scrap=exclude_material_scrap,
exclude_pb_diode=exclude_pb_diode,
)
sql = _prepare_sql(
"reason_pareto",
@@ -523,6 +540,7 @@ def query_list(
categories: Optional[list[str]] = None,
include_excluded_scrap: bool = False,
exclude_material_scrap: bool = True,
exclude_pb_diode: bool = True,
) -> dict[str, Any]:
_validate_range(start_date, end_date)
@@ -537,8 +555,9 @@ def query_list(
categories=categories,
include_excluded_scrap=include_excluded_scrap,
exclude_material_scrap=exclude_material_scrap,
exclude_pb_diode=exclude_pb_diode,
)
sql = _prepare_sql("list", where_clause=where_clause)
sql = _prepare_sql("list", where_clause=where_clause, base_variant="lot")
query_params = _common_params(
start_date,
end_date,
@@ -564,6 +583,9 @@ def query_list(
"SPECNAME": _normalize_text(row.get("SPECNAME")),
"PRODUCTLINENAME": _normalize_text(row.get("PRODUCTLINENAME")),
"PJ_TYPE": _normalize_text(row.get("PJ_TYPE")),
"CONTAINERNAME": _normalize_text(row.get("CONTAINERNAME")),
"PJ_FUNCTION": _normalize_text(row.get("PJ_FUNCTION")),
"PRODUCTNAME": _normalize_text(row.get("PRODUCTNAME")),
"LOSSREASONNAME": _normalize_text(row.get("LOSSREASONNAME")),
"LOSSREASON_CODE": _normalize_text(row.get("LOSSREASON_CODE")),
"MOVEIN_QTY": _as_int(row.get("MOVEIN_QTY")),
@@ -577,7 +599,6 @@ def query_list(
"REJECT_RATE_PCT": round(_as_float(row.get("REJECT_RATE_PCT")), 4),
"DEFECT_RATE_PCT": round(_as_float(row.get("DEFECT_RATE_PCT")), 4),
"REJECT_SHARE_PCT": round(_as_float(row.get("REJECT_SHARE_PCT")), 4),
"AFFECTED_LOT_COUNT": _as_int(row.get("AFFECTED_LOT_COUNT")),
"AFFECTED_WORKORDER_COUNT": _as_int(row.get("AFFECTED_WORKORDER_COUNT")),
}
)
@@ -605,6 +626,7 @@ def export_csv(
categories: Optional[list[str]] = None,
include_excluded_scrap: bool = False,
exclude_material_scrap: bool = True,
exclude_pb_diode: bool = True,
) -> Generator[str, None, None]:
_validate_range(start_date, end_date)
@@ -615,6 +637,7 @@ def export_csv(
categories=categories,
include_excluded_scrap=include_excluded_scrap,
exclude_material_scrap=exclude_material_scrap,
exclude_pb_diode=exclude_pb_diode,
)
sql = _prepare_sql("export", where_clause=where_clause)
df = read_sql_df(sql, _common_params(start_date, end_date, params))
@@ -674,3 +697,168 @@ def export_csv(
"AFFECTED_WORKORDER_COUNT",
]
return _list_to_csv(rows, headers=headers)
def _derive_summary(df: pd.DataFrame) -> dict[str, Any]:
"""Aggregate analytics rows into a single summary dict."""
if df is None or df.empty:
return {
"MOVEIN_QTY": 0,
"REJECT_TOTAL_QTY": 0,
"DEFECT_QTY": 0,
"REJECT_RATE_PCT": 0,
"DEFECT_RATE_PCT": 0,
"REJECT_SHARE_PCT": 0,
"AFFECTED_LOT_COUNT": 0,
"AFFECTED_WORKORDER_COUNT": 0,
}
movein = _as_int(df["MOVEIN_QTY"].sum())
reject_total = _as_int(df["REJECT_TOTAL_QTY"].sum())
defect = _as_int(df["DEFECT_QTY"].sum())
affected_lot = _as_int(df["AFFECTED_LOT_COUNT"].sum())
affected_wo = _as_int(df["AFFECTED_WORKORDER_COUNT"].sum())
total_scrap = reject_total + defect
reject_rate = round((reject_total / movein * 100) if movein else 0, 4)
defect_rate = round((defect / movein * 100) if movein else 0, 4)
reject_share = round((reject_total / total_scrap * 100) if total_scrap else 0, 4)
return {
"MOVEIN_QTY": movein,
"REJECT_TOTAL_QTY": reject_total,
"DEFECT_QTY": defect,
"REJECT_RATE_PCT": reject_rate,
"DEFECT_RATE_PCT": defect_rate,
"REJECT_SHARE_PCT": reject_share,
"AFFECTED_LOT_COUNT": affected_lot,
"AFFECTED_WORKORDER_COUNT": affected_wo,
}
def _derive_trend(df: pd.DataFrame) -> list[dict[str, Any]]:
"""Group analytics rows by BUCKET_DATE into daily trend items."""
if df is None or df.empty:
return []
grouped = df.groupby("BUCKET_DATE", sort=True).agg(
MOVEIN_QTY=("MOVEIN_QTY", "sum"),
REJECT_TOTAL_QTY=("REJECT_TOTAL_QTY", "sum"),
DEFECT_QTY=("DEFECT_QTY", "sum"),
).reset_index()
items = []
for _, row in grouped.iterrows():
movein = _as_int(row["MOVEIN_QTY"])
reject_total = _as_int(row["REJECT_TOTAL_QTY"])
defect = _as_int(row["DEFECT_QTY"])
items.append({
"bucket_date": _to_date_str(row["BUCKET_DATE"]),
"MOVEIN_QTY": movein,
"REJECT_TOTAL_QTY": reject_total,
"DEFECT_QTY": defect,
"REJECT_RATE_PCT": round((reject_total / movein * 100) if movein else 0, 4),
"DEFECT_RATE_PCT": round((defect / movein * 100) if movein else 0, 4),
})
return items
def _derive_pareto(df: pd.DataFrame, metric_mode: str = "reject_total") -> list[dict[str, Any]]:
"""Group analytics rows by REASON into pareto items with PCT/CUM_PCT."""
if df is None or df.empty:
return []
metric_col = "REJECT_TOTAL_QTY" if metric_mode == "reject_total" else "DEFECT_QTY"
grouped = df.groupby("REASON", sort=False).agg(
MOVEIN_QTY=("MOVEIN_QTY", "sum"),
REJECT_TOTAL_QTY=("REJECT_TOTAL_QTY", "sum"),
DEFECT_QTY=("DEFECT_QTY", "sum"),
AFFECTED_LOT_COUNT=("AFFECTED_LOT_COUNT", "sum"),
).reset_index()
grouped = grouped.sort_values(metric_col, ascending=False).reset_index(drop=True)
total_metric = _as_float(grouped[metric_col].sum())
items = []
cum = 0.0
for _, row in grouped.iterrows():
metric_value = _as_float(row[metric_col])
pct = round((metric_value / total_metric * 100) if total_metric else 0, 4)
cum += pct
reason_text = _normalize_text(row["REASON"]) or "(未填寫)"
items.append({
"reason": reason_text,
"metric_value": metric_value,
"MOVEIN_QTY": _as_int(row["MOVEIN_QTY"]),
"REJECT_TOTAL_QTY": _as_int(row["REJECT_TOTAL_QTY"]),
"DEFECT_QTY": _as_int(row["DEFECT_QTY"]),
"count": _as_int(row["AFFECTED_LOT_COUNT"]),
"pct": pct,
"cumPct": round(cum, 4),
})
return items
def _derive_raw_items(df: pd.DataFrame) -> list[dict[str, Any]]:
"""Return per-(date, reason) rows for client-side re-derivation."""
if df is None or df.empty:
return []
items = []
for _, row in df.iterrows():
items.append({
"bucket_date": _to_date_str(row["BUCKET_DATE"]),
"reason": _normalize_text(row["REASON"]) or "(未填寫)",
"MOVEIN_QTY": _as_int(row["MOVEIN_QTY"]),
"REJECT_TOTAL_QTY": _as_int(row["REJECT_TOTAL_QTY"]),
"DEFECT_QTY": _as_int(row["DEFECT_QTY"]),
"AFFECTED_LOT_COUNT": _as_int(row["AFFECTED_LOT_COUNT"]),
"AFFECTED_WORKORDER_COUNT": _as_int(row["AFFECTED_WORKORDER_COUNT"]),
})
return items
def query_analytics(
*,
start_date: str,
end_date: str,
metric_mode: str = "reject_total",
workcenter_groups: Optional[list[str]] = None,
packages: Optional[list[str]] = None,
reasons: Optional[list[str]] = None,
categories: Optional[list[str]] = None,
include_excluded_scrap: bool = False,
exclude_material_scrap: bool = True,
exclude_pb_diode: bool = True,
) -> dict[str, Any]:
"""Single DB query → summary + trend + pareto (replaces 3 separate queries)."""
_validate_range(start_date, end_date)
normalized_metric = _normalize_text(metric_mode).lower() or "reject_total"
if normalized_metric not in VALID_METRIC_MODE:
raise ValueError("Invalid metric_mode. Use reject_total or defect")
where_clause, params, meta = _build_where_clause(
workcenter_groups=workcenter_groups,
packages=packages,
reasons=reasons,
categories=categories,
include_excluded_scrap=include_excluded_scrap,
exclude_material_scrap=exclude_material_scrap,
exclude_pb_diode=exclude_pb_diode,
)
sql = _prepare_sql("analytics", where_clause=where_clause)
df = read_sql_df(sql, _common_params(start_date, end_date, params))
return {
"summary": _derive_summary(df),
"trend": {
"items": _derive_trend(df),
"granularity": "day",
},
"pareto": {
"items": _derive_pareto(df, normalized_metric),
"metric_mode": normalized_metric,
},
"raw_items": _derive_raw_items(df),
"meta": meta,
}

View File

@@ -87,12 +87,14 @@ def _build_base_conditions_builder(
builder.add_condition("LOTID NOT LIKE '%DUMMY%'")
# WORKORDER filter (fuzzy match)
if workorder:
builder.add_like_condition("WORKORDER", workorder, position="both")
workorders = _split_csv_values(workorder)
if workorders:
builder.add_or_like_conditions("WORKORDER", workorders, position="both", case_insensitive=True)
# LOTID filter (fuzzy match)
if lotid:
builder.add_like_condition("LOTID", lotid, position="both")
lotids = _split_csv_values(lotid)
if lotids:
builder.add_or_like_conditions("LOTID", lotids, position="both", case_insensitive=True)
return builder
@@ -198,6 +200,57 @@ def _normalize_text_value(value: Any) -> str:
return text
def _split_csv_values(raw: Optional[str]) -> List[str]:
if not raw:
return []
values: List[str] = []
seen = set()
for token in str(raw).split(","):
text = token.strip()
if not text or text in seen:
continue
values.append(text)
seen.add(text)
return values
def _contains_any_mask(series: pd.Series, raw_values: Optional[str]) -> pd.Series:
values = _split_csv_values(raw_values)
if not values:
return pd.Series(True, index=series.index)
text_series = series.astype(str)
mask = pd.Series(False, index=series.index)
for value in values:
mask |= text_series.str.contains(value, case=False, na=False)
return mask
def _add_exact_filter_conditions(builder: QueryBuilder, column: str, raw_values: Optional[str]) -> QueryBuilder:
values = _split_csv_values(raw_values)
if not values:
return builder
if len(values) == 1:
builder.add_param_condition(column, values[0])
return builder
builder.add_in_condition(column, values)
return builder
def _lookup_positions(index_map: Dict[str, np.ndarray], raw_values: Optional[str]) -> Optional[np.ndarray]:
values = _split_csv_values(raw_values)
if not values:
return None
buckets = [index_map.get(str(value)) for value in values]
buckets = [bucket for bucket in buckets if bucket is not None and len(bucket) > 0]
if not buckets:
return _EMPTY_INT_INDEX
if len(buckets) == 1:
return buckets[0]
return np.unique(np.concatenate(buckets))
def _build_filter_mask(
df: pd.DataFrame,
*,
@@ -214,10 +267,10 @@ def _build_filter_mask(
mask &= ~df['LOTID'].astype(str).str.contains('DUMMY', case=False, na=False)
if workorder and 'WORKORDER' in df.columns:
mask &= df['WORKORDER'].astype(str).str.contains(workorder, case=False, na=False)
mask &= _contains_any_mask(df['WORKORDER'], workorder)
if lotid and 'LOTID' in df.columns:
mask &= df['LOTID'].astype(str).str.contains(lotid, case=False, na=False)
mask &= _contains_any_mask(df['LOTID'], lotid)
return mask
@@ -245,6 +298,8 @@ def _select_with_snapshot_indexes(
lotid: Optional[str] = None,
package: Optional[str] = None,
pj_type: Optional[str] = None,
firstname: Optional[str] = None,
waferdesc: Optional[str] = None,
workcenter: Optional[str] = None,
status: Optional[str] = None,
hold_type: Optional[str] = None,
@@ -260,17 +315,27 @@ def _select_with_snapshot_indexes(
if workcenter:
selected_positions = _intersect_positions(
selected_positions,
indexes["workcenter"].get(str(workcenter)),
_lookup_positions(indexes["workcenter"], workcenter),
)
if package:
selected_positions = _intersect_positions(
selected_positions,
indexes["package"].get(str(package)),
_lookup_positions(indexes["package"], package),
)
if pj_type:
selected_positions = _intersect_positions(
selected_positions,
indexes["pj_type"].get(str(pj_type)),
_lookup_positions(indexes["pj_type"], pj_type),
)
if firstname:
selected_positions = _intersect_positions(
selected_positions,
_lookup_positions(indexes["firstname"], firstname),
)
if waferdesc:
selected_positions = _intersect_positions(
selected_positions,
_lookup_positions(indexes["waferdesc"], waferdesc),
)
if status:
selected_positions = _intersect_positions(
@@ -291,13 +356,15 @@ def _select_with_snapshot_indexes(
result = df.iloc[selected_positions]
if workorder:
result = result[result['WORKORDER'].astype(str).str.contains(workorder, case=False, na=False)]
result = result[_contains_any_mask(result['WORKORDER'], workorder)]
if lotid:
result = result[result['LOTID'].astype(str).str.contains(lotid, case=False, na=False)]
result = result[_contains_any_mask(result['LOTID'], lotid)]
return result
def _build_search_signatures(df: pd.DataFrame) -> tuple[Counter, Dict[str, tuple[str, str, str, str]]]:
def _build_search_signatures(
df: pd.DataFrame,
) -> tuple[Counter, Dict[str, tuple[str, str, str, str, str, str]]]:
if df.empty:
return Counter(), {}
@@ -305,6 +372,8 @@ def _build_search_signatures(df: pd.DataFrame) -> tuple[Counter, Dict[str, tuple
lotids = df.get("LOTID", pd.Series(index=df.index, dtype=object)).map(_normalize_text_value)
packages = df.get("PACKAGE_LEF", pd.Series(index=df.index, dtype=object)).map(_normalize_text_value)
types = df.get("PJ_TYPE", pd.Series(index=df.index, dtype=object)).map(_normalize_text_value)
firstnames = df.get("FIRSTNAME", pd.Series(index=df.index, dtype=object)).map(_normalize_text_value)
waferdescs = df.get("WAFERDESC", pd.Series(index=df.index, dtype=object)).map(_normalize_text_value)
signatures = (
workorders
@@ -314,28 +383,49 @@ def _build_search_signatures(df: pd.DataFrame) -> tuple[Counter, Dict[str, tuple
+ packages
+ "\x1f"
+ types
+ "\x1f"
+ firstnames
+ "\x1f"
+ waferdescs
).tolist()
signature_counter = Counter(signatures)
signature_fields: Dict[str, tuple[str, str, str, str]] = {}
for signature, wo, lot, pkg, pj in zip(signatures, workorders, lotids, packages, types):
signature_fields: Dict[str, tuple[str, str, str, str, str, str]] = {}
for signature, wo, lot, pkg, pj, first, wafer in zip(
signatures,
workorders,
lotids,
packages,
types,
firstnames,
waferdescs,
):
if signature not in signature_fields:
signature_fields[signature] = (wo, lot, pkg, pj)
signature_fields[signature] = (wo, lot, pkg, pj, first, wafer)
return signature_counter, signature_fields
def _decode_signature_fields(signature: str) -> tuple[str, str, str, str, str, str]:
parts = [str(value) for value in str(signature).split("\x1f")]
if len(parts) < 6:
parts.extend([""] * (6 - len(parts)))
return tuple(parts[:6])
def _build_field_counters(
signature_counter: Counter,
signature_fields: Dict[str, tuple[str, str, str, str]],
signature_fields: Dict[str, tuple[str, str, str, str, str, str]],
) -> Dict[str, Counter]:
counters = {
"workorders": Counter(),
"lotids": Counter(),
"packages": Counter(),
"types": Counter(),
"firstnames": Counter(),
"waferdescs": Counter(),
}
for signature, count in signature_counter.items():
wo, lot, pkg, pj = signature_fields.get(signature, ("", "", "", ""))
wo, lot, pkg, pj, first, wafer = signature_fields.get(signature, ("", "", "", "", "", ""))
if wo:
counters["workorders"][wo] += count
if lot:
@@ -344,6 +434,10 @@ def _build_field_counters(
counters["packages"][pkg] += count
if pj:
counters["types"][pj] += count
if first:
counters["firstnames"][first] += count
if wafer:
counters["waferdescs"][wafer] += count
return counters
@@ -362,11 +456,15 @@ def _materialize_search_payload(
lotids = sorted(field_counters["lotids"].keys())
packages = sorted(field_counters["packages"].keys())
types = sorted(field_counters["types"].keys())
firstnames = sorted(field_counters["firstnames"].keys())
waferdescs = sorted(field_counters["waferdescs"].keys())
memory_bytes = (
_estimate_counter_payload_bytes(field_counters["workorders"])
+ _estimate_counter_payload_bytes(field_counters["lotids"])
+ _estimate_counter_payload_bytes(field_counters["packages"])
+ _estimate_counter_payload_bytes(field_counters["types"])
+ _estimate_counter_payload_bytes(field_counters["firstnames"])
+ _estimate_counter_payload_bytes(field_counters["waferdescs"])
)
return {
"version": version,
@@ -376,6 +474,8 @@ def _materialize_search_payload(
"lotids": lotids,
"packages": packages,
"types": types,
"firstnames": firstnames,
"waferdescs": waferdescs,
"sync_mode": mode,
"sync_added_rows": int(added_rows),
"sync_removed_rows": int(removed_rows),
@@ -387,6 +487,8 @@ def _materialize_search_payload(
"lotids": dict(field_counters["lotids"]),
"packages": dict(field_counters["packages"]),
"types": dict(field_counters["types"]),
"firstnames": dict(field_counters["firstnames"]),
"waferdescs": dict(field_counters["waferdescs"]),
},
}
@@ -410,7 +512,7 @@ def _try_incremental_search_sync(
version: str,
row_count: int,
signature_counter: Counter,
signature_fields: Dict[str, tuple[str, str, str, str]],
signature_fields: Dict[str, tuple[str, str, str, str, str, str]],
) -> Optional[Dict[str, Any]]:
if not previous:
return None
@@ -432,10 +534,12 @@ def _try_incremental_search_sync(
"lotids": Counter(old_field_counters_raw.get("lotids") or {}),
"packages": Counter(old_field_counters_raw.get("packages") or {}),
"types": Counter(old_field_counters_raw.get("types") or {}),
"firstnames": Counter(old_field_counters_raw.get("firstnames") or {}),
"waferdescs": Counter(old_field_counters_raw.get("waferdescs") or {}),
}
for signature, count in added.items():
wo, lot, pkg, pj = signature_fields.get(signature, ("", "", "", ""))
wo, lot, pkg, pj, first, wafer = signature_fields.get(signature, ("", "", "", "", "", ""))
if wo:
field_counters["workorders"][wo] += count
if lot:
@@ -444,13 +548,14 @@ def _try_incremental_search_sync(
field_counters["packages"][pkg] += count
if pj:
field_counters["types"][pj] += count
if first:
field_counters["firstnames"][first] += count
if wafer:
field_counters["waferdescs"][wafer] += count
previous_fields = {
sig: tuple(str(v) for v in sig.split("\x1f", 3))
for sig in old_signature_counter.keys()
}
previous_fields = {sig: _decode_signature_fields(sig) for sig in old_signature_counter.keys()}
for signature, count in removed.items():
wo, lot, pkg, pj = previous_fields.get(signature, ("", "", "", ""))
wo, lot, pkg, pj, first, wafer = previous_fields.get(signature, ("", "", "", "", "", ""))
if wo:
field_counters["workorders"][wo] -= count
if field_counters["workorders"][wo] <= 0:
@@ -467,6 +572,14 @@ def _try_incremental_search_sync(
field_counters["types"][pj] -= count
if field_counters["types"][pj] <= 0:
field_counters["types"].pop(pj, None)
if first:
field_counters["firstnames"][first] -= count
if field_counters["firstnames"][first] <= 0:
field_counters["firstnames"].pop(first, None)
if wafer:
field_counters["waferdescs"][wafer] -= count
if field_counters["waferdescs"][wafer] <= 0:
field_counters["waferdescs"].pop(wafer, None)
_increment_wip_metric("search_index_incremental_updates")
return _materialize_search_payload(
@@ -495,6 +608,8 @@ def _build_wip_snapshot(df: pd.DataFrame, include_dummy: bool, version: str) ->
"workcenter": _build_value_index(filtered, "WORKCENTER_GROUP"),
"package": _build_value_index(filtered, "PACKAGE_LEF"),
"pj_type": _build_value_index(filtered, "PJ_TYPE"),
"firstname": _build_value_index(filtered, "FIRSTNAME"),
"waferdesc": _build_value_index(filtered, "WAFERDESC"),
"wip_status": _build_value_index(filtered, "WIP_STATUS"),
"hold_type": _build_value_index(pd.DataFrame({"HOLD_TYPE": hold_type_series}), "HOLD_TYPE"),
}
@@ -600,6 +715,8 @@ def get_wip_search_index_status() -> Dict[str, Any]:
"lotids": len(payload.get("lotids", [])),
"packages": len(payload.get("packages", [])),
"types": len(payload.get("types", [])),
"firstnames": len(payload.get("firstnames", [])),
"waferdescs": len(payload.get("waferdescs", [])),
"sync_mode": payload.get("sync_mode"),
"sync_added_rows": payload.get("sync_added_rows", 0),
"sync_removed_rows": payload.get("sync_removed_rows", 0),
@@ -716,7 +833,9 @@ def get_wip_summary(
workorder: Optional[str] = None,
lotid: Optional[str] = None,
package: Optional[str] = None,
pj_type: Optional[str] = None
pj_type: Optional[str] = None,
firstname: Optional[str] = None,
waferdesc: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Get WIP KPI summary for overview dashboard.
@@ -728,6 +847,8 @@ def get_wip_summary(
lotid: Optional LOTID filter (fuzzy match)
package: Optional PACKAGE_LEF filter (exact match)
pj_type: Optional PJ_TYPE filter (exact match)
firstname: Optional FIRSTNAME filter (exact match)
waferdesc: Optional WAFERDESC filter (exact match)
Returns:
Dict with summary stats (camelCase):
@@ -746,9 +867,19 @@ def get_wip_summary(
lotid=lotid,
package=package,
pj_type=pj_type,
firstname=firstname,
waferdesc=waferdesc,
)
if df is None:
return _get_wip_summary_from_oracle(include_dummy, workorder, lotid, package, pj_type)
return _get_wip_summary_from_oracle(
include_dummy,
workorder,
lotid,
package,
pj_type,
firstname,
waferdesc,
)
if df.empty:
return {
@@ -804,7 +935,15 @@ def get_wip_summary(
logger.warning(f"Cache-based summary calculation failed, falling back to Oracle: {exc}")
# Fallback to Oracle direct query
return _get_wip_summary_from_oracle(include_dummy, workorder, lotid, package, pj_type)
return _get_wip_summary_from_oracle(
include_dummy,
workorder,
lotid,
package,
pj_type,
firstname,
waferdesc,
)
def _get_wip_summary_from_oracle(
@@ -812,17 +951,19 @@ def _get_wip_summary_from_oracle(
workorder: Optional[str] = None,
lotid: Optional[str] = None,
package: Optional[str] = None,
pj_type: Optional[str] = None
pj_type: Optional[str] = None,
firstname: Optional[str] = None,
waferdesc: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Get WIP summary directly from Oracle (fallback)."""
try:
# Build conditions using QueryBuilder
builder = _build_base_conditions_builder(include_dummy, workorder, lotid)
if package:
builder.add_param_condition("PACKAGE_LEF", package)
if pj_type:
builder.add_param_condition("PJ_TYPE", pj_type)
_add_exact_filter_conditions(builder, "PACKAGE_LEF", package)
_add_exact_filter_conditions(builder, "PJ_TYPE", pj_type)
_add_exact_filter_conditions(builder, "FIRSTNAME", firstname)
_add_exact_filter_conditions(builder, "WAFERDESC", waferdesc)
# Load SQL template and build query
base_sql = SQLLoader.load("wip/summary")
@@ -881,7 +1022,9 @@ def get_wip_matrix(
hold_type: Optional[str] = None,
reason: Optional[str] = None,
package: Optional[str] = None,
pj_type: Optional[str] = None
pj_type: Optional[str] = None,
firstname: Optional[str] = None,
waferdesc: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Get workcenter x product line matrix for overview dashboard.
@@ -898,6 +1041,8 @@ def get_wip_matrix(
Only effective when status='HOLD'
package: Optional PACKAGE_LEF filter (exact match)
pj_type: Optional PJ_TYPE filter (exact match)
firstname: Optional FIRSTNAME filter (exact match)
waferdesc: Optional WAFERDESC filter (exact match)
Returns:
Dict with matrix data:
@@ -921,6 +1066,8 @@ def get_wip_matrix(
lotid=lotid,
package=package,
pj_type=pj_type,
firstname=firstname,
waferdesc=waferdesc,
status=status_upper,
hold_type=hold_type_filter,
)
@@ -934,6 +1081,8 @@ def get_wip_matrix(
reason,
package,
pj_type,
firstname,
waferdesc,
)
if reason_filter:
@@ -968,6 +1117,8 @@ def get_wip_matrix(
reason,
package,
pj_type,
firstname,
waferdesc,
)
@@ -1032,7 +1183,9 @@ def _get_wip_matrix_from_oracle(
hold_type: Optional[str] = None,
reason: Optional[str] = None,
package: Optional[str] = None,
pj_type: Optional[str] = None
pj_type: Optional[str] = None,
firstname: Optional[str] = None,
waferdesc: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Get WIP matrix directly from Oracle (fallback)."""
try:
@@ -1041,10 +1194,10 @@ def _get_wip_matrix_from_oracle(
builder.add_is_not_null("WORKCENTER_GROUP")
builder.add_is_not_null("PACKAGE_LEF")
if package:
builder.add_param_condition("PACKAGE_LEF", package)
if pj_type:
builder.add_param_condition("PJ_TYPE", pj_type)
_add_exact_filter_conditions(builder, "PACKAGE_LEF", package)
_add_exact_filter_conditions(builder, "PJ_TYPE", pj_type)
_add_exact_filter_conditions(builder, "FIRSTNAME", firstname)
_add_exact_filter_conditions(builder, "WAFERDESC", waferdesc)
# WIP status filter
if status:
@@ -1091,7 +1244,11 @@ def _get_wip_matrix_from_oracle(
def get_wip_hold_summary(
include_dummy: bool = False,
workorder: Optional[str] = None,
lotid: Optional[str] = None
lotid: Optional[str] = None,
package: Optional[str] = None,
pj_type: Optional[str] = None,
firstname: Optional[str] = None,
waferdesc: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Get hold summary grouped by hold reason.
@@ -1101,6 +1258,10 @@ def get_wip_hold_summary(
include_dummy: If True, include DUMMY lots (default: False)
workorder: Optional WORKORDER filter (fuzzy match)
lotid: Optional LOTID filter (fuzzy match)
package: Optional PACKAGE_LEF filter (exact match)
pj_type: Optional PJ_TYPE filter (exact match)
firstname: Optional FIRSTNAME filter (exact match)
waferdesc: Optional WAFERDESC filter (exact match)
Returns:
Dict with hold items sorted by lots desc:
@@ -1114,10 +1275,22 @@ def get_wip_hold_summary(
include_dummy=include_dummy,
workorder=workorder,
lotid=lotid,
package=package,
pj_type=pj_type,
firstname=firstname,
waferdesc=waferdesc,
status='HOLD',
)
if df is None:
return _get_wip_hold_summary_from_oracle(include_dummy, workorder, lotid)
return _get_wip_hold_summary_from_oracle(
include_dummy,
workorder,
lotid,
package,
pj_type,
firstname,
waferdesc,
)
# Filter for HOLD status with reason
df = df[df['HOLDREASONNAME'].notna()]
@@ -1150,13 +1323,25 @@ def get_wip_hold_summary(
logger.warning(f"Cache-based hold summary calculation failed, falling back to Oracle: {exc}")
# Fallback to Oracle direct query
return _get_wip_hold_summary_from_oracle(include_dummy, workorder, lotid)
return _get_wip_hold_summary_from_oracle(
include_dummy,
workorder,
lotid,
package,
pj_type,
firstname,
waferdesc,
)
def _get_wip_hold_summary_from_oracle(
include_dummy: bool = False,
workorder: Optional[str] = None,
lotid: Optional[str] = None
lotid: Optional[str] = None,
package: Optional[str] = None,
pj_type: Optional[str] = None,
firstname: Optional[str] = None,
waferdesc: Optional[str] = None,
) -> Optional[Dict[str, Any]]:
"""Get WIP hold summary directly from Oracle (fallback)."""
try:
@@ -1164,6 +1349,10 @@ def _get_wip_hold_summary_from_oracle(
builder = _build_base_conditions_builder(include_dummy, workorder, lotid)
builder.add_param_condition("STATUS", "HOLD")
builder.add_is_not_null("HOLDREASONNAME")
_add_exact_filter_conditions(builder, "PACKAGE_LEF", package)
_add_exact_filter_conditions(builder, "PJ_TYPE", pj_type)
_add_exact_filter_conditions(builder, "FIRSTNAME", firstname)
_add_exact_filter_conditions(builder, "WAFERDESC", waferdesc)
where_clause, params = builder.build_where_only()
@@ -1208,6 +1397,8 @@ def get_wip_detail(
workcenter: str,
package: Optional[str] = None,
pj_type: Optional[str] = None,
firstname: Optional[str] = None,
waferdesc: Optional[str] = None,
status: Optional[str] = None,
hold_type: Optional[str] = None,
workorder: Optional[str] = None,
@@ -1224,6 +1415,8 @@ def get_wip_detail(
workcenter: WORKCENTER_GROUP name
package: Optional PACKAGE_LEF filter
pj_type: Optional PJ_TYPE filter (exact match)
firstname: Optional FIRSTNAME filter (exact match)
waferdesc: Optional WAFERDESC filter (exact match)
status: Optional WIP status filter ('RUN', 'QUEUE', 'HOLD')
hold_type: Optional hold type filter ('quality', 'non-quality')
Only effective when status='HOLD'
@@ -1252,6 +1445,8 @@ def get_wip_detail(
lotid=lotid,
package=package,
pj_type=pj_type,
firstname=firstname,
waferdesc=waferdesc,
workcenter=workcenter,
)
if summary_df is None:
@@ -1259,6 +1454,8 @@ def get_wip_detail(
workcenter,
package,
pj_type,
firstname,
waferdesc,
status,
hold_type,
workorder,
@@ -1308,6 +1505,8 @@ def get_wip_detail(
lotid=lotid,
package=package,
pj_type=pj_type,
firstname=firstname,
waferdesc=waferdesc,
workcenter=workcenter,
status=status_upper,
hold_type=hold_type_filter,
@@ -1317,6 +1516,8 @@ def get_wip_detail(
workcenter,
package,
pj_type,
firstname,
waferdesc,
status,
hold_type,
workorder,
@@ -1374,7 +1575,18 @@ def get_wip_detail(
# Fallback to Oracle direct query
return _get_wip_detail_from_oracle(
workcenter, package, pj_type, status, hold_type, workorder, lotid, include_dummy, page, page_size
workcenter,
package,
pj_type,
firstname,
waferdesc,
status,
hold_type,
workorder,
lotid,
include_dummy,
page,
page_size,
)
@@ -1382,6 +1594,8 @@ def _get_wip_detail_from_oracle(
workcenter: str,
package: Optional[str] = None,
pj_type: Optional[str] = None,
firstname: Optional[str] = None,
waferdesc: Optional[str] = None,
status: Optional[str] = None,
hold_type: Optional[str] = None,
workorder: Optional[str] = None,
@@ -1396,10 +1610,10 @@ def _get_wip_detail_from_oracle(
builder = _build_base_conditions_builder(include_dummy, workorder, lotid)
builder.add_param_condition("WORKCENTER_GROUP", workcenter)
if package:
builder.add_param_condition("PACKAGE_LEF", package)
if pj_type:
builder.add_param_condition("PJ_TYPE", pj_type)
_add_exact_filter_conditions(builder, "PACKAGE_LEF", package)
_add_exact_filter_conditions(builder, "PJ_TYPE", pj_type)
_add_exact_filter_conditions(builder, "FIRSTNAME", firstname)
_add_exact_filter_conditions(builder, "WAFERDESC", waferdesc)
# WIP status filter (RUN/QUEUE/HOLD based on EQUIPMENTCOUNT and CURRENTHOLDCOUNT)
if status:
@@ -1419,10 +1633,10 @@ def _get_wip_detail_from_oracle(
# Build summary conditions (without status/hold_type filter for full breakdown)
summary_builder = _build_base_conditions_builder(include_dummy, workorder, lotid)
summary_builder.add_param_condition("WORKCENTER_GROUP", workcenter)
if package:
summary_builder.add_param_condition("PACKAGE_LEF", package)
if pj_type:
summary_builder.add_param_condition("PJ_TYPE", pj_type)
_add_exact_filter_conditions(summary_builder, "PACKAGE_LEF", package)
_add_exact_filter_conditions(summary_builder, "PJ_TYPE", pj_type)
_add_exact_filter_conditions(summary_builder, "FIRSTNAME", firstname)
_add_exact_filter_conditions(summary_builder, "WAFERDESC", waferdesc)
summary_where, summary_params = summary_builder.build_where_only()
non_quality_list = CommonFilters.get_non_quality_reasons_sql()
@@ -1718,6 +1932,202 @@ def _get_packages_from_oracle(include_dummy: bool = False) -> Optional[List[Dict
return None
def _distinct_non_empty_values(df: pd.DataFrame, column: str) -> List[str]:
if df is None or df.empty or column not in df.columns:
return []
values = (
df[column]
.map(_normalize_text_value)
.tolist()
)
return sorted({value for value in values if value})
def _build_filter_options_payload(df: pd.DataFrame) -> Dict[str, List[str]]:
return {
"workorders": _distinct_non_empty_values(df, "WORKORDER"),
"lotids": _distinct_non_empty_values(df, "LOTID"),
"packages": _distinct_non_empty_values(df, "PACKAGE_LEF"),
"types": _distinct_non_empty_values(df, "PJ_TYPE"),
"firstnames": _distinct_non_empty_values(df, "FIRSTNAME"),
"waferdescs": _distinct_non_empty_values(df, "WAFERDESC"),
}
def _query_distinct_values_from_oracle(
column: str,
include_dummy: bool = False,
workorder: Optional[str] = None,
lotid: Optional[str] = None,
package: Optional[str] = None,
pj_type: Optional[str] = None,
firstname: Optional[str] = None,
waferdesc: Optional[str] = None,
exclude_field: Optional[str] = None,
) -> Optional[List[str]]:
try:
builder = _build_base_conditions_builder(
include_dummy=include_dummy,
workorder=None if exclude_field == "workorder" else workorder,
lotid=None if exclude_field == "lotid" else lotid,
)
builder.add_is_not_null(column)
if exclude_field != "package":
_add_exact_filter_conditions(builder, "PACKAGE_LEF", package)
if exclude_field != "pj_type":
_add_exact_filter_conditions(builder, "PJ_TYPE", pj_type)
if exclude_field != "firstname":
_add_exact_filter_conditions(builder, "FIRSTNAME", firstname)
if exclude_field != "waferdesc":
_add_exact_filter_conditions(builder, "WAFERDESC", waferdesc)
where_clause, params = builder.build_where_only()
sql = f"""
SELECT DISTINCT {column}
FROM {WIP_VIEW}
{where_clause}
ORDER BY {column}
"""
df = read_sql_df(sql, params)
if df is None or df.empty:
return []
values = df[column].map(_normalize_text_value).tolist()
return sorted({value for value in values if value})
except (DatabasePoolExhaustedError, DatabaseCircuitOpenError):
raise
except Exception as exc:
logger.error(f"Distinct value query failed for {column}: {exc}")
return None
def _get_wip_filter_options_from_oracle(
include_dummy: bool = False,
workorder: Optional[str] = None,
lotid: Optional[str] = None,
package: Optional[str] = None,
pj_type: Optional[str] = None,
firstname: Optional[str] = None,
waferdesc: Optional[str] = None,
) -> Optional[Dict[str, List[str]]]:
columns = {
"workorder": ("workorders", "WORKORDER"),
"lotid": ("lotids", "LOTID"),
"package": ("packages", "PACKAGE_LEF"),
"pj_type": ("types", "PJ_TYPE"),
"firstname": ("firstnames", "FIRSTNAME"),
"waferdesc": ("waferdescs", "WAFERDESC"),
}
payload: Dict[str, List[str]] = {}
for field, (key, column) in columns.items():
values = _query_distinct_values_from_oracle(
column,
include_dummy=include_dummy,
workorder=workorder,
lotid=lotid,
package=package,
pj_type=pj_type,
firstname=firstname,
waferdesc=waferdesc,
exclude_field=field,
)
if values is None:
return None
payload[key] = values
return payload
def _get_filter_options_cache_payload(
*,
include_dummy: bool,
workorder: Optional[str] = None,
lotid: Optional[str] = None,
package: Optional[str] = None,
pj_type: Optional[str] = None,
firstname: Optional[str] = None,
waferdesc: Optional[str] = None,
) -> Optional[Dict[str, List[str]]]:
by_field = {
"workorder": ("workorders", "WORKORDER"),
"lotid": ("lotids", "LOTID"),
"package": ("packages", "PACKAGE_LEF"),
"pj_type": ("types", "PJ_TYPE"),
"firstname": ("firstnames", "FIRSTNAME"),
"waferdesc": ("waferdescs", "WAFERDESC"),
}
payload: Dict[str, List[str]] = {}
for field, (key, column) in by_field.items():
df = _select_with_snapshot_indexes(
include_dummy=include_dummy,
workorder=None if field == "workorder" else workorder,
lotid=None if field == "lotid" else lotid,
package=None if field == "package" else package,
pj_type=None if field == "pj_type" else pj_type,
firstname=None if field == "firstname" else firstname,
waferdesc=None if field == "waferdesc" else waferdesc,
)
if df is None:
return None
payload[key] = _distinct_non_empty_values(df, column)
return payload
def get_wip_filter_options(
include_dummy: bool = False,
workorder: Optional[str] = None,
lotid: Optional[str] = None,
package: Optional[str] = None,
pj_type: Optional[str] = None,
firstname: Optional[str] = None,
waferdesc: Optional[str] = None,
) -> Optional[Dict[str, List[str]]]:
"""Get interdependent filter option lists for WIP overview dropdowns."""
has_filter = any(
_split_csv_values(value)
for value in (workorder, lotid, package, pj_type, firstname, waferdesc)
)
indexed = _get_wip_search_index(include_dummy=include_dummy)
if indexed is not None and not has_filter:
return {
"workorders": list(indexed.get("workorders", [])),
"lotids": list(indexed.get("lotids", [])),
"packages": list(indexed.get("packages", [])),
"types": list(indexed.get("types", [])),
"firstnames": list(indexed.get("firstnames", [])),
"waferdescs": list(indexed.get("waferdescs", [])),
}
cached_df = _get_wip_dataframe()
if cached_df is not None:
try:
payload = _get_filter_options_cache_payload(
include_dummy=include_dummy,
workorder=workorder,
lotid=lotid,
package=package,
pj_type=pj_type,
firstname=firstname,
waferdesc=waferdesc,
)
if payload is not None:
return payload
except (DatabasePoolExhaustedError, DatabaseCircuitOpenError):
raise
except Exception as exc:
logger.warning(f"Cache-based filter options calculation failed, falling back to Oracle: {exc}")
return _get_wip_filter_options_from_oracle(
include_dummy=include_dummy,
workorder=workorder,
lotid=lotid,
package=package,
pj_type=pj_type,
firstname=firstname,
waferdesc=waferdesc,
)
# ============================================================
# Search API Functions
# ============================================================

View File

@@ -0,0 +1,19 @@
-- Reject History Analytics (Consolidated)
-- Replaces: summary.sql, trend.sql, reason_pareto.sql
-- Template slots:
-- BASE_WITH_CTE (base reject-history daily dataset SQL wrapped as CTE)
-- WHERE_CLAUSE (QueryBuilder-generated WHERE clause against alias b)
{{ BASE_WITH_CTE }}
SELECT
TRUNC(b.TXN_DAY) AS BUCKET_DATE,
b.LOSSREASONNAME AS REASON,
SUM(b.MOVEIN_QTY) AS MOVEIN_QTY,
SUM(b.REJECT_TOTAL_QTY) AS REJECT_TOTAL_QTY,
SUM(b.DEFECT_QTY) AS DEFECT_QTY,
SUM(b.AFFECTED_LOT_COUNT) AS AFFECTED_LOT_COUNT,
SUM(b.AFFECTED_WORKORDER_COUNT) AS AFFECTED_WORKORDER_COUNT
FROM base b
{{ WHERE_CLAUSE }}
GROUP BY TRUNC(b.TXN_DAY), b.LOSSREASONNAME
ORDER BY BUCKET_DATE, REASON

View File

@@ -0,0 +1,17 @@
-- Reject History Filter Options (Consolidated)
-- Replaces: reason_options.sql, package_options.sql, material_reason_option.sql
-- Template slots:
-- BASE_WITH_CTE (base reject-history daily dataset SQL wrapped as CTE)
-- WHERE_CLAUSE (QueryBuilder-generated WHERE clause against alias b)
{{ BASE_WITH_CTE }}
SELECT
b.LOSSREASONNAME AS REASON,
b.PRODUCTLINENAME AS PACKAGE,
b.SCRAP_OBJECTTYPE,
SUM(b.REJECT_TOTAL_QTY) AS REJECT_TOTAL_QTY,
SUM(b.DEFECT_QTY) AS DEFECT_QTY
FROM base b
{{ WHERE_CLAUSE }}
GROUP BY b.LOSSREASONNAME, b.PRODUCTLINENAME, b.SCRAP_OBJECTTYPE
HAVING SUM(b.REJECT_TOTAL_QTY) > 0 OR SUM(b.DEFECT_QTY) > 0

View File

@@ -1,6 +1,6 @@
-- Reject History Detail List (Paginated)
-- Reject History Detail List (Paginated, Per-LOT)
-- Template slots:
-- BASE_QUERY (base reject-history daily dataset SQL)
-- BASE_WITH_CTE (lot-level base SQL via performance_daily_lot)
-- WHERE_CLAUSE (QueryBuilder-generated WHERE clause against alias b)
{{ BASE_WITH_CTE }},
@@ -10,39 +10,52 @@ filtered AS (
COUNT(*) OVER () AS TOTAL_COUNT
FROM base b
{{ WHERE_CLAUSE }}
),
paged AS (
SELECT *
FROM filtered
ORDER BY
TXN_DAY DESC,
WORKCENTERSEQUENCE_GROUP ASC,
WORKCENTERNAME ASC,
REJECT_TOTAL_QTY DESC,
CONTAINERNAME ASC
OFFSET :offset ROWS FETCH NEXT :limit ROWS ONLY
)
SELECT
TXN_DAY,
TXN_MONTH,
WORKCENTER_GROUP,
WORKCENTERSEQUENCE_GROUP,
WORKCENTERNAME,
SPECNAME,
EQUIPMENTNAME,
PRIMARY_EQUIPMENTNAME,
PRODUCTLINENAME,
PJ_TYPE,
LOSSREASONNAME,
LOSSREASON_CODE,
REJECT_EVENT_ROWS,
AFFECTED_LOT_COUNT,
AFFECTED_WORKORDER_COUNT,
MOVEIN_QTY,
REJECT_QTY,
REJECT_TOTAL_QTY,
DEFECT_QTY,
STANDBY_QTY,
QTYTOPROCESS_QTY,
INPROCESS_QTY,
PROCESSED_QTY,
REJECT_RATE_PCT,
DEFECT_RATE_PCT,
REJECT_SHARE_PCT,
TOTAL_COUNT
FROM filtered
p.TXN_DAY,
p.TXN_MONTH,
p.WORKCENTER_GROUP,
p.WORKCENTERSEQUENCE_GROUP,
p.WORKCENTERNAME,
p.SPECNAME,
p.EQUIPMENTNAME,
p.PRIMARY_EQUIPMENTNAME,
p.PRODUCTLINENAME,
p.PJ_TYPE,
p.CONTAINERNAME,
p.PJ_FUNCTION,
p.PRODUCTNAME,
p.LOSSREASONNAME,
p.LOSSREASON_CODE,
p.REJECT_EVENT_ROWS,
p.AFFECTED_WORKORDER_COUNT,
p.MOVEIN_QTY,
p.REJECT_QTY,
p.REJECT_TOTAL_QTY,
p.DEFECT_QTY,
p.STANDBY_QTY,
p.QTYTOPROCESS_QTY,
p.INPROCESS_QTY,
p.PROCESSED_QTY,
p.REJECT_RATE_PCT,
p.DEFECT_RATE_PCT,
p.REJECT_SHARE_PCT,
p.TOTAL_COUNT
FROM paged p
ORDER BY
TXN_DAY DESC,
WORKCENTERSEQUENCE_GROUP ASC,
WORKCENTERNAME ASC,
REJECT_TOTAL_QTY DESC
OFFSET :offset ROWS FETCH NEXT :limit ROWS ONLY
p.TXN_DAY DESC,
p.WORKCENTERSEQUENCE_GROUP ASC,
p.WORKCENTERNAME ASC,
p.REJECT_TOTAL_QTY DESC,
p.CONTAINERNAME ASC

View File

@@ -0,0 +1,165 @@
-- Reject History Performance (Daily Grain, Per-LOT)
-- Like performance_daily.sql but keeps CONTAINERID in GROUP BY for per-LOT detail rows.
--
-- Parameters:
-- :start_date - Start date (YYYY-MM-DD)
-- :end_date - End date (YYYY-MM-DD)
WITH workcenter_map AS (
SELECT
WORK_CENTER,
MIN(WORK_CENTER_GROUP) KEEP (
DENSE_RANK FIRST ORDER BY WORKCENTERSEQUENCE_GROUP
) AS WORKCENTER_GROUP,
MIN(WORKCENTERSEQUENCE_GROUP) AS WORKCENTERSEQUENCE_GROUP
FROM DWH.DW_MES_SPEC_WORKCENTER_V
WHERE WORK_CENTER IS NOT NULL
GROUP BY WORK_CENTER
),
reject_raw AS (
SELECT
TRUNC(r.TXNDATE) AS TXN_DAY,
TO_CHAR(TRUNC(r.TXNDATE), 'YYYY-MM') AS TXN_MONTH,
r.CONTAINERID,
NVL(TRIM(c.CONTAINERNAME), TRIM(r.CONTAINERID)) AS CONTAINERNAME,
NVL(TRIM(r.PJ_WORKORDER), TRIM(c.MFGORDERNAME)) AS PJ_WORKORDER,
NVL(TRIM(c.PJ_TYPE), '(NA)') AS PJ_TYPE,
NVL(TRIM(c.PJ_FUNCTION), '(NA)') AS PJ_FUNCTION,
NVL(TRIM(c.PRODUCTNAME), '(NA)') AS PRODUCTNAME,
NVL(TRIM(c.PRODUCTLINENAME), '(NA)') AS PRODUCTLINENAME,
NVL(TRIM(c.OBJECTTYPE), '(NA)') AS SCRAP_OBJECTTYPE,
NVL(TRIM(r.WORKCENTERNAME), '(NA)') AS WORKCENTERNAME,
NVL(TRIM(wm.WORKCENTER_GROUP), NVL(TRIM(r.WORKCENTERNAME), '(NA)')) AS WORKCENTER_GROUP,
NVL(wm.WORKCENTERSEQUENCE_GROUP, 999) AS WORKCENTERSEQUENCE_GROUP,
NVL(TRIM(r.SPECNAME), '(NA)') AS SPECNAME,
NVL(TRIM(r.EQUIPMENTNAME), '(NA)') AS EQUIPMENTNAME,
NVL(
TRIM(REGEXP_SUBSTR(r.EQUIPMENTNAME, '[^,]+', 1, 1)),
NVL(TRIM(r.EQUIPMENTNAME), '(NA)')
) AS PRIMARY_EQUIPMENTNAME,
NVL(TRIM(r.LOSSREASONNAME), '(未填寫)') AS LOSSREASONNAME,
NVL(
TRIM(REGEXP_SUBSTR(NVL(TRIM(r.LOSSREASONNAME), '(未填寫)'), '^[^_[:space:]-]+')),
NVL(TRIM(r.LOSSREASONNAME), '(未填寫)')
) AS LOSSREASON_CODE,
NVL(TRIM(r.REJECTCATEGORYNAME), '(未填寫)') AS REJECTCATEGORYNAME,
NVL(r.MOVEINQTY, 0) AS MOVEINQTY,
NVL(r.REJECTQTY, 0) AS REJECT_QTY,
NVL(r.STANDBYQTY, 0) AS STANDBY_QTY,
NVL(r.QTYTOPROCESS, 0) AS QTYTOPROCESS_QTY,
NVL(r.INPROCESSQTY, 0) AS INPROCESS_QTY,
NVL(r.PROCESSEDQTY, 0) AS PROCESSED_QTY,
NVL(r.REJECTQTY, 0)
+ NVL(r.STANDBYQTY, 0)
+ NVL(r.QTYTOPROCESS, 0)
+ NVL(r.INPROCESSQTY, 0)
+ NVL(r.PROCESSEDQTY, 0) AS REJECT_TOTAL_QTY,
NVL(r.DEFECTQTY, 0) AS DEFECT_QTY,
ROW_NUMBER() OVER (
PARTITION BY NVL(
TRIM(r.HISTORYMAINLINEID),
TRIM(r.CONTAINERID) || ':' || TO_CHAR(r.TXNDATE, 'YYYYMMDDHH24MISS') || ':' || NVL(TRIM(r.SPECID), '-')
)
ORDER BY NVL(TRIM(r.LOSSREASONNAME), ' ')
) AS EVENT_RN
FROM DWH.DW_MES_LOTREJECTHISTORY r
LEFT JOIN DWH.DW_MES_CONTAINER c
ON c.CONTAINERID = r.CONTAINERID
LEFT JOIN workcenter_map wm
ON wm.WORK_CENTER = r.WORKCENTERNAME
WHERE r.TXNDATE >= TO_DATE(:start_date, 'YYYY-MM-DD')
AND r.TXNDATE < TO_DATE(:end_date, 'YYYY-MM-DD') + 1
),
daily_agg AS (
SELECT
TXN_DAY,
TXN_MONTH,
CONTAINERID,
CONTAINERNAME,
PJ_FUNCTION,
PRODUCTNAME,
WORKCENTER_GROUP,
WORKCENTERSEQUENCE_GROUP,
WORKCENTERNAME,
SPECNAME,
EQUIPMENTNAME,
PRIMARY_EQUIPMENTNAME,
PRODUCTLINENAME,
SCRAP_OBJECTTYPE,
PJ_TYPE,
LOSSREASONNAME,
LOSSREASON_CODE,
REJECTCATEGORYNAME,
COUNT(*) AS REJECT_EVENT_ROWS,
COUNT(DISTINCT PJ_WORKORDER) AS AFFECTED_WORKORDER_COUNT,
SUM(CASE WHEN EVENT_RN = 1 THEN MOVEINQTY ELSE 0 END) AS MOVEIN_QTY,
SUM(REJECT_QTY) AS REJECT_QTY,
SUM(REJECT_TOTAL_QTY) AS REJECT_TOTAL_QTY,
SUM(DEFECT_QTY) AS DEFECT_QTY,
SUM(STANDBY_QTY) AS STANDBY_QTY,
SUM(QTYTOPROCESS_QTY) AS QTYTOPROCESS_QTY,
SUM(INPROCESS_QTY) AS INPROCESS_QTY,
SUM(PROCESSED_QTY) AS PROCESSED_QTY
FROM reject_raw
GROUP BY
TXN_DAY,
TXN_MONTH,
CONTAINERID,
CONTAINERNAME,
PJ_FUNCTION,
PRODUCTNAME,
WORKCENTER_GROUP,
WORKCENTERSEQUENCE_GROUP,
WORKCENTERNAME,
SPECNAME,
EQUIPMENTNAME,
PRIMARY_EQUIPMENTNAME,
PRODUCTLINENAME,
SCRAP_OBJECTTYPE,
PJ_TYPE,
LOSSREASONNAME,
LOSSREASON_CODE,
REJECTCATEGORYNAME
)
SELECT
TXN_DAY,
TXN_MONTH,
CONTAINERID,
CONTAINERNAME,
PJ_FUNCTION,
PRODUCTNAME,
WORKCENTER_GROUP,
WORKCENTERSEQUENCE_GROUP,
WORKCENTERNAME,
SPECNAME,
EQUIPMENTNAME,
PRIMARY_EQUIPMENTNAME,
PRODUCTLINENAME,
SCRAP_OBJECTTYPE,
PJ_TYPE,
LOSSREASONNAME,
LOSSREASON_CODE,
REJECTCATEGORYNAME,
REJECT_EVENT_ROWS,
AFFECTED_WORKORDER_COUNT,
MOVEIN_QTY,
REJECT_QTY,
REJECT_TOTAL_QTY,
DEFECT_QTY,
STANDBY_QTY,
QTYTOPROCESS_QTY,
INPROCESS_QTY,
PROCESSED_QTY,
CASE
WHEN MOVEIN_QTY = 0 THEN 0
ELSE ROUND(REJECT_TOTAL_QTY * 100 / MOVEIN_QTY, 4)
END AS REJECT_RATE_PCT,
CASE
WHEN MOVEIN_QTY = 0 THEN 0
ELSE ROUND(DEFECT_QTY * 100 / MOVEIN_QTY, 4)
END AS DEFECT_RATE_PCT,
CASE
WHEN (REJECT_TOTAL_QTY + DEFECT_QTY) = 0 THEN 0
ELSE ROUND(REJECT_TOTAL_QTY * 100 / (REJECT_TOTAL_QTY + DEFECT_QTY), 4)
END AS REJECT_SHARE_PCT
FROM daily_agg

View File

@@ -71,7 +71,8 @@ class TestOverviewSummaryRoute(TestWipRoutesBase):
}
self.client.get(
'/api/wip/overview/summary?workorder=WO1&lotid=L1&package=SOT-23&type=PJA&include_dummy=true'
'/api/wip/overview/summary?workorder=WO1&lotid=L1&package=SOT-23'
'&type=PJA&firstname=WF001&waferdesc=SiC&include_dummy=true'
)
mock_get_summary.assert_called_once_with(
@@ -79,7 +80,9 @@ class TestOverviewSummaryRoute(TestWipRoutesBase):
workorder='WO1',
lotid='L1',
package='SOT-23',
pj_type='PJA'
pj_type='PJA',
firstname='WF001',
waferdesc='SiC',
)
@@ -136,6 +139,35 @@ class TestOverviewMatrixRoute(TestWipRoutesBase):
self.assertFalse(data['success'])
self.assertIn('Invalid hold_type', data['error'])
@patch('mes_dashboard.routes.wip_routes.get_wip_matrix')
def test_passes_filters_to_service(self, mock_get_matrix):
"""Should pass overview matrix filters to service layer."""
mock_get_matrix.return_value = {
'workcenters': [],
'packages': [],
'matrix': {},
'workcenter_totals': {},
'package_totals': {},
'grand_total': 0,
}
self.client.get(
'/api/wip/overview/matrix?workorder=WO1&lotid=L1&package=SOT-23&type=PJA'
'&firstname=WF001&waferdesc=SiC&status=RUN&include_dummy=1'
)
mock_get_matrix.assert_called_once_with(
include_dummy=True,
workorder='WO1',
lotid='L1',
status='RUN',
hold_type=None,
package='SOT-23',
pj_type='PJA',
firstname='WF001',
waferdesc='SiC',
)
class TestOverviewHoldRoute(TestWipRoutesBase):
"""Test GET /api/wip/overview/hold endpoint."""
@@ -173,12 +205,19 @@ class TestOverviewHoldRoute(TestWipRoutesBase):
"""Should pass hold filter params to service layer."""
mock_get_hold.return_value = {'items': []}
self.client.get('/api/wip/overview/hold?workorder=WO1&lotid=L1&include_dummy=1')
self.client.get(
'/api/wip/overview/hold?workorder=WO1&lotid=L1&package=SOT-23&type=PJA'
'&firstname=WF001&waferdesc=SiC&include_dummy=1'
)
mock_get_hold.assert_called_once_with(
include_dummy=True,
workorder='WO1',
lotid='L1'
lotid='L1',
package='SOT-23',
pj_type='PJA',
firstname='WF001',
waferdesc='SiC',
)
@@ -234,13 +273,16 @@ class TestDetailRoute(TestWipRoutesBase):
}
response = self.client.get(
'/api/wip/detail/焊接_DB?package=SOT-23&status=RUN&page=2&page_size=50'
'/api/wip/detail/焊接_DB?package=SOT-23&type=PJA&firstname=WF001&waferdesc=SiC'
'&status=RUN&page=2&page_size=50'
)
mock_get_detail.assert_called_once_with(
workcenter='焊接_DB',
package='SOT-23',
pj_type=None,
pj_type='PJA',
firstname='WF001',
waferdesc='SiC',
status='RUN',
hold_type=None,
workorder=None,
@@ -412,6 +454,87 @@ class TestMetaPackagesRoute(TestWipRoutesBase):
self.assertFalse(data['success'])
class TestMetaFilterOptionsRoute(TestWipRoutesBase):
"""Test GET /api/wip/meta/filter-options endpoint."""
@patch('mes_dashboard.routes.wip_routes.get_wip_filter_options')
def test_returns_success_with_options(self, mock_get_options):
mock_get_options.return_value = {
'workorders': ['WO1'],
'lotids': ['LOT1'],
'packages': ['PKG1'],
'types': ['TYPE1'],
'firstnames': ['WF001'],
'waferdescs': ['SiC'],
}
response = self.client.get('/api/wip/meta/filter-options')
data = json.loads(response.data)
self.assertEqual(response.status_code, 200)
self.assertTrue(data['success'])
self.assertEqual(data['data']['workorders'], ['WO1'])
self.assertEqual(data['data']['waferdescs'], ['SiC'])
@patch('mes_dashboard.routes.wip_routes.get_wip_filter_options')
def test_passes_include_dummy_flag(self, mock_get_options):
mock_get_options.return_value = {
'workorders': [],
'lotids': [],
'packages': [],
'types': [],
'firstnames': [],
'waferdescs': [],
}
self.client.get('/api/wip/meta/filter-options?include_dummy=true')
mock_get_options.assert_called_once_with(
include_dummy=True,
workorder=None,
lotid=None,
package=None,
pj_type=None,
firstname=None,
waferdesc=None,
)
@patch('mes_dashboard.routes.wip_routes.get_wip_filter_options')
def test_passes_cross_filter_parameters(self, mock_get_options):
mock_get_options.return_value = {
'workorders': ['WO1'],
'lotids': ['LOT1'],
'packages': ['PKG1'],
'types': ['TYPE1'],
'firstnames': ['WF001'],
'waferdescs': ['SiC'],
}
self.client.get(
'/api/wip/meta/filter-options?workorder=WO1,WO2&lotid=L1&package=PKG1'
'&type=PJA&firstname=WF001&waferdesc=SiC'
)
mock_get_options.assert_called_once_with(
include_dummy=False,
workorder='WO1,WO2',
lotid='L1',
package='PKG1',
pj_type='PJA',
firstname='WF001',
waferdesc='SiC',
)
@patch('mes_dashboard.routes.wip_routes.get_wip_filter_options')
def test_returns_error_on_failure(self, mock_get_options):
mock_get_options.return_value = None
response = self.client.get('/api/wip/meta/filter-options')
data = json.loads(response.data)
self.assertEqual(response.status_code, 500)
self.assertFalse(data['success'])
class TestPageRoutes(TestWipRoutesBase):
"""Test page routes for WIP dashboards."""

View File

@@ -23,6 +23,7 @@ from mes_dashboard.services.wip_service import (
get_hold_overview_treemap,
get_workcenters,
get_packages,
get_wip_filter_options,
search_workorders,
search_lot_ids,
)
@@ -287,6 +288,84 @@ class TestGetPackages(unittest.TestCase):
self.assertEqual(result, [])
class TestGetWipFilterOptions(unittest.TestCase):
"""Test get_wip_filter_options function."""
def setUp(self):
import mes_dashboard.services.wip_service as wip_service
with wip_service._wip_search_index_lock:
wip_service._wip_search_index_cache.clear()
with wip_service._wip_snapshot_lock:
wip_service._wip_snapshot_cache.clear()
@patch('mes_dashboard.services.wip_service._get_wip_search_index')
def test_prefers_search_index_payload(self, mock_get_index):
mock_get_index.return_value = {
'workorders': ['WO1', 'WO2'],
'lotids': ['LOT1'],
'packages': ['PKG1'],
'types': ['TYPE1'],
'firstnames': ['WF001'],
'waferdescs': ['SiC'],
}
result = get_wip_filter_options()
self.assertEqual(result['workorders'], ['WO1', 'WO2'])
self.assertEqual(result['firstnames'], ['WF001'])
self.assertEqual(result['waferdescs'], ['SiC'])
@patch('mes_dashboard.services.wip_service._get_wip_search_index', return_value=None)
@patch('mes_dashboard.services.wip_service._get_wip_dataframe')
def test_interdependent_options_follow_cross_filters(self, mock_cached_wip, _mock_get_index):
mock_cached_wip.return_value = pd.DataFrame({
'WORKORDER': ['WO1', 'WO1', 'WO2'],
'LOTID': ['L1', 'L2', 'L3'],
'PACKAGE_LEF': ['PKG-A', 'PKG-B', 'PKG-B'],
'PJ_TYPE': ['TYPE-1', 'TYPE-1', 'TYPE-2'],
'FIRSTNAME': ['WF-A', 'WF-B', 'WF-A'],
'WAFERDESC': ['SiC', 'SiC', 'Si'],
'EQUIPMENTCOUNT': [0, 1, 0],
'CURRENTHOLDCOUNT': [1, 0, 0],
'QTY': [10, 20, 30],
'HOLDREASONNAME': ['Q-Check', None, None],
'WORKCENTER_GROUP': ['WC-A', 'WC-A', 'WC-B'],
})
result = get_wip_filter_options(workorder='WO1')
# Exclude-self semantics: workorder options still show values allowed by other filters.
self.assertEqual(result['workorders'], ['WO1', 'WO2'])
self.assertEqual(result['lotids'], ['L1', 'L2'])
self.assertEqual(result['types'], ['TYPE-1'])
self.assertEqual(result['waferdescs'], ['SiC'])
@patch('mes_dashboard.services.wip_service._get_wip_search_index', return_value=None)
@patch('mes_dashboard.services.wip_service._select_with_snapshot_indexes')
@patch('mes_dashboard.services.wip_service._get_wip_dataframe')
def test_falls_back_to_cache_dataframe(
self,
mock_cached_wip,
mock_select_with_snapshot,
_mock_get_index,
):
mock_cached_wip.return_value = pd.DataFrame({'WORKORDER': ['WO1']})
mock_select_with_snapshot.return_value = pd.DataFrame({
'WORKORDER': ['WO2', 'WO1'],
'LOTID': ['LOT2', 'LOT1'],
'PACKAGE_LEF': ['PKG2', 'PKG1'],
'PJ_TYPE': ['TYPE2', 'TYPE1'],
'FIRSTNAME': ['WF002', 'WF001'],
'WAFERDESC': ['Si', 'SiC'],
})
result = get_wip_filter_options()
self.assertEqual(result['workorders'], ['WO1', 'WO2'])
self.assertEqual(result['firstnames'], ['WF001', 'WF002'])
self.assertEqual(result['waferdescs'], ['Si', 'SiC'])
class TestSearchWorkorders(unittest.TestCase):
"""Test search_workorders function."""
@@ -688,8 +767,9 @@ class TestMultipleFilterConditions(unittest.TestCase):
sql = call_args[0][0]
params = call_args[0][1] if len(call_args[0]) > 1 else {}
self.assertIn("WORKORDER LIKE", sql)
self.assertIn("LOTID LIKE", sql)
self.assertIn("WORKORDER", sql)
self.assertIn("LOTID", sql)
self.assertIn("LIKE", sql)
self.assertIn("LOTID NOT LIKE '%DUMMY%'", sql)
# Verify params contain the search patterns
self.assertTrue(any('%GA26%' in str(v) for v in params.values()))
@@ -714,8 +794,9 @@ class TestMultipleFilterConditions(unittest.TestCase):
sql = call_args[0][0]
params = call_args[0][1] if len(call_args[0]) > 1 else {}
self.assertIn("WORKORDER LIKE", sql)
self.assertIn("LOTID LIKE", sql)
self.assertIn("WORKORDER", sql)
self.assertIn("LOTID", sql)
self.assertIn("LIKE", sql)
# Should NOT contain DUMMY exclusion since include_dummy=True
self.assertNotIn("LOTID NOT LIKE '%DUMMY%'", sql)
# Verify params contain the search patterns