feat: polish reject history UI and enhance WIP filter interactions
This commit is contained in:
@@ -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) };
|
||||
}
|
||||
|
||||
@@ -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"
|
||||
<SummaryCards :cards="kpiCards" />
|
||||
|
||||
<TrendChart
|
||||
:items="trend.items"
|
||||
:selected-dates="selectedTrendDates"
|
||||
:loading="loading.querying"
|
||||
@date-click="onTrendDateClick"
|
||||
@legend-change="onTrendLegendChange"
|
||||
/>
|
||||
|
||||
<ParetoSection
|
||||
:items="filteredParetoItems"
|
||||
:detail-reason="detailReason"
|
||||
:selected-dates="selectedTrendDates"
|
||||
:metric-label="paretoMetricLabel"
|
||||
:loading="loading.querying"
|
||||
@reason-click="onParetoClick"
|
||||
/>
|
||||
|
||||
<DetailTable
|
||||
:items="detail.items"
|
||||
:pagination="pagination"
|
||||
:loading="loading.list"
|
||||
:detail-reason="detailReason"
|
||||
@go-to-page="goToPage"
|
||||
@clear-reason="onParetoClick(detailReason)"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
<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>
|
||||
|
||||
85
frontend/src/reject-history/components/DetailTable.vue
Normal file
85
frontend/src/reject-history/components/DetailTable.vue
Normal 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>
|
||||
108
frontend/src/reject-history/components/FilterPanel.vue
Normal file
108
frontend/src/reject-history/components/FilterPanel.vue
Normal 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>
|
||||
167
frontend/src/reject-history/components/ParetoSection.vue
Normal file
167
frontend/src/reject-history/components/ParetoSection.vue
Normal 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>
|
||||
27
frontend/src/reject-history/components/SummaryCards.vue
Normal file
27
frontend/src/reject-history/components/SummaryCards.vue
Normal 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>
|
||||
105
frontend/src/reject-history/components/TrendChart.vue
Normal file
105
frontend/src/reject-history/components/TrendChart.vue
Normal 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>
|
||||
@@ -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;
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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]"
|
||||
<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();
|
||||
"
|
||||
/>
|
||||
<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>
|
||||
</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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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" />
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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',
|
||||
});
|
||||
});
|
||||
|
||||
|
||||
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-22
|
||||
@@ -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 都一致變化
|
||||
- 下拉框可模糊搜尋、可多選、可清除
|
||||
@@ -0,0 +1,36 @@
|
||||
## Why
|
||||
|
||||
WIP 即時概況目前使用文字輸入搭配動態 autocomplete,使用者在多條件查詢時容易反覆輸入且選項不一致。改為可搜尋的下拉清單並新增 Wafer 維度篩選,可降低操作成本、提升查詢一致性,且能直接利用既有快取資料來源。
|
||||
|
||||
## What Changes
|
||||
|
||||
- 將 WIP 即時概況篩選 UI 從文字 autocomplete 改為可模糊搜尋的下拉清單(對齊設備即時概況機台篩選互動)
|
||||
- 新增兩個篩選欄位:`Wafer LOT`(資料欄位 `FIRSTNAME`)、`Wafer Type`(資料欄位 `WAFERDESC`)
|
||||
- 新增 WIP 篩選選項 API,一次回傳舊有與新增篩選欄位的候選值,優先由 WIP 快取衍生索引提供
|
||||
- WIP 概況查詢 API(summary/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 與參數仍保持相容,新增參數採選填。
|
||||
@@ -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
|
||||
@@ -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
|
||||
2
openspec/changes/reject-history-ui-polish/.openspec.yaml
Normal file
2
openspec/changes/reject-history-ui-polish/.openspec.yaml
Normal file
@@ -0,0 +1,2 @@
|
||||
schema: spec-driven
|
||||
created: 2026-02-22
|
||||
57
openspec/changes/reject-history-ui-polish/design.md
Normal file
57
openspec/changes/reject-history-ui-polish/design.md
Normal 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.
|
||||
28
openspec/changes/reject-history-ui-polish/proposal.md
Normal file
28
openspec/changes/reject-history-ui-polish/proposal.md
Normal 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`
|
||||
@@ -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
|
||||
26
openspec/changes/reject-history-ui-polish/tasks.md
Normal file
26
openspec/changes/reject-history-ui-polish/tasks.md
Normal 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
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
}
|
||||
|
||||
@@ -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
|
||||
# ============================================================
|
||||
|
||||
19
src/mes_dashboard/sql/reject_history/analytics.sql
Normal file
19
src/mes_dashboard/sql/reject_history/analytics.sql
Normal 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
|
||||
17
src/mes_dashboard/sql/reject_history/filter_options.sql
Normal file
17
src/mes_dashboard/sql/reject_history/filter_options.sql
Normal 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
|
||||
@@ -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 }}
|
||||
)
|
||||
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
|
||||
ORDER BY
|
||||
),
|
||||
paged AS (
|
||||
SELECT *
|
||||
FROM filtered
|
||||
ORDER BY
|
||||
TXN_DAY DESC,
|
||||
WORKCENTERSEQUENCE_GROUP ASC,
|
||||
WORKCENTERNAME ASC,
|
||||
REJECT_TOTAL_QTY DESC
|
||||
OFFSET :offset ROWS FETCH NEXT :limit ROWS ONLY
|
||||
REJECT_TOTAL_QTY DESC,
|
||||
CONTAINERNAME ASC
|
||||
OFFSET :offset ROWS FETCH NEXT :limit ROWS ONLY
|
||||
)
|
||||
SELECT
|
||||
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
|
||||
p.TXN_DAY DESC,
|
||||
p.WORKCENTERSEQUENCE_GROUP ASC,
|
||||
p.WORKCENTERNAME ASC,
|
||||
p.REJECT_TOTAL_QTY DESC,
|
||||
p.CONTAINERNAME ASC
|
||||
|
||||
165
src/mes_dashboard/sql/reject_history/performance_daily_lot.sql
Normal file
165
src/mes_dashboard/sql/reject_history/performance_daily_lot.sql
Normal 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
|
||||
@@ -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."""
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user